Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e34eeb1df | ||
|
|
873b45d3a5 | ||
|
|
774376fa16 | ||
|
|
381da60ae4 | ||
|
|
8bc6251899 | ||
|
|
2eb0d8e807 | ||
|
|
43a5373233 | ||
|
|
fed52930cc | ||
|
|
f5c416dd9d | ||
|
|
65ba3cc7a5 | ||
|
|
ac0b7ca42b | ||
|
|
459e21dad4 | ||
|
|
4af825a5e4 |
@@ -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,11 @@
|
||||
|
||||
- `h5/`:现有浏览器/微信公众号 H5、管理后台,以及共享后端 `server.js`。
|
||||
- `taobao/`:淘宝原生小程序。开发、联调、测试说明见 [taobao/README.md](taobao/README.md)。
|
||||
- `backend-java/`:SAE + 淘宝云网关的 Java 最小连通性 MVP;操作步骤见 [docs/SAE-Java-MVP-操作步骤.md](docs/SAE-Java-MVP-操作步骤.md)。
|
||||
- 当前交接(**换开发机先读这份**):[docs/HANDOFF-2026-11-02.md](docs/HANDOFF-2026-11-02.md)。
|
||||
- 上一轮交接:[docs/HANDOFF-2026-11-01.md](docs/HANDOFF-2026-11-01.md)。
|
||||
- 服务器侧配置(systemd / nginx `/tb/` 白名单 / 安装脚本):[deploy/README.md](deploy/README.md)。
|
||||
- `rednote/`:复用 H5 的持久浏览器身份入口,独立运行和存储。说明见 [rednote/README.md](rednote/README.md)。
|
||||
- `h5/js/skin-score.js`、`report-ui.js`、`report-copy.js`:业务规则的源文件。小程序内的副本由同步脚本生成并检查哈希。
|
||||
- `h5/lib/taobao-*.js`:淘宝网关验签、短期上传/异步任务和独立 API 路由。
|
||||
- `docs/`、`reference/`、`design/`、`feedback/`、`new_feedback/`:需求、接口资料、设计与历轮反馈;早期文档的规则可能已被后续实现替代。
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/* Loaded only by the rednote instance: persistence disclosure needs natural height. */
|
||||
#p-privacy .popup {
|
||||
width: min(308px, calc(100vw - 24px));
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
max-height: calc(100vh - 24px);
|
||||
overflow-y: auto;
|
||||
padding: 36px 32px 32px;
|
||||
top: 0;
|
||||
}
|
||||
#p-privacy .popup-title,
|
||||
#p-privacy .popup-body,
|
||||
#p-privacy .privacy-row,
|
||||
#p-privacy .popup-btn {
|
||||
position: static;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#p-privacy .popup-title { margin: 0 0 16px; }
|
||||
#p-privacy .popup-body { width: auto; margin: 0 0 18px; }
|
||||
#p-privacy .privacy-row { margin: 0; }
|
||||
#p-privacy .popup-btn { margin: 26px auto 0; }
|
||||
@@ -1,8 +1,10 @@
|
||||
/* Guest access uses an ephemeral server session, never a saved browser identity. */
|
||||
window.AccountClient = (function () {
|
||||
'use strict';
|
||||
var kindKey = 'juzi.auth.kind.v1', user = null, guestReady = false;
|
||||
var rednote = !!(window.JuziEntry && window.JuziEntry.mode === 'rednote');
|
||||
var kindKey = rednote ? 'juzi.rednote.auth.kind.v1' : 'juzi.auth.kind.v1', user = null, guestReady = false;
|
||||
function clearGuestCache() {
|
||||
if (rednote) return;
|
||||
try {
|
||||
localStorage.removeItem('juzi.identity.v1');
|
||||
localStorage.removeItem('juzi.reports.v1'); // Retired local guest-report cache.
|
||||
@@ -12,7 +14,7 @@ window.AccountClient = (function () {
|
||||
} catch (_) {}
|
||||
}
|
||||
function rememberKind() {
|
||||
try { if (user && user.kind === 'wechat') localStorage.setItem(kindKey, 'wechat'); else localStorage.removeItem(kindKey); } catch (_) {}
|
||||
try { if (user && (user.kind === 'wechat' || (rednote && user.kind === 'browser_persistent'))) localStorage.setItem(kindKey, user.kind); else localStorage.removeItem(kindKey); } catch (_) {}
|
||||
}
|
||||
function request(url, data) {
|
||||
var controller = new AbortController();
|
||||
@@ -43,7 +45,7 @@ window.AccountClient = (function () {
|
||||
function ensureGuest() {
|
||||
// The visitor entry is entirely local. Create temporary server access only
|
||||
// after explicit consent in the welcome dialog, not on entry/button tap.
|
||||
if (!user || user.kind === 'wechat' || guestReady) return Promise.resolve({ user: user });
|
||||
if (!user || user.kind === 'wechat' || (rednote && user.kind === 'browser_persistent') || guestReady) return Promise.resolve({ user: user });
|
||||
return browser(true);
|
||||
}
|
||||
function init(options) {
|
||||
@@ -58,6 +60,9 @@ window.AccountClient = (function () {
|
||||
}
|
||||
function logout() {
|
||||
return request('api/auth/logout', {}).then(function (v) {
|
||||
if (rednote && user) {
|
||||
try { localStorage.removeItem('juzi.rednote.profile.v1.browser_persistent.' + encodeURIComponent(user.id)); } catch (_) {}
|
||||
}
|
||||
user = null; guestReady = false; rememberKind(); return v;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -38,14 +38,38 @@ function toast(msg) {
|
||||
toast._timer = setTimeout(function () { t.classList.add('hidden'); }, 2600);
|
||||
}
|
||||
|
||||
function adminURL(url) {
|
||||
return window.JuziEntry && window.JuziEntry.mode === 'rednote' && url.indexOf('/api/') === 0 ? '/rednote' + url : url;
|
||||
}
|
||||
function api(url, opts) {
|
||||
return fetch(url, opts || {}).then(function (res) {
|
||||
return fetch(adminURL(url), opts || {}).then(function (res) {
|
||||
return res.json().then(function (data) {
|
||||
if (!res.ok || data.ok === false) throw new Error(data.message || ('HTTP ' + res.status));
|
||||
return data;
|
||||
});
|
||||
});
|
||||
}
|
||||
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 };
|
||||
@@ -113,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);
|
||||
@@ -127,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 = '';
|
||||
@@ -142,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>' +
|
||||
@@ -156,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]);
|
||||
@@ -172,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>';
|
||||
@@ -187,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', '暂无失败记录'));
|
||||
@@ -202,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) {
|
||||
@@ -212,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]);
|
||||
@@ -230,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();
|
||||
@@ -244,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],
|
||||
@@ -329,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;
|
||||
@@ -352,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 = '';
|
||||
|
||||
/* 三张照片 */
|
||||
@@ -372,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>' +
|
||||
@@ -548,10 +584,11 @@ function renderDetail(data) {
|
||||
|
||||
/* 接口原始 JSON:details 折叠,pre 展示(face_maps 已在服务端裁剪,完整版走下载链接) */
|
||||
if (data.raw_json) {
|
||||
var rawDl = data.raw_file
|
||||
? ' <a href="/api/admin/records/' + encodeURIComponent(r.record_id) + '/raw" download>下载完整原始 JSON</a>'
|
||||
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' + sourceQuery(data.source)) + '" download>下载完整原始 JSON</a>'
|
||||
: '';
|
||||
h += dSec('接口原始 JSON', '<details><summary>展开/收起(' + (data.raw_file || '') + ')' + rawDl + '</summary><pre>' +
|
||||
h += dSec('接口原始 JSON', '<details><summary>展开/收起(' + (rawFile || '') + ')' + rawDl + '</summary><pre>' +
|
||||
esc(JSON.stringify(data.raw_json, null, 1)) + '</pre></details>');
|
||||
} else {
|
||||
h += dSec('接口原始 JSON', '<div class="sub">无(失败记录或未落盘)</div>');
|
||||
@@ -568,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); });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,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); });
|
||||
@@ -595,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 = '/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 = '';
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
'use strict';
|
||||
|
||||
var $ = function (sel, root) { return (root || document).querySelector(sel); };
|
||||
var REDNOTE = !!(window.JuziEntry && window.JuziEntry.mode === 'rednote');
|
||||
function savedAccount(user) { return !!(user && (user.kind === 'wechat' || (REDNOTE && user.kind === 'browser_persistent'))); }
|
||||
|
||||
var captureJourney = null;
|
||||
function beginCaptureJourney() {
|
||||
@@ -96,13 +98,13 @@
|
||||
function renderAccountStatus() {
|
||||
var user = AccountClient.user();
|
||||
document.querySelectorAll('[data-auth-status]').forEach(function (label) {
|
||||
label.textContent = user ? (user.kind === 'wechat' ? '微信已登录' : '游客模式') : '尚未登录';
|
||||
label.textContent = user ? (user.kind === 'wechat' ? '微信已登录' : REDNOTE ? (savedAccount(user) ? '浏览器已登录' : '浏览器体验') : '游客模式') : '尚未登录';
|
||||
label.parentElement.dataset.kind = user ? user.kind : '';
|
||||
});
|
||||
document.querySelectorAll('[data-action="logout-wechat"]').forEach(function (button) {
|
||||
button.hidden = !user || user.kind !== 'wechat';
|
||||
button.hidden = !savedAccount(user);
|
||||
button.disabled = logoutBusy;
|
||||
button.textContent = logoutBusy ? '正在退出…' : '退出登录';
|
||||
button.textContent = logoutBusy ? '正在退出…' : REDNOTE ? '清除本机身份' : '退出登录';
|
||||
});
|
||||
}
|
||||
function loginSucceeded() {
|
||||
@@ -1208,7 +1210,7 @@
|
||||
}
|
||||
renderReport(vm, photos[0], photos[1], payload, photos); // photos 已统一为正/左/右
|
||||
if (IS_MOCK) toast('演示报告,不保存至我的报告');
|
||||
else if (AccountClient.user() && AccountClient.user().kind !== 'wechat') toast('游客报告仅供本次查看,不保存历史记录');
|
||||
else if (AccountClient.user() && !savedAccount(AccountClient.user())) toast('游客报告仅供本次查看,不保存历史记录');
|
||||
else if (!payload.report_saved) toast('报告生成成功,但保存失败,请勿关闭页面并联系管理员');
|
||||
photos = []; // 报告已渲染(图片 src 已挂):释放三连拍 dataURL 内存
|
||||
showScreen('s-report', 'replace'); // 报告顶替拍照页:返回键回档案页,不再回到相机
|
||||
@@ -1934,6 +1936,7 @@
|
||||
'history-retry': function () { renderHistoryList(); },
|
||||
'logout-wechat': logoutWechat,
|
||||
'open-wechat-login': function () {
|
||||
if (REDNOTE) return;
|
||||
historySeq++;
|
||||
$('#login-consent').checked = false;
|
||||
$('#login-message').textContent = '微信登录后,后续检测报告可保存和跨手机查看。';
|
||||
@@ -1952,7 +1955,12 @@
|
||||
var historySeq = 0;
|
||||
function renderHistoryList() {
|
||||
var seq = ++historySeq, box = $('#history-list');
|
||||
if (!AccountClient.user() || AccountClient.user().kind !== 'wechat') {
|
||||
if (!savedAccount(AccountClient.user())) {
|
||||
if (REDNOTE) {
|
||||
$('#history-count').textContent = '暂无历史报告';
|
||||
$('#identity-note').textContent = '同意隐私说明并完成检测后,将在此浏览器保留最近 5 份报告。';
|
||||
box.textContent = '请返回首页开始检测。'; return;
|
||||
}
|
||||
$('#history-count').textContent = '游客模式不保存检测记录';
|
||||
$('#identity-note').textContent = '游客不保存个人信息和检测记录,报告仅供本次查看。微信登录后可保留历史报告。';
|
||||
box.innerHTML = '<p class="history-empty">暂无历史报告<br>请使用微信登录,后续检测报告会自动保存。<br><button class="history-retry" data-action="open-wechat-login">微信登录,保存报告</button></p>';
|
||||
@@ -1962,7 +1970,7 @@
|
||||
AccountClient.request('api/reports').then(function (data) {
|
||||
if (seq !== historySeq) return;
|
||||
$('#history-count').textContent = '您最近已进行过' + data.total + '次面部问题检测';
|
||||
$('#identity-note').textContent = '保留最近 5 份报告,同一微信账号可跨手机查看。';
|
||||
$('#identity-note').textContent = REDNOTE ? '保留最近 5 份报告。请使用同一浏览器查看;清除浏览器数据或更换设备后无法恢复。' : '保留最近 5 份报告,同一微信账号可跨手机查看。';
|
||||
if (!data.items.length) { box.innerHTML = '<p class="history-empty">暂无报告<br>完成一次检测后,报告会自动保存在这里。</p>'; return; }
|
||||
box.innerHTML = data.items.map(function (item) {
|
||||
var date = new Date(item.created_at).toLocaleDateString('sv-SE');
|
||||
@@ -1995,13 +2003,17 @@
|
||||
var loginBusy = false;
|
||||
function logoutWechat() {
|
||||
var user = AccountClient.user();
|
||||
if (logoutBusy || !user || user.kind !== 'wechat') return;
|
||||
if (!window.confirm('确认退出微信登录?\n历史报告将保留,重新微信登录后可查看。')) return;
|
||||
if (logoutBusy || !savedAccount(user)) return;
|
||||
if (!window.confirm(REDNOTE ? '确认清除本机浏览器身份?\n清除后将无法恢复该身份的历史报告,下次体验会生成新身份。' : '确认退出微信登录?\n历史报告将保留,重新微信登录后可查看。')) return;
|
||||
logoutBusy = true; renderAccountStatus();
|
||||
AccountClient.logout().then(function () {
|
||||
historySeq++; reportOwner = null;
|
||||
$('#history-list').textContent = ''; $('#history-count').textContent = ''; $('#identity-note').textContent = '';
|
||||
$('#login-consent').checked = false;
|
||||
if (REDNOTE) {
|
||||
agreed = false; $('#privacy-check').classList.remove('checked');
|
||||
AccountClient.guest(); showScreen('s-splash', 'replace'); return;
|
||||
}
|
||||
$('#login-message').textContent = '已退出微信登录,历史报告已保留。';
|
||||
showScreen('s-login', 'replace');
|
||||
$('[data-action="login-wechat"]').focus({ preventScroll: true });
|
||||
@@ -2020,6 +2032,7 @@
|
||||
showScreen('s-splash', 'replace');
|
||||
}
|
||||
function loginWechat() {
|
||||
if (REDNOTE) return;
|
||||
if (!loginAllowed()) return;
|
||||
if (!/MicroMessenger/i.test(navigator.userAgent)) {
|
||||
$('#login-message').textContent = '请在微信内打开本页面,或选择免登录体验。';
|
||||
@@ -2074,11 +2087,14 @@
|
||||
history.replaceState({ screen: 's-login' }, ''); // 刷新后从启动页开始,历史栈的屏标记与之对齐
|
||||
renderCamStep();
|
||||
if (!(/[?&]mock=1/.test(location.search) && /[?&]screen=report/.test(location.search))) {
|
||||
var loginError = /[?&]login_error=([^&#]*)/.exec(location.search);
|
||||
var loginSuccess = /[?&]login=success(?:&|$)/.test(location.search);
|
||||
var loginError = REDNOTE ? null : /[?&]login_error=([^&#]*)/.exec(location.search);
|
||||
var loginSuccess = !REDNOTE && /[?&]login=success(?:&|$)/.test(location.search);
|
||||
var oauthReturn = !!loginError || loginSuccess;
|
||||
loginBusy = true;
|
||||
AccountClient.init({ restoreBrowser: !oauthReturn }).then(function () {
|
||||
if (REDNOTE && !AccountClient.user()) {
|
||||
AccountClient.guest(); showScreen('s-splash', 'replace'); return;
|
||||
}
|
||||
if (loginError) {
|
||||
var code = loginError[1];
|
||||
$('#login-message').textContent = code === 'state'
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
})(typeof window !== 'undefined' ? window : this, function() {
|
||||
'use strict';
|
||||
function key(user) {
|
||||
return user && user.kind === 'wechat' && typeof user.id === 'string' && user.id.length <= 128 && user.id
|
||||
? 'juzi.profile.v1.' + user.kind + '.' + encodeURIComponent(user.id) : null;
|
||||
return user && ['wechat', 'browser_persistent'].indexOf(user.kind) >= 0 && typeof user.id === 'string' && user.id.length <= 128 && user.id
|
||||
? (user.kind === 'browser_persistent' ? 'juzi.rednote.profile.v1.' : 'juzi.profile.v1.') + user.kind + '.' + encodeURIComponent(user.id) : null;
|
||||
}
|
||||
function age(birth, now) {
|
||||
if (typeof birth !== 'string' || !/^\d{4}-(0[1-9]|1[0-2])$/.test(birth)) return null;
|
||||
|
||||
@@ -11,6 +11,9 @@ const hash = value => crypto.createHash('sha256').update(value).digest('hex');
|
||||
// One Node process owns this embedded store. No credentials or identity are accepted
|
||||
// from a report request; the owner always comes from a server-created session.
|
||||
module.exports = function createAccounts(root, env = process.env) {
|
||||
const rednote = env.JUZI_ENTRY_MODE === 'rednote';
|
||||
const sessionCookie = rednote ? 'juzi_rednote_session' : 'juzi_session';
|
||||
const persistentKind = kind => ['wechat', 'taobao'].includes(kind) || (rednote && kind === 'browser_persistent');
|
||||
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
||||
const dbFile = path.join(root, 'identities.json');
|
||||
const snapshots = path.join(root, 'reports');
|
||||
@@ -30,9 +33,10 @@ 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 = !!(appId && secret && secure && !/^(localhost|127\.|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(base.hostname));
|
||||
const available = !rednote && !!(appId && secret && secure && !/^(localhost|127\.|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(base.hostname));
|
||||
function cookies(req) {
|
||||
return Object.fromEntries((req.headers.cookie || '').split(';').map(s => s.trim().split('=')));
|
||||
}
|
||||
@@ -42,17 +46,17 @@ module.exports = function createAccounts(root, env = process.env) {
|
||||
res.setHeader('Set-Cookie', [].concat(old, line));
|
||||
}
|
||||
function session(req) {
|
||||
const token = cookies(req).juzi_session;
|
||||
const token = cookies(req)[sessionCookie];
|
||||
const guest = token && guestSessions.get(hash(token));
|
||||
if (guest && guest.expires > Date.now()) return guest.user;
|
||||
const row = token && db.sessions[hash(token)];
|
||||
return row && row.expires > Date.now() ? db.users.find(u => u.id === row.user && u.kind === 'wechat') : null;
|
||||
return row && row.expires > Date.now() ? db.users.find(u => u.id === row.user && u.kind === (rednote ? 'browser_persistent' : 'wechat')) : null;
|
||||
}
|
||||
function login(res, user) {
|
||||
for (const key of Object.keys(db.sessions)) if (db.sessions[key].expires < Date.now()) delete db.sessions[key];
|
||||
const token = random();
|
||||
db.sessions[hash(token)] = { user: user.id, expires: Date.now() + 30 * 86400000 };
|
||||
cookie(res, 'juzi_session', token, 30 * 86400);
|
||||
cookie(res, sessionCookie, token, 30 * 86400);
|
||||
save();
|
||||
}
|
||||
function publicUser(u) { return u ? { id: u.id, kind: u.kind } : null; }
|
||||
@@ -91,6 +95,9 @@ module.exports = function createAccounts(root, env = process.env) {
|
||||
if (!pathname.startsWith('/api/auth/') && !pathname.startsWith('/api/reports')) return false;
|
||||
try {
|
||||
if (req.method !== 'GET' && !sameOrigin(req)) { json(res, 403, { ok: false, message: '请求来源不匹配' }); return true; }
|
||||
if (rednote && pathname.startsWith('/api/auth/wechat')) {
|
||||
json(res, 404, { ok: false, message: '此入口不提供微信登录' }); return true;
|
||||
}
|
||||
if (pathname === '/api/auth/config' && req.method === 'GET') {
|
||||
json(res, 200, { ok: true, wechat: available, fallback: 'browser', reason: available ? null : '当前环境尚未开通微信登录,可使用游客登录' });
|
||||
} else if (pathname === '/api/auth/session' && req.method === 'GET') {
|
||||
@@ -101,20 +108,28 @@ module.exports = function createAccounts(root, env = process.env) {
|
||||
if (data.consent !== true) { json(res, 400, { ok: false, message: '请先阅读并同意协议' }); return true; }
|
||||
// Old persistent guest tokens cannot restore identity or history.
|
||||
if (data.token) { json(res, 401, { ok: false, message: '游客身份已失效,请重新登录' }); return true; }
|
||||
if (rednote) {
|
||||
let user = session(req);
|
||||
if (!user) {
|
||||
user = { id: crypto.randomUUID(), kind: 'browser_persistent', channel: 'rednote', consent: new Date().toISOString(), reports: [] };
|
||||
db.users.push(user); login(res, user);
|
||||
}
|
||||
json(res, 200, { ok: true, user: publicUser(user) }); return true;
|
||||
}
|
||||
for (const [key, value] of guestSessions) if (value.expires < Date.now()) guestSessions.delete(key);
|
||||
const previous = cookies(req).juzi_session;
|
||||
const previous = cookies(req)[sessionCookie];
|
||||
if (previous) guestSessions.delete(hash(previous));
|
||||
const token = random(), user = { id: crypto.randomUUID(), kind: 'browser' };
|
||||
guestSessions.set(hash(token), { user, expires: Date.now() + 8 * 3600000 });
|
||||
cookie(res, 'juzi_session', token, null);
|
||||
cookie(res, sessionCookie, token, null);
|
||||
json(res, 200, { ok: true, user: publicUser(user) });
|
||||
} else if (pathname === '/api/auth/logout' && req.method === 'POST') {
|
||||
const token = cookies(req).juzi_session;
|
||||
const token = cookies(req)[sessionCookie];
|
||||
if (token) {
|
||||
guestSessions.delete(hash(token));
|
||||
if (db.sessions[hash(token)]) { delete db.sessions[hash(token)]; save(); }
|
||||
}
|
||||
cookie(res, 'juzi_session', '', 0); json(res, 200, { ok: true });
|
||||
cookie(res, sessionCookie, '', 0); json(res, 200, { ok: true });
|
||||
} else if (pathname === '/api/auth/wechat' && req.method === 'POST') {
|
||||
const data = await body(req);
|
||||
if (data.consent !== true) { json(res, 400, { ok: false, message: '请先阅读并同意协议' }); return true; }
|
||||
@@ -155,7 +170,7 @@ module.exports = function createAccounts(root, env = process.env) {
|
||||
const user = session(req);
|
||||
if (!user) { json(res, 401, { ok: false, message: '请先登录' }); return true; }
|
||||
const id = pathname.slice('/api/reports/'.length);
|
||||
if (user.kind !== 'wechat') {
|
||||
if (user.kind !== 'wechat' && !(rednote && user.kind === 'browser_persistent')) {
|
||||
json(res, pathname === '/api/reports' ? 200 : 404,
|
||||
pathname === '/api/reports' ? { ok: true, total: 0, items: [] } : { ok: false, message: '游客模式不保存历史报告' });
|
||||
return true;
|
||||
@@ -177,7 +192,7 @@ module.exports = function createAccounts(root, env = process.env) {
|
||||
}
|
||||
function storeReport(ownerId, snapshot) {
|
||||
const user = db.users.find(u => u.id === ownerId);
|
||||
if (!user || !['wechat', 'taobao'].includes(user.kind) || !/^R[\w-]+$/.test(snapshot.id)) throw new Error('Invalid report owner');
|
||||
if (!user || !persistentKind(user.kind) || !/^R[\w-]+$/.test(snapshot.id)) throw new Error('Invalid report owner');
|
||||
const dir = path.join(snapshots, user.id); fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
atomic(path.join(dir, snapshot.id + '.json'), snapshot);
|
||||
user.reports = [{ id: snapshot.id, created_at: snapshot.created_at, total: snapshot.vm.total, skinAge: snapshot.vm.skinAgeDisplay }]
|
||||
@@ -193,7 +208,7 @@ module.exports = function createAccounts(root, env = process.env) {
|
||||
catch (_) { return null; }
|
||||
}
|
||||
function reportSnapshot(ownerId,id) {
|
||||
if (!db.users.some(u=>u.id===ownerId&&['wechat','taobao'].includes(u.kind)) || !/^R[\w-]+$/.test(id)) return null;
|
||||
if (!db.users.some(u=>u.id===ownerId&&persistentKind(u.kind)) || !/^R[\w-]+$/.test(id)) return null;
|
||||
try {return JSON.parse(fs.readFileSync(path.join(snapshots,ownerId,id+'.json'),'utf8'));} catch (_) {return null;}
|
||||
}
|
||||
function updateWrinkleViews(ownerId,id,views) {
|
||||
@@ -212,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) {
|
||||
@@ -233,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 })) };
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
'use strict';
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
function physical(file) {
|
||||
if (fs.existsSync(file)) return fs.realpathSync(file);
|
||||
const parent = path.dirname(file);
|
||||
return parent === file ? file : path.join(physical(parent), path.basename(file));
|
||||
}
|
||||
function inside(file, parent) {
|
||||
if (process.platform === 'win32') { file = file.toLowerCase(); parent = parent.toLowerCase(); }
|
||||
return file === parent || file.startsWith(parent + path.sep);
|
||||
}
|
||||
|
||||
// One immutable entry mode per process. Never infer a channel from client input.
|
||||
module.exports = function entryConfig(env, root) {
|
||||
const mode = env.JUZI_ENTRY_MODE || 'state';
|
||||
if (!['state', 'rednote'].includes(mode)) throw Error('Unsupported JUZI_ENTRY_MODE');
|
||||
const rednote = mode === 'rednote';
|
||||
const dataRoot = env.JUZI_DATA_ROOT ? path.resolve(env.JUZI_DATA_ROOT) : path.join(root, 'data');
|
||||
if (rednote) {
|
||||
const protectedRoots = [root, env.JUZI_PROTECTED_DATA_ROOT].filter(Boolean).map(p => physical(path.resolve(p)));
|
||||
if (!env.JUZI_DATA_ROOT || protectedRoots.some(p => inside(physical(dataRoot), p))) {
|
||||
throw Error('Rednote requires its own JUZI_DATA_ROOT outside the shared h5 directory and protected data');
|
||||
}
|
||||
const base = new URL(env.PUBLIC_BASE_URL || '');
|
||||
if (!['http:', 'https:'].includes(base.protocol) || base.pathname !== '/rednote/' || base.search || base.hash || base.username || base.password ||
|
||||
(env.NODE_ENV === 'production' && base.protocol !== 'https:')) {
|
||||
throw Error('Rednote PUBLIC_BASE_URL must end with /rednote/');
|
||||
}
|
||||
}
|
||||
return { mode, rednote, dataRoot, channel: rednote ? 'rednote' : 'h5',
|
||||
client: { mode, persistentBrowser: rednote } };
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
'use strict';
|
||||
// Rednote-only admission control. Includes JSON consumption in the critical section.
|
||||
module.exports = function providerGate(fetchImpl, maxPending = 4) {
|
||||
let tail = Promise.resolve(), pending = 0;
|
||||
return function (...args) {
|
||||
if (pending >= maxPending) return Promise.reject(Error('Detection queue is full'));
|
||||
pending++;
|
||||
const result = tail.then(async () => {
|
||||
args[1]?.signal?.throwIfAborted();
|
||||
const response = await fetchImpl(...args);
|
||||
const data = await response.json();
|
||||
return { ok: response.ok, status: response.status, json: async () => data };
|
||||
});
|
||||
tail = result.catch(() => {}).finally(() => { pending--; });
|
||||
return result;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
'use strict';
|
||||
// Only the rednote instance transforms the shared HTML; state is served verbatim.
|
||||
module.exports = function rednoteView(html, admin = false) {
|
||||
const bootstrap = '<script>window.JuziEntry={mode:"rednote",persistentBrowser:true};</script>';
|
||||
if (admin) return html.replace('</head>', bootstrap + '</head>')
|
||||
.replace('<title>', '<title>Rednote · ');
|
||||
return html.replace('</head>', bootstrap + '<link rel="stylesheet" href="css/rednote.css"></head>')
|
||||
.replace(/<button class="login-button login-wechat"[\s\S]*?<\/button>/, '')
|
||||
.replace('游客登陆</button>', '浏览器继续体验</button>')
|
||||
.replace('我们将采集您的面部影像,为您生成专属肌肤分析与状态管理方案。所有数据严格保密,仅用于本次检测分析。',
|
||||
'同意后将自动建立本机浏览器身份。我们将采集面部影像和基础档案,交由 Face⁺⁺ 分析,并在服务器保存照片和报告,供此浏览器查看最近5份报告。浏览器身份有效期30天,清除浏览器数据或更换设备后无法恢复。')
|
||||
.replace('<h1 class="policy-title">', '<p>本入口使用浏览器身份保存检测记录及照片,最近5份报告可由同一浏览器查看。Face⁺⁺提供检测分析,以下为其政策说明;清除本机身份后无法恢复原身份的历史报告。</p><h1 class="policy-title">');
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ module.exports = function({dir, collect, load, complete, now = Date.now}) {
|
||||
fs.renameSync(tmp,file);
|
||||
}
|
||||
function register(owner,record,context) {
|
||||
const job={token:crypto.randomBytes(24).toString('hex'),owner:owner.id,record,persist:['wechat','taobao'].includes(owner.kind),status:'pending',context};
|
||||
const job={token:crypto.randomBytes(24).toString('hex'),owner:owner.id,record,persist:['wechat','taobao','browser_persistent'].includes(owner.kind),status:'pending',context};
|
||||
save(job);remember(job);
|
||||
return {token:job.token,status:'pending'};
|
||||
}
|
||||
@@ -26,7 +26,7 @@ module.exports = function({dir, collect, load, complete, now = Date.now}) {
|
||||
let job=jobs.get(token);
|
||||
if (!job) {
|
||||
try {job=JSON.parse(fs.readFileSync(path.join(dir,token+'.json'),'utf8'));} catch (_) {return null;}
|
||||
if (job.owner!==owner.id || !['wechat','taobao'].includes(owner.kind)) return null;
|
||||
if (job.owner!==owner.id || !['wechat','taobao','browser_persistent'].includes(owner.kind)) return null;
|
||||
remember(job);
|
||||
}
|
||||
if (job.owner!==owner.id) return null;
|
||||
|
||||
@@ -20,8 +20,10 @@ const PORT = Number(process.env.PORT) || 8080;
|
||||
// 只绑回环:公网入口是 nginx(1me.xajuzi.com/state/),8080 不再对公网裸奔;需局域网调试时 HOST=0.0.0.0
|
||||
const HOST = process.env.HOST || '127.0.0.1';
|
||||
const ROOT = __dirname;
|
||||
const entry = require('./lib/entry-config')(process.env, ROOT);
|
||||
const providerFetch = entry.rednote ? require('./lib/provider-gate')((...args) => fetch(...args)) : (...args) => fetch(...args);
|
||||
const FACEPP_URL = 'https://api-cn.faceplusplus.com/facepp/v1/skinanalyze_pro';
|
||||
const DATA_ROOT = process.env.JUZI_DATA_ROOT ? path.resolve(process.env.JUZI_DATA_ROOT) : path.join(ROOT, 'data');
|
||||
const DATA_ROOT = entry.dataRoot;
|
||||
const DATA_DIR = path.join(DATA_ROOT, 'results'); // 每次检测的原始返回落盘(复核用,已在 .gitignore)
|
||||
const PHOTOS_DIR = path.join(DATA_ROOT, 'photos'); // 检测照片:data/photos/<record_id>/{front,left,right}.jpg
|
||||
const RECORDS_FILE = path.join(DATA_ROOT, 'records.jsonl'); // 检测记录(一行一条 JSON)
|
||||
@@ -31,10 +33,12 @@ 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,{
|
||||
key:CRED.key,secret:CRED.secret,url:FACEPP_URL,gapMs:process.env.NODE_ENV==='test'?0:4000,deadline:Date.now()+60000
|
||||
key:CRED.key,secret:CRED.secret,url:FACEPP_URL,fetch:providerFetch,gapMs:process.env.NODE_ENV==='test'?0:4000,deadline:Date.now()+60000
|
||||
})),
|
||||
load(owner,id) {
|
||||
const snapshot=accounts.reportSnapshot(owner,id);
|
||||
@@ -91,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;
|
||||
@@ -116,6 +121,9 @@ function serveStatic(req, res, pathname) {
|
||||
if (file === DATA_ROOT || file.startsWith(DATA_ROOT + path.sep)) { res.writeHead(403); return res.end('Forbidden'); }
|
||||
fs.readFile(file, (err, data) => {
|
||||
if (err) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('Not Found'); }
|
||||
if (entry.rednote && ['index.html', 'admin.html'].some(name => file === path.join(ROOT, name))) {
|
||||
data = Buffer.from(require('./lib/rednote-view')(data.toString('utf8'), path.basename(file) === 'admin.html'));
|
||||
}
|
||||
const ext = path.extname(file);
|
||||
// Revalidate every time: updates remain immediate; unchanged resources use
|
||||
// 304 instead of downloading again. APIs/private reports never enter here.
|
||||
@@ -360,7 +368,7 @@ async function runDetect(rec, front, left, right, persist = true, deferWrinkles
|
||||
const fpJson = await callFacepp(front, left, right);
|
||||
if (fpJson.result && !fpJson.error_message && !deferWrinkles) {
|
||||
fpJson.wrinkle_views = await WrinkleViews.collect(fpJson,{left,right},{
|
||||
key:CRED.key,secret:CRED.secret,url:FACEPP_URL,
|
||||
key:CRED.key,secret:CRED.secret,url:FACEPP_URL,fetch:providerFetch,
|
||||
gapMs:process.env.NODE_ENV==='test'?0:4000,
|
||||
// Legacy admin retries may still collect inline; customer reports use
|
||||
// deferred tasks instead. Missing side maps never fail a report.
|
||||
@@ -404,7 +412,7 @@ async function callFacepp(front, left, right) {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
fetchErr = null; fpRes = null; fpJson = null;
|
||||
try {
|
||||
fpRes = await fetch(FACEPP_URL, { method: 'POST', body: form, signal: AbortSignal.timeout(60000) });
|
||||
fpRes = await providerFetch(FACEPP_URL, { method: 'POST', body: form, signal: AbortSignal.timeout(60000) });
|
||||
fpJson = await fpRes.json().catch(() => ({}));
|
||||
} catch (e) { // 60s 超时(AbortError)/网络异常:无 fpJson,与 5xx/并发超限统一走「等 4s 重试 1 次」
|
||||
fetchErr = e;
|
||||
@@ -432,8 +440,8 @@ async function handleAnalyze(req, res, miniProgram = false, preparedBody = null,
|
||||
if (!accounts.sameOrigin(req)) return sendJson(res, 403, { ok: false, message: '请求来源不匹配' });
|
||||
const body = preparedBody || JSON.parse((await readBody(req, 20 * 1024 * 1024)).toString('utf8'));
|
||||
// A client-supplied channel must never change H5 guest retention behavior.
|
||||
const channel = miniProgram ? 'taobao' : 'h5';
|
||||
persist = ['wechat', 'taobao'].includes(owner.kind);
|
||||
const channel = miniProgram ? 'taobao' : entry.channel;
|
||||
persist = ['wechat', 'taobao', 'browser_persistent'].includes(owner.kind);
|
||||
// 三个 image 字段先做类型检查:非字符串视为缺失(左/右图缺失时仅发正脸,接口自动降级为单视图)
|
||||
const front = stripDataPrefix(typeof body.front_image === 'string' ? body.front_image : '');
|
||||
const leftB64 = stripDataPrefix(typeof body.left_image === 'string' ? body.left_image : '');
|
||||
@@ -572,9 +580,11 @@ function sendJson(res, status, obj) {
|
||||
/* 记录里照片的在线 URL(有存才给) */
|
||||
function photoUrls(rec) {
|
||||
const urls = {};
|
||||
if (rec.img_front) urls.front = `/api/admin/photos/${rec.record_id}/front`;
|
||||
if (rec.img_left) urls.left = `/api/admin/photos/${rec.record_id}/left`;
|
||||
if (rec.img_right) urls.right = `/api/admin/photos/${rec.record_id}/right`;
|
||||
const prefix = entry.rednote ? '/rednote' : '';
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -597,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,
|
||||
@@ -642,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,
|
||||
@@ -659,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),详情页直接渲染会卡死浏览器:
|
||||
@@ -681,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) {
|
||||
@@ -693,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.
|
||||
@@ -713,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, {
|
||||
@@ -740,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);
|
||||
});
|
||||
@@ -755,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) {
|
||||
@@ -816,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,
|
||||
@@ -877,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;
|
||||
@@ -893,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([
|
||||
@@ -909,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, '');
|
||||
@@ -923,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);
|
||||
@@ -941,9 +971,10 @@ async function handleAdmin(req, res, pathname, url) {
|
||||
}
|
||||
}
|
||||
|
||||
let rednoteAnalyses = 0;
|
||||
http.createServer(async (req, res) => {
|
||||
const pathname = req.url.split('?')[0];
|
||||
if (pathname.startsWith('/api/taobao/')) return handleTaobao(req, res, pathname);
|
||||
if (pathname.startsWith('/api/taobao/')) return entry.rednote ? sendJson(res, 404, { ok: false }) : handleTaobao(req, res, pathname);
|
||||
if (await accounts.route(req, res, pathname)) return;
|
||||
if (req.method==='POST' && pathname==='/api/wrinkle-views') {
|
||||
const owner=accounts.session(req);
|
||||
@@ -962,10 +993,15 @@ http.createServer(async (req, res) => {
|
||||
return sendJson(res, accepted ? 200 : 400, { ok: accepted });
|
||||
} catch (e) { return sendJson(res, e.message === 'TOO_LARGE' ? 413 : e instanceof SyntaxError ? 400 : 500, { ok: false }); }
|
||||
}
|
||||
if (req.method === 'POST' && pathname === '/api/skin-analyze') return handleAnalyze(req, res);
|
||||
if (req.method === 'POST' && pathname === '/api/skin-analyze') {
|
||||
if (!entry.rednote) return handleAnalyze(req, res);
|
||||
if (rednoteAnalyses) return sendJson(res, 429, { ok: false, code: 'BUSY', message: '当前检测人数较多,请稍后重试' });
|
||||
rednoteAnalyses++;
|
||||
try { return await handleAnalyze(req, res); } finally { rednoteAnalyses--; }
|
||||
}
|
||||
if (req.method === 'GET' && pathname === '/api/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ ok: true }));
|
||||
return res.end(JSON.stringify(entry.rednote ? { ok: true, channel: entry.channel } : { ok: true }));
|
||||
}
|
||||
if (pathname.startsWith('/api/admin/')) {
|
||||
return handleAdmin(req, res, pathname, new URL(req.url, 'http://localhost'));
|
||||
|
||||
@@ -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');
|
||||
@@ -0,0 +1,44 @@
|
||||
# Rednote 生产发布记录 · 2026-10-19
|
||||
|
||||
状态:已发布并通过上线检查。
|
||||
|
||||
- 入口:<https://1me.xajuzi.com/rednote/>;后台:<https://1me.xajuzi.com/rednote/admin.html>(沿用原 Basic Auth)。
|
||||
- 发布版本:`20261019-225927-4af825a`;运行代码提交:`4af825a5e422c633c43dd0df3923ada1e6a8f760`。
|
||||
- 发布包 SHA256:`c8d3cc8f9d68b7214eafc3a3c832c09854d07f6c3b3a9f53a6bdfb0397e4933f`;148个运行文件及1份哈希清单,线上逐文件校验一致。
|
||||
- 目录:`/home/ubuntu/juzi-rednote/releases/20261019-225927-4af825a`;`current` 指向该版本。
|
||||
- 服务:`juzi-rednote.service`,已启用开机启动,仅监听 `127.0.0.1:8081`;数据:`/home/ubuntu/juzi-rednote-data`。
|
||||
- 凭据:`/etc/juzi/rednote.env`,root所有、权限600;只在服务器内部提取现有 Face++ 凭据,没有复制微信配置或下载密钥。
|
||||
- Nginx及基线备份:`/etc/juzi-deploy-backup/rednote-20261019-225927-4af825a/`,含 `nginx-juzi.before.conf`、`baseline.json`、`installed.json`、`published.json`。
|
||||
|
||||
## 原入口保护
|
||||
|
||||
原服务目录 `/home/ubuntu/juzi` 没有 git pull、文件覆盖、数据迁移或服务重启,仍为 `fc9f2519bdc50595556b244cdecf695ae90ba9eb`,工作区干净。原进程PID始终为689641,自动重启次数0。旧systemd主文件及微信drop-in哈希一致。
|
||||
|
||||
新服务独立运行;实际检查其挂载命名空间中 `/home/ubuntu/juzi` 为只读。Nginx只在原HTTPS server中增加 `/rednote` 路由块,移除该块可还原原文件;配置检查通过后平滑reload。发布前Nginx文件SHA256:`6c21c06e22c953628049601adb4bb8726c22f45e16b4973988c36115beefb484`;发布后:`5d0aa501340566d6eb8a0cccef0944fb8eccaece3c1a5809b87a57d1490ee7bf`。
|
||||
|
||||
发布后原 `/state/` 页面、健康接口、微信授权配置、域名验证文件的内容哈希与发布前一致;微信登录开关保持启用。原后台仍要求鉴权,私有数据不能公网直读。
|
||||
|
||||
## 验证结果和边界
|
||||
|
||||
本地Rednote后端5项测试及浏览器集成通过,覆盖最近5份报告、真实共享页面的样本检测/历史重载、所有者隔离、后台API/照片/导出子路径、同域双入口隔离、限流和数据路径保护。淘宝16项测试、共享公式一致性检查,以及原H5游客隐私、微信授权与退出、报告持久化、异步侧脸4组回归通过。
|
||||
|
||||
原H5较旧的两个单元断言 `tone-dark-circle.js:13`、`factual-copy.js:9` 在发布前HEAD也不通过,属于既有期望值/文案差异。本次未修改相关评分、文案实现,没有为通过断言调整业务结论。
|
||||
|
||||
线上通过:
|
||||
|
||||
- 新入口HTTPS页面、静态资源、健康和账号配置;不展示微信登录,不开放微信/淘宝登录API。
|
||||
- 未同意隐私前不创建身份;同意后Cookie为Secure、HttpOnly、SameSite=Lax、`Path=/rednote/`、30天。
|
||||
- 关闭并重建浏览器上下文、单独重启新服务后,同一Cookie仍恢复原身份,档案缓存及历史入口正常。
|
||||
- 清除身份的取消/确认、服务端会话撤销、旧Cookie重放失败;原入口仍显示微信登录、没有继承新身份。
|
||||
- 新旧后台页面/API/导出未鉴权返回401;私有数据和服务器源码路径不可访问;跨域创建身份返回403。
|
||||
- 上线浏览器检查无JS异常、无资源失败;新服务自动重启次数0,启动/检查日志无错误。仅为持久化检查主动重启过新服务一次,原服务未重启。
|
||||
|
||||
线上未调用收费检测API,自动化没有代替手机摄像头实测或真实微信账号授权验收。发布后请在目标手机/内置浏览器完成拍摄、真实报告生成和关闭重开后的历史查看,并从微信打开原 `/state/` 核对真实授权。验证只新增1个空浏览器身份,检查结束已撤销其会话,没有测试照片或报告写入线上。
|
||||
|
||||
## 回退
|
||||
|
||||
备份中的 `nginx-juzi.before.conf` 是新增路由前的完整配置。先核对当前配置仍为本次发布后的哈希;若此后还有其他配置变更,应仅移除本次 `BEGIN rednote ... END rednote` 块,保留后续变更。
|
||||
|
||||
在确认没有后续变更的前提下,先另存当前Nginx配置,再将 `nginx-juzi.before.conf` 安装回 `/etc/nginx/sites-available/juzi`。执行 `sudo nginx -t`,通过后执行 `sudo systemctl reload nginx`,并核对 `/state/` 健康;之后执行 `sudo systemctl disable --now juzi-rednote.service`。若配置检查失败,恢复刚另存的配置并再次检查,不reload失败配置。
|
||||
|
||||
保留新入口发布目录及 `/home/ubuntu/juzi-rednote-data`,不删除数据,不改原服务,不重启原Node进程。
|
||||
@@ -0,0 +1,58 @@
|
||||
# Rednote H5
|
||||
|
||||
面向 `/rednote/` 的浏览器入口,复用 `h5/` 的页面、相机、检测、评分、报告及管理后台。不是小红书账号授权:同意隐私说明后由服务器生成随机浏览器身份,使用独立 HttpOnly Cookie 保存30天,可查看最近5份完整报告。清除浏览器数据、清除本机身份、身份到期或更换设备后无法恢复原身份;不同 App 的内置浏览器可能不共享身份。
|
||||
|
||||
## 本地样本环境
|
||||
|
||||
```powershell
|
||||
cd D:\juzi\rednote
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- 新入口:`http://127.0.0.1:18380/rednote/`,后台 `/rednote/admin.html`。
|
||||
- 对照入口:`http://127.0.0.1:18380/state/`,本地未配置微信真实授权,只提供原游客体验。
|
||||
- 数据:`../temp/rednote-local-data/rednote/` 和 `state/`,与真实 H5 数据隔离。
|
||||
- 所有检测返回固定样本,不连接 Face++ 或淘宝/微信。后台在回环开发环境不要求 Basic Auth,公网必须使用部署模板的鉴权保护。
|
||||
|
||||
## 配置与隔离
|
||||
|
||||
真实服务直接运行共享 `h5/server.js`,不要运行 fixture-server 或 dev 脚本。新增 `JUZI_ENTRY_MODE=rednote`,并显式配置外部独立 `JUZI_DATA_ROOT` 及以 `/rednote/` 结尾的 `PUBLIC_BASE_URL`。配置错误会在初始化账号/数据目录之前终止。部署模板另通过 `JUZI_PROTECTED_DATA_ROOT` 拦截旧数据路径(含符号链接),并将旧部署目录以只读方式挂载到新服务的运行环境;生产模式要求HTTPS。未设置入口模式时仍是原 state 模式。
|
||||
|
||||
- 原入口:`juzi_session`,原有 `/state/` Cookie 路径,微信和临时游客规则不变。
|
||||
- 新入口:`juzi_rednote_session`,`/rednote/` 路径,服务器持久化浏览器身份;不会恢复微信 Cookie,也不会接受微信/淘宝登录接口。
|
||||
- 账号身份和会话仅由服务器生成,客户端提交的 token、用户 ID、channel 不用来恢复身份或决定来源;服务器只保存会话 token 哈希。
|
||||
- 表单缓存使用 `juzi.rednote.profile.v1.*` 和 `juzi.rednote.auth.kind.v1`,避免同域 localStorage 与 state 相互清理。
|
||||
- 首次打开仅生成页面内体验状态,同意隐私后才创建服务器身份;有效 Cookie 自动恢复。清除本机身份会撤销服务端会话并清理本机档案缓存,界面明确说明历史不可恢复。
|
||||
- 检测记录统一由服务端标记 `channel=rednote`。新实例后台及 CSV 可查看新来源,原实例数据和后台不改。
|
||||
- 每个新实例同时只接纳1个主检测;上游主检测和侧脸请求使用有界串行队列。限流只约束新实例,不能代替旧入口和新入口共享 Face++ 账户的全局额度管理。
|
||||
- 前台展示最近5份报告,后台检测记录及照片的保留方式沿用现有微信入口;没有删除或迁移原渠道数据。
|
||||
|
||||
## 验证
|
||||
|
||||
```powershell
|
||||
npm test
|
||||
npm run test:browser
|
||||
cd ..\taobao
|
||||
npm test
|
||||
npm run check:shared
|
||||
npm run test:h5
|
||||
```
|
||||
|
||||
新测试覆盖持久身份、首次同意、Secure Cookie、来源防伪、重启恢复、最近5份报告、越权拦截、侧脸缓存、并发限制,以及同一浏览器下 state/rednote 的登录、缓存、历史和退出隔离。浏览器截图位于 `../temp/rednote-browser/`。自动测试使用样本,不代表手机相机已验收。
|
||||
|
||||
## 部署方式
|
||||
|
||||
已于2026-10-19发布到生产,版本 `20261019-225927-4af825a`。发布验证、备份位置及回退说明见 [发布记录](DEPLOYMENT-20261019.md)。
|
||||
|
||||
2026-10-19发布前基线:线上 `juzi-h5.service` 在127.0.0.1:8080运行,工作目录 `/home/ubuntu/juzi/h5`,版本 `fc9f251`;`/state/` 正常,微信授权已启用。新入口使用独立目录、进程和数据存储。
|
||||
|
||||
部署到 `/home/ubuntu/juzi-rednote/releases/<版本>/`,`current` 仅指向新入口发布目录;数据固定 `/home/ubuntu/juzi-rednote-data`。不能在旧线上目录执行 git pull、覆盖其静态文件、移动旧数据或重启旧服务。
|
||||
|
||||
`deploy/juzi-rednote.service` 和 `deploy/nginx-rednote.locations.conf` 是部署模板,不会自动安装。管理员通过私有 `/etc/juzi/rednote.env` 配置 Face++ 凭据;不复制旧微信配置,也不将密钥放进仓库。
|
||||
|
||||
发布顺序:备份原Nginx配置并记录旧进程/版本/健康基线 → 部署新目录和独立数据 → 启动新服务并用8081检查 → 在现有HTTPS server中仅添加新location → `nginx -t`通过后平滑reload → 两入口健康、鉴权和数据隔离检查 → 用户手机验收相机及微信登录。
|
||||
|
||||
回退:还原新增前的Nginx配置、检查并reload,停止新服务;保留新入口数据供复核。无需重启旧服务,不删除或覆盖任何原数据。
|
||||
|
||||
共享主机、Nginx与Face++额度仍是公共依赖;生产发布记录应包含版本、配置备份位置及两入口验证结果。
|
||||
@@ -0,0 +1,27 @@
|
||||
[Unit]
|
||||
Description=1'me rednote H5 - isolated browser identities
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ubuntu
|
||||
WorkingDirectory=/home/ubuntu/juzi-rednote/current/h5
|
||||
Environment=JUZI_ENTRY_MODE=rednote
|
||||
Environment=NODE_ENV=production
|
||||
Environment=HOST=127.0.0.1
|
||||
Environment=PORT=8081
|
||||
Environment=PUBLIC_BASE_URL=https://1me.xajuzi.com/rednote/
|
||||
Environment=JUZI_DATA_ROOT=/home/ubuntu/juzi-rednote-data
|
||||
Environment=JUZI_PROTECTED_DATA_ROOT=/home/ubuntu/juzi/h5/data
|
||||
Environment=TRUST_LOOPBACK_PROXY=1
|
||||
EnvironmentFile=/etc/juzi/rednote.env
|
||||
ExecStart=/usr/bin/node /home/ubuntu/juzi-rednote/current/h5/server.js
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ReadOnlyPaths=/home/ubuntu/juzi
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,42 @@
|
||||
# Insert into the EXISTING HTTPS server during an authorized deployment.
|
||||
# Retain all /state/, /api/, TLS and WeChat verification locations unchanged.
|
||||
location = /rednote {
|
||||
return 301 /rednote/;
|
||||
}
|
||||
location = /rednote/admin.html {
|
||||
auth_basic "juzi admin";
|
||||
auth_basic_user_file /etc/nginx/.htpasswd-juzi;
|
||||
proxy_pass http://127.0.0.1:8081/admin.html;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
location = /rednote/css/admin.css {
|
||||
auth_basic "juzi admin";
|
||||
auth_basic_user_file /etc/nginx/.htpasswd-juzi;
|
||||
proxy_pass http://127.0.0.1:8081/css/admin.css;
|
||||
}
|
||||
location = /rednote/js/admin.js {
|
||||
auth_basic "juzi admin";
|
||||
auth_basic_user_file /etc/nginx/.htpasswd-juzi;
|
||||
proxy_pass http://127.0.0.1:8081/js/admin.js;
|
||||
}
|
||||
location ^~ /rednote/api/admin/ {
|
||||
auth_basic "juzi admin";
|
||||
auth_basic_user_file /etc/nginx/.htpasswd-juzi;
|
||||
proxy_pass http://127.0.0.1:8081/api/admin/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 150s;
|
||||
}
|
||||
location ^~ /rednote/data/ {
|
||||
return 404;
|
||||
}
|
||||
location /rednote/ {
|
||||
client_max_body_size 20m;
|
||||
proxy_pass http://127.0.0.1:8081/;
|
||||
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,62 @@
|
||||
{
|
||||
"name": "juzi-skin-rednote",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "juzi-skin-rednote",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"playwright": "1.58.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "juzi-skin-rednote",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev.cjs",
|
||||
"test": "node --test test/*.test.cjs",
|
||||
"test:browser": "node test/browser.cjs"
|
||||
},
|
||||
"devDependencies": { "playwright": "1.58.2" }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
'use strict';
|
||||
const path = require('node:path'), { spawn } = require('node:child_process');
|
||||
const root = path.resolve(__dirname, '../..'), children = [];
|
||||
for (const mode of ['state', 'rednote']) {
|
||||
children.push(spawn(process.execPath, [path.join(__dirname, 'fixture-server.cjs')], {
|
||||
env: { ...process.env, JUZI_ENTRY_MODE: mode, PORT: mode === 'state' ? '18381' : '18382',
|
||||
PUBLIC_BASE_URL: 'http://127.0.0.1:18380/' + mode + '/', JUZI_DATA_ROOT: path.join(root, 'temp/rednote-local-data', mode) },
|
||||
stdio: 'inherit'
|
||||
}));
|
||||
}
|
||||
const proxy = require('./proxy.cjs')({ statePort: 18381, rednotePort: 18382 });
|
||||
proxy.listen(18380, '127.0.0.1', () => console.log('Samples only: http://127.0.0.1:18380/rednote/ and /state/; no external API calls'));
|
||||
let closing = false;
|
||||
function stop(code = 0) {
|
||||
if (closing) return; closing = true;
|
||||
children.forEach(child => { if (child.exitCode == null) child.kill(); });
|
||||
proxy.close(() => { process.exitCode = code; });
|
||||
}
|
||||
children.forEach(child => child.on('exit', code => { if (!closing) stop(code || 1); }));
|
||||
process.on('SIGINT', () => stop()); process.on('SIGTERM', () => stop());
|
||||
@@ -0,0 +1,16 @@
|
||||
'use strict';
|
||||
// Local test/dev runner only. The production service launches h5/server.js directly.
|
||||
const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm');
|
||||
if (!process.env.JUZI_DATA_ROOT || !process.env.PUBLIC_BASE_URL) throw Error('Explicit isolated data root and public URL required');
|
||||
Object.assign(process.env, { NODE_ENV: 'test', HOST: '127.0.0.1', SKIN_API_KEY: 'fixture', SKIN_API_SECRET: 'fixture',
|
||||
WECHAT_APP_ID: '', WECHAT_APP_SECRET: '', TAOBAO_APP_KEY: '', TAOBAO_APP_SECRET: '' });
|
||||
const root = path.resolve(__dirname, '../..'), context = { window: {} };
|
||||
vm.runInNewContext(fs.readFileSync(path.join(root, 'h5/assets/mock/report-fixture.js'), 'utf8'), context);
|
||||
const fixture = context.window.MOCK_FIXTURE;
|
||||
global.fetch = async url => {
|
||||
if (!String(url).startsWith('https://api-cn.faceplusplus.com/')) throw Error('External requests disabled in fixture mode');
|
||||
if (process.env.FIXTURE_DELAY_MS) await new Promise(resolve => setTimeout(resolve, Number(process.env.FIXTURE_DELAY_MS)));
|
||||
return { ok: true, status: 200, json: async () => ({ request_id: 'fixture', result: structuredClone(fixture.result),
|
||||
face_rectangle: fixture.face_rectangle, left_side_result: fixture.side.left, right_side_result: fixture.side.right }) };
|
||||
};
|
||||
require('../../h5/server');
|
||||
@@ -0,0 +1,17 @@
|
||||
'use strict';
|
||||
const http = require('node:http');
|
||||
module.exports = function proxy({ statePort, rednotePort }) {
|
||||
return http.createServer((req, res) => {
|
||||
const mode = /^\/(state|rednote)(?:\/|\?|$)/.exec(req.url)?.[1];
|
||||
if (!mode) { res.writeHead(404); return res.end('Not Found'); }
|
||||
if (req.url === '/' + mode) { res.writeHead(301, { Location: '/' + mode + '/' }); return res.end(); }
|
||||
const upstream = http.request({ host: '127.0.0.1', port: mode === 'state' ? statePort : rednotePort,
|
||||
method: req.method, path: req.url.slice(mode.length + 1) || '/',
|
||||
headers: { ...req.headers, 'x-real-ip': '127.0.0.1', 'x-forwarded-proto': 'http' } }, reply => {
|
||||
res.writeHead(reply.statusCode, reply.headers); reply.pipe(res);
|
||||
});
|
||||
upstream.setTimeout(150000, () => upstream.destroy());
|
||||
upstream.on('error', () => { if (!res.headersSent) res.writeHead(502); res.end(); });
|
||||
req.pipe(upstream);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
'use strict';
|
||||
const { test } = require('node:test'), assert = require('node:assert/strict');
|
||||
const fs = require('node:fs'), os = require('node:os'), path = require('node:path'), vm = require('node:vm');
|
||||
const { freePort, start, stop } = require('./support.cjs');
|
||||
const fx = { window: {} };
|
||||
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../../h5/assets/mock/report-fixture.js'), 'utf8'), fx);
|
||||
const fixture = fx.window.MOCK_FIXTURE;
|
||||
|
||||
test('persistent browser consent, cookies, owner isolation, reports, source, admission control and restart', async () => {
|
||||
const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-rednote-backend-')), port = await freePort();
|
||||
const base = 'http://127.0.0.1:' + port, publicBase = base + '/rednote/';
|
||||
let child;
|
||||
const get = async (route, cookie = '') => fetch(base + route, { headers: { cookie } });
|
||||
const post = async (route, data = {}, cookie = '', headers = {}) => {
|
||||
const response = await fetch(base + route, { method: 'POST', headers: { 'Content-Type': 'application/json', cookie, ...headers }, body: JSON.stringify(data) });
|
||||
return { status: response.status, cookie: response.headers.getSetCookie(), data: await response.json() };
|
||||
};
|
||||
try {
|
||||
child = await start({ dataRoot, port, publicBase, delay: 80 });
|
||||
const html = await (await get('/')).text();
|
||||
assert(html.includes('window.JuziEntry={mode:"rednote"')); assert(!html.includes('data-action="login-wechat"'));
|
||||
assert.equal((await (await get('/api/auth/config')).json()).wechat, false);
|
||||
assert.equal((await post('/api/auth/wechat', { consent: true })).status, 404);
|
||||
assert.equal((await get('/api/auth/wechat/callback?code=fake&state=fake')).status, 404);
|
||||
assert.equal((await post('/api/taobao/session')).status, 404);
|
||||
assert.equal((await post('/api/auth/browser', { consent: false })).status, 400);
|
||||
assert.equal((await post('/api/auth/browser', { consent: true }, '', { Origin: 'https://evil.invalid' })).status, 403);
|
||||
assert.equal(fs.existsSync(path.join(dataRoot, 'accounts/identities.json')), false, 'no identity before consent');
|
||||
const alice = await post('/api/auth/browser', { consent: true, channel: 'taobao', user_id: 'forged' });
|
||||
assert.equal(alice.data.user.kind, 'browser_persistent'); assert.notEqual(alice.data.user.id, 'forged');
|
||||
assert.match(alice.cookie[0], /juzi_rednote_session=[a-f0-9]{64}; Path=\/rednote\/; HttpOnly; SameSite=Lax; Max-Age=2592000/);
|
||||
const cookie = alice.cookie[0].split(';')[0];
|
||||
assert.equal((await post('/api/auth/browser', { consent: true }, cookie)).data.user.id, alice.data.user.id);
|
||||
assert.equal((await post('/api/auth/browser', { consent: true, token: cookie })).status, 401);
|
||||
const bob = await post('/api/auth/browser', { consent: true }), bobCookie = bob.cookie[0].split(';')[0];
|
||||
const body = { front_image: fixture.photos[0], left_image: fixture.photos[0], right_image: fixture.photos[0],
|
||||
profile: { real_age: 44, daily_skin_type: '干性肌肤', gender: '女' }, channel: 'h5' };
|
||||
const [first, busy] = await Promise.all([post('/api/skin-analyze', body, cookie), post('/api/skin-analyze', body, cookie)]);
|
||||
assert.equal(first.status, 200); assert.equal(first.data.report_saved, true);
|
||||
assert.equal(busy.status, 429); assert.equal(busy.data.code, 'BUSY');
|
||||
assert.equal((await get('/api/reports/' + first.data.record_id, bobCookie)).status, 404);
|
||||
assert.equal((await get('/api/reports', cookie.replace('juzi_rednote_session=', 'juzi_session='))).status, 401);
|
||||
assert.equal((await get('/data/accounts/identities.json')).status, 403);
|
||||
const detail = await (await get('/api/admin/records/' + first.data.record_id)).json();
|
||||
assert.equal(detail.record.channel, 'rednote');
|
||||
assert(detail.photos.front.startsWith('/rednote/api/admin/photos/'));
|
||||
const ids = [first.data.record_id]; let last = first;
|
||||
for (let i = 0; i < 5; i++) { last = await post('/api/skin-analyze', body, cookie); assert.equal(last.data.report_saved, true); ids.push(last.data.record_id); }
|
||||
const history = await (await get('/api/reports', cookie)).json();
|
||||
assert.equal(history.total, 5); assert.equal(history.items[0].id, ids.at(-1));
|
||||
assert.equal((await get('/api/reports/' + ids[0], cookie)).status, 404);
|
||||
assert.equal((await (await get('/api/reports', bobCookie)).json()).total, 0);
|
||||
const stats = await (await get('/api/admin/stats')).json(); assert.equal(stats.by_channel.rednote.total, 6);
|
||||
assert.equal((await (await get('/api/admin/records?channel=h5')).json()).total, 0);
|
||||
const csv = await (await get('/api/admin/export?channel=rednote')).text(); assert.equal(csv.trim().split('\n').length, 7); assert(csv.includes(',rednote'));
|
||||
const snapshot = await (await get('/api/reports/' + last.data.record_id, cookie)).json();
|
||||
await stop(child); child = await start({ dataRoot, port, publicBase });
|
||||
assert.equal((await (await get('/api/auth/session', cookie)).json()).user.id, alice.data.user.id);
|
||||
assert.deepEqual((await (await get('/api/reports/' + last.data.record_id, cookie)).json()).report, snapshot.report);
|
||||
const side = await post('/api/wrinkle-views', { token: last.data.wrinkle_job.token }, cookie);
|
||||
assert.equal(side.data.ok, true); assert.equal(side.data.wrinkle_views.left.status, 'ready');
|
||||
assert.equal((await post('/api/wrinkle-views', { token: last.data.wrinkle_job.token }, bobCookie)).status, 404);
|
||||
assert.equal((await post('/api/auth/logout', {}, cookie)).status, 200);
|
||||
assert.equal((await get('/api/reports', cookie)).status, 401);
|
||||
assert.notEqual((await post('/api/auth/browser', { consent: true }, cookie)).data.user.id, alice.data.user.id);
|
||||
const db = fs.readFileSync(path.join(dataRoot, 'accounts/identities.json'), 'utf8');
|
||||
assert(!db.includes(cookie.split('=')[1]), 'server stores session hashes, not cookie secrets');
|
||||
} finally { await stop(child); fs.rmSync(dataRoot, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict'), fs = require('node:fs'), path = require('node:path'), vm = require('node:vm');
|
||||
const { chromium } = require('playwright'), { freePort, start, stop } = require('./support.cjs');
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const artifacts = path.join(root, 'temp/rednote-browser'); fs.mkdirSync(artifacts, { recursive: true });
|
||||
const runRoot = fs.mkdtempSync(path.join(root, 'temp/rednote-dual-'));
|
||||
const ports = {}, children = []; let browser, proxy;
|
||||
function disk(dir) {
|
||||
const out = {};
|
||||
function visit(current) { for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const full = path.join(current, entry.name); if (entry.isDirectory()) visit(full); else out[path.relative(dir, full)] = fs.readFileSync(full).toString('base64');
|
||||
} } visit(dir); return out;
|
||||
}
|
||||
async function screenshot(page, name, fullPage = false) {
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.screenshot({ path: path.join(artifacts, name + '.png'), fullPage, animations: 'disabled' });
|
||||
}
|
||||
async function consent(page, capture = false) {
|
||||
await page.click('#s-splash [data-action="open-privacy"]');
|
||||
if (capture) {
|
||||
for (const size of [{ width: 320, height: 568 }, { width: 375, height: 726 }]) {
|
||||
await page.setViewportSize(size);
|
||||
const layout = await page.evaluate(() => {
|
||||
const body = document.querySelector('#p-privacy .popup-body').getBoundingClientRect();
|
||||
const row = document.querySelector('#privacy-row').getBoundingClientRect();
|
||||
const button = document.querySelector('#p-privacy .popup-btn').getBoundingClientRect();
|
||||
return { separated: body.bottom <= row.top && row.bottom <= button.top, visible: button.bottom <= innerHeight };
|
||||
});
|
||||
assert(layout.separated && layout.visible, 'persistence disclosure must not overlap consent or hide Continue');
|
||||
}
|
||||
await screenshot(page, 'rednote-consent');
|
||||
}
|
||||
await page.locator('#privacy-check').click();
|
||||
await page.click('#p-privacy [data-action="agree-continue"]');
|
||||
await page.waitForSelector('#s-profile.active');
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
for (const key of ['proxy', 'state', 'rednote']) ports[key] = await freePort();
|
||||
const base = 'http://127.0.0.1:' + ports.proxy;
|
||||
const stateData = path.join(runRoot, 'state'), rednoteData = path.join(runRoot, 'rednote');
|
||||
const wechat = require('../../h5/test/wechat-fixture.cjs')(path.join(stateData, 'accounts'))[0];
|
||||
for (const mode of ['state', 'rednote']) children.push(await start({ mode, dataRoot: path.join(runRoot, mode), port: ports[mode], publicBase: base + '/' + mode + '/' }));
|
||||
proxy = require('../scripts/proxy.cjs')({ statePort: ports.state, rednotePort: ports.rednote });
|
||||
await new Promise(resolve => proxy.listen(ports.proxy, '127.0.0.1', resolve));
|
||||
const baseline = disk(stateData);
|
||||
browser = await chromium.launch({ headless: true });
|
||||
let context = await browser.newContext({ viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true });
|
||||
await context.addCookies([{ name: 'juzi_session', value: wechat.token, domain: '127.0.0.1', path: '/state/' }]);
|
||||
const state = await context.newPage(), rednote = await context.newPage(), errors = [], rednoteRequests = [];
|
||||
for (const page of [state, rednote]) page.on('pageerror', error => errors.push(error.message));
|
||||
rednote.on('request', request => { if (request.url().includes('/api/')) rednoteRequests.push(request.url()); });
|
||||
await state.goto(base + '/state/'); await state.waitForSelector('#s-splash.active');
|
||||
assert.equal(await state.locator('#s-splash [data-auth-status]').textContent(), '微信已登录');
|
||||
const stateKind = await state.evaluate(() => localStorage.getItem('juzi.auth.kind.v1'));
|
||||
await rednote.goto(base + '/rednote/'); await rednote.waitForSelector('#s-splash.active');
|
||||
assert.equal(await rednote.locator('[data-action="login-wechat"]').count(), 0);
|
||||
assert.equal((await context.cookies()).filter(c => c.name === 'juzi_rednote_session').length, 0, 'opening the page does not persist an identity before consent');
|
||||
await screenshot(rednote, 'rednote-home');
|
||||
await rednote.click('#s-splash [data-action="history"]');
|
||||
await rednote.waitForSelector('#s-history.active');
|
||||
assert((await rednote.locator('#identity-note').textContent()).includes('同意隐私'));
|
||||
await rednote.goBack(); await rednote.waitForSelector('#s-splash.active');
|
||||
await consent(rednote, true);
|
||||
const alice = await rednote.evaluate(() => AccountClient.user()); assert.equal(alice.kind, 'browser_persistent');
|
||||
const cookie = (await context.cookies()).find(c => c.name === 'juzi_rednote_session');
|
||||
assert.equal(cookie.path, '/rednote/'); assert(cookie.httpOnly && cookie.expires > Date.now() / 1000);
|
||||
await rednote.evaluate(() => ProfileStore.create(() => localStorage).write(AccountClient.user(), { birth: '1996-01', gender: '女', region: { province: '北京市', city: '北京市' }, skinIndex: 2 }));
|
||||
const stored = await context.storageState();
|
||||
await context.close();
|
||||
context = await browser.newContext({ storageState: stored, viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true });
|
||||
const restored = await context.newPage(); restored.on('pageerror', e => errors.push(e.message));
|
||||
restored.on('request', r => { if (r.url().includes('/api/')) rednoteRequests.push(r.url()); });
|
||||
await restored.goto(base + '/rednote/'); await restored.waitForSelector('#s-splash.active');
|
||||
assert.equal(await restored.evaluate(() => AccountClient.user().id), alice.id);
|
||||
await consent(restored); assert.equal(await restored.locator('#f-birth').inputValue(), '1996-01');
|
||||
assert.equal(await restored.locator('#f-gender').inputValue(), '女');
|
||||
await screenshot(restored, 'rednote-profile');
|
||||
const fx = { window: {} }; vm.runInNewContext(fs.readFileSync(path.join(root, 'h5/assets/mock/report-fixture.js'), 'utf8'), fx);
|
||||
const photo = fx.window.MOCK_FIXTURE.photos[0];
|
||||
const payload = await (await context.request.post(base + '/rednote/api/skin-analyze', { data: {
|
||||
front_image: photo, left_image: photo, right_image: photo, profile: { real_age: 30, daily_skin_type: '中性肌肤' }, channel: 'taobao' } })).json();
|
||||
assert.equal(payload.report_saved, true);
|
||||
await restored.goto(base + '/rednote/'); await restored.waitForSelector('#s-splash.active');
|
||||
await restored.click('#s-splash [data-action="history"]'); await restored.waitForSelector('.history-detail');
|
||||
assert(!(await restored.locator('#identity-note').textContent()).includes('微信'));
|
||||
await screenshot(restored, 'rednote-history');
|
||||
await restored.click('.history-detail'); await restored.waitForSelector('#s-report.active');
|
||||
assert.equal(await restored.locator('#stat-score b').textContent(), String(payload.vm.total));
|
||||
assert.equal(await restored.locator('#stat-age b').textContent(), String(payload.vm.skinAgeDisplay));
|
||||
await screenshot(restored, 'rednote-report', true);
|
||||
const stateAgain = await context.newPage(); await stateAgain.goto(base + '/state/'); await stateAgain.waitForSelector('#s-splash.active');
|
||||
assert.equal(await stateAgain.evaluate(() => AccountClient.user().id), wechat.user.id);
|
||||
assert.equal(await stateAgain.evaluate(() => localStorage.getItem('juzi.auth.kind.v1')), stateKind);
|
||||
assert((await stateAgain.evaluate(() => Object.keys(localStorage))).some(key => key.startsWith('juzi.rednote.profile.v1.')));
|
||||
const admin = await browser.newPage({ viewport: { width: 1280, height: 900 } }), adminRequests = []; admin.on('pageerror', e => errors.push(e.message));
|
||||
admin.on('request', r => { if (r.url().includes('/api/')) adminRequests.push(r.url()); });
|
||||
await admin.goto(base + '/rednote/admin.html'); await admin.waitForSelector('#tbl-records .thumb');
|
||||
await admin.locator('#tbl-records tbody tr').first().click(); await admin.waitForSelector('#drawer:not(.hidden)');
|
||||
await admin.waitForSelector('#d-body a[download]');
|
||||
assert((await admin.locator('#d-body a[href$="/raw"]').getAttribute('href')).startsWith('/rednote/api/admin/'));
|
||||
assert(adminRequests.every(url => url.startsWith(base + '/rednote/api/')));
|
||||
assert(adminRequests.some(url => url.includes('/api/admin/photos/')));
|
||||
await screenshot(admin, 'rednote-admin', true);
|
||||
await admin.click('#btn-close');
|
||||
await admin.click('[data-tab="stats"]');
|
||||
const [download] = await Promise.all([admin.waitForEvent('download'), admin.click('#btn-export')]);
|
||||
assert(download.url().startsWith(base + '/rednote/api/admin/export'));
|
||||
assert(fs.readFileSync(await download.path(), 'utf8').includes(',rednote'));
|
||||
await admin.click('[data-tab="records"]');
|
||||
assert(adminRequests.every(url => url.startsWith(base + '/rednote/api/')));
|
||||
await restored.goto(base + '/rednote/'); await restored.waitForSelector('#s-splash.active');
|
||||
restored.once('dialog', dialog => dialog.dismiss()); await restored.click('#s-splash [data-action="logout-wechat"]');
|
||||
assert.equal(await restored.evaluate(() => AccountClient.user().id), alice.id);
|
||||
restored.once('dialog', dialog => dialog.accept()); await restored.click('#s-splash [data-action="logout-wechat"]');
|
||||
await restored.waitForFunction(() => AccountClient.user().kind === 'browser');
|
||||
assert.equal((await context.request.get(base + '/rednote/api/reports')).status(), 401);
|
||||
await restored.goBack();
|
||||
assert.equal(await restored.locator('#s-report.active').count(), 0, 'back navigation cannot restore the old private report after clearing identity');
|
||||
assert.equal((await context.request.get(base + '/state/api/auth/session')).status(), 200);
|
||||
assert.equal((await (await context.request.get(base + '/state/api/auth/session')).json()).user.id, wechat.user.id);
|
||||
assert(rednoteRequests.every(url => url.startsWith(base + '/rednote/api/')));
|
||||
assert.deepEqual(disk(stateData), baseline, 'rednote operations never modify original state identities/reports/data');
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS: consent-only creation, persistent browser restore, profile cache, saved/history reports, admin API/photo subpaths, source isolation, independent logout and unchanged state data; screenshots in temp/rednote-browser');
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
if (proxy) await new Promise(resolve => proxy.close(resolve));
|
||||
for (const child of children) await stop(child);
|
||||
fs.rmSync(runRoot, { recursive: true, force: true });
|
||||
}
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,51 @@
|
||||
'use strict';
|
||||
const { test } = require('node:test'), assert = require('node:assert/strict'), path = require('node:path');
|
||||
const config = require('../../h5/lib/entry-config');
|
||||
test('rednote rejects missing, shared data paths and wrong public prefixes before initializing storage', () => {
|
||||
const root = path.resolve(__dirname, '../../h5');
|
||||
assert.equal(config({}, root).mode, 'state');
|
||||
const env = { JUZI_ENTRY_MODE: 'rednote', PUBLIC_BASE_URL: 'https://skin.example.test/rednote/' };
|
||||
for (const dataRoot of [undefined, root, path.join(root, 'custom-data'), path.join(root, 'data'), path.join(root, 'data/subdir')]) {
|
||||
assert.throws(() => config({ ...env, JUZI_DATA_ROOT: dataRoot }, root), /own JUZI_DATA_ROOT/);
|
||||
}
|
||||
assert.throws(() => config({ ...env, JUZI_DATA_ROOT: path.join(root, '../temp/rednote'), PUBLIC_BASE_URL: 'https://skin.example.test/state/' }, root), /rednote/);
|
||||
assert.equal(config({ ...env, JUZI_DATA_ROOT: path.join(root, '../temp/rednote') }, root).channel, 'rednote');
|
||||
assert.throws(() => config({ ...env, JUZI_DATA_ROOT: path.join(root, '../temp/original'), JUZI_PROTECTED_DATA_ROOT: path.join(root, '../temp/original') }, root), /own JUZI_DATA_ROOT/);
|
||||
assert.throws(() => config({ ...env, JUZI_DATA_ROOT: path.join(root, '../temp/rednote'), NODE_ENV: 'production', PUBLIC_BASE_URL: 'http://skin.example.test/rednote/' }, root), /rednote/);
|
||||
});
|
||||
test('HTTPS browser cookies are secure and persistent, even when WeChat credentials exist', async () => {
|
||||
const fs = require('node:fs'), os = require('node:os'), { Readable } = require('node:stream');
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-rednote-secure-'));
|
||||
try {
|
||||
const accounts = require('../../h5/lib/accounts')(dir, { JUZI_ENTRY_MODE: 'rednote', PUBLIC_BASE_URL: 'https://skin.example.test/rednote/', WECHAT_APP_ID: 'configured', WECHAT_APP_SECRET: 'fixture' });
|
||||
assert.equal(accounts.wechatAvailable, false);
|
||||
const headers = {};
|
||||
const req = Readable.from([Buffer.from('{"consent":true}')]);
|
||||
req.method = 'POST'; req.url = '/api/auth/browser'; req.headers = { host: 'skin.example.test', origin: 'https://skin.example.test' }; req.socket = { remoteAddress: '127.0.0.1' };
|
||||
let response;
|
||||
await accounts.route(req, { setHeader: (k, v) => { headers[k] = v; }, getHeader: k => headers[k], writeHead() {}, end: text => { response = JSON.parse(text); } }, '/api/auth/browser');
|
||||
assert.match(headers['Set-Cookie'][0], /HttpOnly; SameSite=Lax; Max-Age=2592000; Secure$/);
|
||||
assert.equal(response.user.kind, 'browser_persistent');
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
test('data-directory aliases cannot bypass protection of the original deployment', () => {
|
||||
const fs = require('node:fs'), os = require('node:os');
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-rednote-path-'));
|
||||
try {
|
||||
const original = path.join(dir, 'original'), alias = path.join(dir, 'alias'); fs.mkdirSync(original);
|
||||
fs.symlinkSync(original, alias, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
assert.throws(() => config({ JUZI_ENTRY_MODE: 'rednote', PUBLIC_BASE_URL: 'https://skin.example.test/rednote/',
|
||||
JUZI_DATA_ROOT: path.join(alias, 'nested'), JUZI_PROTECTED_DATA_ROOT: original }, path.join(dir, 'h5')), /own JUZI_DATA_ROOT/);
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
test('new-channel provider requests serialize through body consumption and cap the queue', async () => {
|
||||
let active = 0, peak = 0;
|
||||
const gated = require('../../h5/lib/provider-gate')(async () => {
|
||||
peak = Math.max(peak, ++active);
|
||||
return { ok: true, status: 200, json: async () => { await new Promise(resolve => setTimeout(resolve, 10)); active--; return { ok: true }; } };
|
||||
}, 2);
|
||||
const a = gated('fixture'), b = gated('fixture');
|
||||
await assert.rejects(gated('fixture'), /queue is full/);
|
||||
assert.deepEqual(await (await a).json(), { ok: true }); await b;
|
||||
assert.equal(peak, 1); assert.equal(active, 0);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
'use strict';
|
||||
const http = require('node:http'), path = require('node:path'), { spawn } = require('node:child_process'), { once } = require('node:events');
|
||||
async function freePort() {
|
||||
const server = http.createServer(); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const port = server.address().port; await new Promise(resolve => server.close(resolve)); return port;
|
||||
}
|
||||
async function start({ mode = 'rednote', dataRoot, port, publicBase, delay = 0 }) {
|
||||
const child = spawn(process.execPath, [path.join(__dirname, '../scripts/fixture-server.cjs')], {
|
||||
env: { ...process.env, JUZI_ENTRY_MODE: mode, JUZI_DATA_ROOT: dataRoot, PORT: String(port),
|
||||
PUBLIC_BASE_URL: publicBase, FIXTURE_DELAY_MS: String(delay) }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => { child.kill(); reject(Error('startup timeout')); }, 15000);
|
||||
let error = '';
|
||||
child.stderr.on('data', bytes => { error += bytes; });
|
||||
child.stdout.on('data', bytes => { if (String(bytes).includes('已启动')) { clearTimeout(timer); resolve(); } });
|
||||
child.once('exit', code => { clearTimeout(timer); reject(Error('backend exited ' + code + ': ' + error)); });
|
||||
});
|
||||
return child;
|
||||
}
|
||||
async function stop(child) {
|
||||
if (child && child.exitCode == null) { const exited = once(child, 'exit'); child.kill(); await exited; }
|
||||
}
|
||||
module.exports = { freePort, start, stop };
|
||||
@@ -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 |