Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e34eeb1df | ||
|
|
873b45d3a5 | ||
|
|
774376fa16 | ||
|
|
381da60ae4 | ||
|
|
8bc6251899 | ||
|
|
2eb0d8e807 | ||
|
|
43a5373233 | ||
|
|
fed52930cc | ||
|
|
f5c416dd9d | ||
|
|
65ba3cc7a5 | ||
|
|
ac0b7ca42b | ||
|
|
459e21dad4 | ||
|
|
4af825a5e4 | ||
|
|
9eec4369da | ||
|
|
d07f8cc1bb | ||
|
|
bb6da17f54 | ||
|
|
571d78a2ed | ||
|
|
9181069643 | ||
|
|
b19811f974 | ||
|
|
5e07985b19 | ||
|
|
6a628f837b | ||
|
|
9ff7a582af | ||
|
|
03a8907b4e | ||
|
|
30c90f8df5 | ||
|
|
8365ef459f | ||
|
|
98d6189f5c | ||
|
|
49db1a6f5b | ||
|
|
c6d65493b6 | ||
|
|
fbc556d1a6 | ||
|
|
8115db4ec2 | ||
|
|
480fed40cc | ||
|
|
5632ac43d7 | ||
|
|
482fb4280d | ||
|
|
757b96dfad | ||
|
|
2d80f6dfd0 | ||
|
|
60f0965c5a | ||
|
|
77493bdff5 | ||
|
|
60ae6c97ef | ||
|
|
d8c67762da | ||
|
|
61a7f7c726 | ||
|
|
fc711e3b1c | ||
|
|
6acc541cd8 | ||
|
|
84a92137aa | ||
|
|
7381306139 | ||
|
|
e71368db6d | ||
|
|
4e42e33994 | ||
|
|
1fdbb575b0 | ||
|
|
98935dd168 | ||
|
|
4c478b16e3 | ||
|
|
3efa28b311 | ||
|
|
f1e64565b9 | ||
|
|
6f83f58a09 | ||
|
|
2a49ebdab2 | ||
|
|
63c9e065a9 | ||
|
|
bcf198ca6f | ||
|
|
69dc9962c0 | ||
|
|
ce6dbc20d4 | ||
|
|
c9352fe4ca | ||
|
|
3f165bf9e5 | ||
|
|
92a3965b7d | ||
|
|
e53c809522 | ||
|
|
c7b085e795 | ||
|
|
f3e7eac909 | ||
|
|
fe9623a1d7 | ||
|
|
abb1c4c1de | ||
|
|
d7ae748e08 | ||
|
|
3193a4b683 | ||
|
|
d2931a41e4 | ||
|
|
85790ef985 | ||
|
|
d68f841f0e | ||
|
|
0ec5e5dc32 | ||
|
|
7518dc2307 | ||
|
|
3dda4ec7ff | ||
|
|
55b3a45282 | ||
|
|
ce8d0ff1f7 | ||
|
|
ee744b6b5f | ||
|
|
652c977cec | ||
|
|
4b26288098 | ||
|
|
658b232a39 | ||
|
|
f7902b86c8 | ||
|
|
55f26d91af | ||
|
|
499be22771 | ||
|
|
c06d15a2d4 | ||
|
|
3de730a127 | ||
|
|
3d32434d6f |
@@ -1,19 +1,46 @@
|
||||
# 密钥与敏感配置 —— 绝不入库
|
||||
# 密钥与敏感配置
|
||||
apikey.bash
|
||||
apikey.*
|
||||
aipkey.bash
|
||||
.env
|
||||
.env.*
|
||||
*.env
|
||||
credentials.env
|
||||
|
||||
# ⚠️ 2026-11-02 测试验证阶段的例外(刻意入库,方便切换开发机)
|
||||
# reference/facepp-skinanalyze/credentials.env —— 旷视 Face++ 凭据
|
||||
# backend-java/src/main/resources/application-local.yml —— 淘宝 AppKey/AppSecret
|
||||
# 约束:
|
||||
# 1) 仅限内网私有仓库 git.xiangsilian.com/xsl/juzi,不要 fork 到公开仓库;
|
||||
# 2) 正式上线前必须重置密钥 → 从 git 历史清除 → 改回环境变量/保密字典注入;
|
||||
# 3) 不要复制到聊天、文档、工单等任何外部渠道。
|
||||
# 下面两条用 ! 覆盖上面的通配规则(必须放在被覆盖的规则之后)。
|
||||
!reference/facepp-skinanalyze/credentials.env
|
||||
!/backend-java/src/main/resources/application-local.yml
|
||||
|
||||
# 运行时产物
|
||||
node_modules/
|
||||
*.log
|
||||
__pycache__/
|
||||
aipkey.bash
|
||||
h5/data/
|
||||
temp/
|
||||
|
||||
# Java MVP 构建产物
|
||||
/backend-java/target/
|
||||
|
||||
# 便携工具链(体积大,本机安装,不入库)
|
||||
/tools/jdk17/
|
||||
/tools/maven/
|
||||
/tools/downloads/
|
||||
|
||||
# 大型设计源文件,不入库
|
||||
new_feedback/第三轮反馈20261012/UI外发版10.09.psd
|
||||
|
||||
# 淘宝开放平台自动生成的 Java SDK(对应已废弃的 AppKey 35479221,当前架构不引 SDK)
|
||||
/sdk-java-35479221/
|
||||
|
||||
# 提案 PPT 生成时用的截图素材(临时产物,可重新截取)
|
||||
/temp/ppt_assets/
|
||||
|
||||
# graphify 本地缓存与日期快照(可重建,无需入库)
|
||||
graphify-out/cache/
|
||||
graphify-out/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
kpx234XimQGtFPo6
|
||||
@@ -0,0 +1,28 @@
|
||||
# 巨子测肤 · H5 与淘宝小程序
|
||||
|
||||
本仓库包含两个入口,共用 Node 后端、Face++ 检测、评分与报告话术。
|
||||
|
||||
- `h5/`:现有浏览器/微信公众号 H5、管理后台,以及共享后端 `server.js`。
|
||||
- `taobao/`:淘宝原生小程序。开发、联调、测试说明见 [taobao/README.md](taobao/README.md)。
|
||||
- `backend-java/`:SAE + 淘宝云网关的 Java 最小连通性 MVP;操作步骤见 [docs/SAE-Java-MVP-操作步骤.md](docs/SAE-Java-MVP-操作步骤.md)。
|
||||
- 当前交接(**换开发机先读这份**):[docs/HANDOFF-2026-11-02.md](docs/HANDOFF-2026-11-02.md)。
|
||||
- 上一轮交接:[docs/HANDOFF-2026-11-01.md](docs/HANDOFF-2026-11-01.md)。
|
||||
- 服务器侧配置(systemd / nginx `/tb/` 白名单 / 安装脚本):[deploy/README.md](deploy/README.md)。
|
||||
- `rednote/`:复用 H5 的持久浏览器身份入口,独立运行和存储。说明见 [rednote/README.md](rednote/README.md)。
|
||||
- `h5/js/skin-score.js`、`report-ui.js`、`report-copy.js`:业务规则的源文件。小程序内的副本由同步脚本生成并检查哈希。
|
||||
- `h5/lib/taobao-*.js`:淘宝网关验签、短期上传/异步任务和独立 API 路由。
|
||||
- `docs/`、`reference/`、`design/`、`feedback/`、`new_feedback/`:需求、接口资料、设计与历轮反馈;早期文档的规则可能已被后续实现替代。
|
||||
- `backend/docs/`:后台需求资料;可运行的共享服务实际在 `h5/server.js`。
|
||||
- `temp/`:本地测试数据、预览和截图,已忽略,不提交。
|
||||
|
||||
兼容边界:允许共享后端扩展,但保持 H5 的页面、微信公众号链接、微信登录、游客不留存和历史报告行为。淘宝使用独立账号来源,不把淘宝账号与微信账号自动合并。
|
||||
|
||||
本轮工作只在本地开发和样本验证,未部署或修改线上服务器。启动本地隔离环境:
|
||||
|
||||
```powershell
|
||||
cd taobao
|
||||
npm ci
|
||||
npm run dev:backend
|
||||
```
|
||||
|
||||
H5 本地入口为 http://127.0.0.1:18180;这个环境固定使用测试样本,不调用真实 Face++。生产 H5 的运行说明仍见 [h5/README.md](h5/README.md)。
|
||||
@@ -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,42 @@
|
||||
# UI 外发版 10.09 图片资源
|
||||
|
||||
来源:用户提供的 `UI外发版10.09.psd`。按 Photoshop 图层原样导出,未重新生成或重绘素材。
|
||||
|
||||
## 文件位置
|
||||
|
||||
- `pages/`:10 个画板的原尺寸 PNG,宽 1080px。包含登录、启动、授权弹窗、我的报告、档案填写、拍照弹窗、三视图拍照及长报告。
|
||||
- `pages-overview.jpg`:全部页面缩略总览,按 PSD 原始画板顺序排列。
|
||||
- `assets-overview.jpg`:独立切图总览。
|
||||
- `manifest.json`:页面与切图的尺寸、文件名、图层索引、边界、可见状态及 SHA-256;同时保存完整图层树及文字,便于后续还原。
|
||||
- `../../h5/assets/img/ui-20261009/`:新版页面使用的独立图片,与旧版素材分开存放。H5 使用去除原生顶栏的 `*-h5.jpg` 衍生背景。
|
||||
|
||||
## 前端素材对应
|
||||
|
||||
| 文件 | 用途 |
|
||||
| --- | --- |
|
||||
| `bg-splash.jpg` | 启动页原始照片,不含独立标题、Logo 与按钮 |
|
||||
| `bg-profile.jpg` / `bg-login.jpg` | 档案页 / 登录页背景,按画板裁剪,保留设计里的叠色 |
|
||||
| `bg-report.jpg` / `bg-history.jpg` | 长报告 / 我的报告页纯背景,按画板裁剪 |
|
||||
| `skin-0-dry.png` … `skin-4-mixedoily.png` | 五档肤质插画;也导出了隐藏状态中的变体 |
|
||||
| `product-25.png` / `product-35.png` / `product-50.png` | 三档产品图,分别取对应产品组中的主视觉图层 |
|
||||
| `brand-*.png` / `product-badge.png` | 品牌标志与产品标志 |
|
||||
| `icon-*.png` | 关闭、返回、下拉、相册、切换相机、快门、微信、游客图标 |
|
||||
| `score-ring-*.png` | 设计稿中的评分环参考素材;实际评分仍应动态绘制 |
|
||||
| `camera-mask-front.png` / `camera-mask-right.png` / `camera-mask-left.png` | 三步拍照轮廓蒙版,保留画板坐标与透明区域 |
|
||||
| `camera-guide-front.png` / `camera-guide-right.png` / `camera-guide-left.png` | 对应三步的眼睛与鼻梁虚线定位叠层,与蒙版使用相同画板坐标 |
|
||||
| `sample-*.png` | 设计稿内检测示意图,仅供视觉参考,不代替用户真实检测图 |
|
||||
|
||||
照片使用高质量渐进 JPG;其他素材使用 PNG 保留透明通道。切图按原图层尺寸输出,不强行放大,不将文字与按钮烘焙进背景。整页预览保留设计稿原有系统状态栏和重复/隐藏图层状态。
|
||||
|
||||
`sample-lines.png` 来自隐藏的备选示意图层,设计文件内各分类存在重复示意图,文件名不表示图片已经过该项目评分引擎的真实检测。产品图按主视觉独立导出,卡片圆角和裁剪需由前端容器实现。
|
||||
|
||||
注意:PSD 的画板名「左面」实际文案为第 2 步「右脸出镜」,「右面」实际文案为第 3 步「左脸出镜」。切图按文案中的用户脸部方向命名,清单保存了对应图层路径。
|
||||
|
||||
## 重新导出
|
||||
|
||||
```bash
|
||||
python -m pip install psd-tools Pillow aggdraw
|
||||
python tools/export_psd_assets.py '/absolute/path/UI外发版10.09.psd'
|
||||
```
|
||||
|
||||
重新导出会更新本版本目录内的导出文件,不会覆盖 `h5/assets/img/` 下原有同名线上图片。
|
||||
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 505 KiB |
|
After Width: | Height: | Size: 391 KiB |
@@ -0,0 +1,780 @@
|
||||
{
|
||||
"viewport": {
|
||||
"width": 375,
|
||||
"height": 726
|
||||
},
|
||||
"pages": {
|
||||
"01-splash": {
|
||||
"screen": "s-splash",
|
||||
"text": "Life in Full\n为更好的生命表现\n\n肌肤状态检测\n开始检测",
|
||||
"overlays": [],
|
||||
"rects": [
|
||||
{
|
||||
"class": "splash-slogan",
|
||||
"rect": {
|
||||
"x": 0,
|
||||
"y": 77.671875,
|
||||
"width": 375,
|
||||
"height": 63.46875,
|
||||
"top": 77.671875,
|
||||
"right": 375,
|
||||
"bottom": 141.140625,
|
||||
"left": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "splash-bottom",
|
||||
"rect": {
|
||||
"x": 0,
|
||||
"y": 551.984375,
|
||||
"width": 375,
|
||||
"height": 84,
|
||||
"top": 551.984375,
|
||||
"right": 375,
|
||||
"bottom": 635.984375,
|
||||
"left": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"02-privacy": {
|
||||
"screen": "s-splash",
|
||||
"text": "Life in Full\n为更好的生命表现\n\n肌肤状态检测\n开始检测",
|
||||
"overlays": [
|
||||
{
|
||||
"id": "p-privacy",
|
||||
"text": "欢迎体验1'me\n肌肤状态评估服务!\n\n我们将采集您的面部影像,为您生成专属肌肤分析与状态管理方案。所有数据严格保密,仅用于本次检测分析。\n\n我已阅读并同意相关隐私协议\n继续"
|
||||
}
|
||||
],
|
||||
"rects": [
|
||||
{
|
||||
"class": "splash-slogan",
|
||||
"rect": {
|
||||
"x": 0,
|
||||
"y": 77.671875,
|
||||
"width": 375,
|
||||
"height": 63.46875,
|
||||
"top": 77.671875,
|
||||
"right": 375,
|
||||
"bottom": 141.140625,
|
||||
"left": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "splash-bottom",
|
||||
"rect": {
|
||||
"x": 0,
|
||||
"y": 551.984375,
|
||||
"width": 375,
|
||||
"height": 84,
|
||||
"top": 551.984375,
|
||||
"right": 375,
|
||||
"bottom": 635.984375,
|
||||
"left": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"03-profile": {
|
||||
"screen": "s-profile",
|
||||
"text": "您的肌肤状态\n您的出生年月\n您的性别\n您的所在地区\n您的日常肤质\n偏干\n偏油\n水油平衡\n干性肌肤\n\n全脸干燥,容易紧绷、缺乏光泽\n\n混合偏干肌肤\n\n两颊偏干,T区油脂较少或偶尔出油\n\n中性肌肤\n\n水油平衡,肤感舒适,无明显干燥出油\n\n油性肌肤\n\n全脸易出油,容易泛油光、毛孔明显\n\n混合偏油肌肤\n\nT区易出油,两颊相对清爽或轻微干燥\n\n提交",
|
||||
"overlays": [],
|
||||
"rects": [
|
||||
{
|
||||
"class": "profile-body",
|
||||
"rect": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 375,
|
||||
"height": 726,
|
||||
"top": 0,
|
||||
"right": 375,
|
||||
"bottom": 726,
|
||||
"left": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "page-title",
|
||||
"rect": {
|
||||
"x": 64,
|
||||
"y": 34,
|
||||
"width": 247,
|
||||
"height": 31,
|
||||
"top": 34,
|
||||
"right": 311,
|
||||
"bottom": 65,
|
||||
"left": 64
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"04-guide": {
|
||||
"screen": "s-profile",
|
||||
"text": "您的肌肤状态\n您的出生年月\n1996年1月\n您的性别\n女\n您的所在地区\n北京市\n您的日常肤质\n偏干\n偏油\n水油平衡\n干性肌肤\n\n全脸干燥,容易紧绷、缺乏光泽\n\n混合偏干肌肤\n\n两颊偏干,T区油脂较少或偶尔出油\n\n中性肌肤\n\n水油平衡,肤感舒适,无明显干燥出油\n\n油性肌肤\n\n全脸易出油,容易泛油光、毛孔明显\n\n混合偏油肌肤\n\nT区易出油,两颊相对清爽或轻微干燥\n\n提交",
|
||||
"overlays": [
|
||||
{
|
||||
"id": "p-photoguide",
|
||||
"text": "Life in Full\n为更好的生命表现\n\n请拍摄或上传\n正面及左右侧面照片\n开始"
|
||||
}
|
||||
],
|
||||
"rects": [
|
||||
{
|
||||
"class": "profile-body",
|
||||
"rect": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 375,
|
||||
"height": 726,
|
||||
"top": 0,
|
||||
"right": 375,
|
||||
"bottom": 726,
|
||||
"left": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "page-title",
|
||||
"rect": {
|
||||
"x": 64,
|
||||
"y": 34,
|
||||
"width": 247,
|
||||
"height": 31,
|
||||
"top": 34,
|
||||
"right": 311,
|
||||
"bottom": 65,
|
||||
"left": 64
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"05-camera-front": {
|
||||
"screen": "s-cam",
|
||||
"text": "1/3\n请正对屏幕\n\n检测引擎加载中…\n\n识别不到?请正对镜头,并到光线充足处\n\n正脸拍摄完成\n准备拍摄下一张\n正脸居中\n取下眼镜\n光线充足",
|
||||
"overlays": [],
|
||||
"rects": [
|
||||
{
|
||||
"class": "cam-step",
|
||||
"rect": {
|
||||
"x": 0,
|
||||
"y": 51.53125,
|
||||
"width": 375,
|
||||
"height": 64.5,
|
||||
"top": 51.53125,
|
||||
"right": 375,
|
||||
"bottom": 116.03125,
|
||||
"left": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "cam-guide",
|
||||
"rect": {
|
||||
"x": 0,
|
||||
"y": 542.390625,
|
||||
"width": 375,
|
||||
"height": 21,
|
||||
"top": 542.390625,
|
||||
"right": 375,
|
||||
"bottom": 563.390625,
|
||||
"left": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "cam-controls",
|
||||
"rect": {
|
||||
"x": 0,
|
||||
"y": 626.25,
|
||||
"width": 375,
|
||||
"height": 62,
|
||||
"top": 626.25,
|
||||
"right": 375,
|
||||
"bottom": 688.25,
|
||||
"left": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"06-camera-right": {
|
||||
"screen": "s-cam",
|
||||
"text": "1/3\n请正对屏幕\n\n检测引擎加载中…\n\n识别不到?请正对镜头,并到光线充足处\n\n正脸拍摄完成\n准备拍摄下一张\n正脸居中\n取下眼镜\n光线充足",
|
||||
"overlays": [],
|
||||
"rects": [
|
||||
{
|
||||
"class": "cam-step",
|
||||
"rect": {
|
||||
"x": 0,
|
||||
"y": 51.53125,
|
||||
"width": 375,
|
||||
"height": 64.5,
|
||||
"top": 51.53125,
|
||||
"right": 375,
|
||||
"bottom": 116.03125,
|
||||
"left": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "cam-guide",
|
||||
"rect": {
|
||||
"x": 0,
|
||||
"y": 542.390625,
|
||||
"width": 375,
|
||||
"height": 21,
|
||||
"top": 542.390625,
|
||||
"right": 375,
|
||||
"bottom": 563.390625,
|
||||
"left": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "cam-controls",
|
||||
"rect": {
|
||||
"x": 0,
|
||||
"y": 626.25,
|
||||
"width": 375,
|
||||
"height": 62,
|
||||
"top": 626.25,
|
||||
"right": 375,
|
||||
"bottom": 688.25,
|
||||
"left": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"07-camera-left": {
|
||||
"screen": "s-cam",
|
||||
"text": "2/3\n请右脸出镜\n\n检测引擎加载中…\n\n识别不到?请正对镜头,并到光线充足处\n\n正脸拍摄完成\n下一张:右脸出镜\n请向左转头\n右脸居中\n取下眼镜\n光线充足",
|
||||
"overlays": [],
|
||||
"rects": [
|
||||
{
|
||||
"class": "cam-step",
|
||||
"rect": {
|
||||
"x": 0,
|
||||
"y": 51.53125,
|
||||
"width": 375,
|
||||
"height": 64.5,
|
||||
"top": 51.53125,
|
||||
"right": 375,
|
||||
"bottom": 116.03125,
|
||||
"left": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "cam-guide",
|
||||
"rect": {
|
||||
"x": 0,
|
||||
"y": 542.390625,
|
||||
"width": 375,
|
||||
"height": 21,
|
||||
"top": 542.390625,
|
||||
"right": 375,
|
||||
"bottom": 563.390625,
|
||||
"left": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "cam-controls",
|
||||
"rect": {
|
||||
"x": 0,
|
||||
"y": 626.25,
|
||||
"width": 375,
|
||||
"height": 62,
|
||||
"top": 626.25,
|
||||
"right": 375,
|
||||
"bottom": 688.25,
|
||||
"left": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"08-report-top": {
|
||||
"screen": "s-report",
|
||||
"text": "您的肌肤状态表现\n70分\n综合评分\n肌肤年龄\n27岁\n比实际年龄年轻1岁\n紧塑力\n7.7\n优先关注\n活力感\n8.7\n稳定力\n9.1\n近期优先关注:紧塑力\n紧塑提拉力,轮廓上扬不松垮。\n紧塑力\n活力感\n稳定力\n轮廓\n\n下颌线角度左104.54°/右103.75°(标准区间116–120°,双侧均偏离标准,提示下颌缘支撑力下降);眼袋1级、黑眼圈1级;中面部支撑与轮廓清晰度处于预警水平。建议加强轮廓紧致护理:重点针对下颌线与下颌缘区域使用紧致类精华,配合由下向上的提拉按摩手法,同时加强眼周支撑护理,将紧致防线前置。\n\n皱纹\n\n全脸皱纹159条、细纹150条,最差区域为左嘴角纹(分区分77,最高严重度3级),部分区域已现静态纹趋势。建议针对眼周、法令纹等高发区使用视黄醇/胜肽类淡纹精华,遏制静态纹转化。\n\n*您的肌肤状态会随年龄、环境、作息和生活节奏动态变化。\n*本次检测仅代表当前肌肤状态参考。\n\n护理建议\n\n1、你的日常肤质为干性肌肤:全脸皮脂分泌偏少,角质层保水能力弱,易紧绷起屑。护理以高效保湿与封闭锁水为核心,洁面选用温和低泡产品,避免频繁去角质,面霜可选择含神经酰胺、角鲨烷等封闭性成分的配方。\n\n2、加强纹路分级管理,重点针对眼周、法令纹等动态纹高发区域,使用视黄醇/胜肽类淡纹精华并配合点按手法,遏制静态纹转化。\n\n3、加强细腻度管理,避免过度清洁破坏角质屏障,重点关注毛孔显著区域,配合低浓度酸类(如水杨酸)周期护理与柔润精华。\n\n4、建议搭配使用35+支撑精华:专为轻熟肌打造,提升弹韧表现、维持面部轮廓清晰度,改善疲态并延缓衰老速度。\n\n5、保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳\n\n好状态,是内外同调的长期节奏。\n\n精华推荐\n\n35+支撑精华\n\n推荐依据:皱纹得分偏低\n\n感受高光时刻\n从日常的好状态开始\n以我自在有势挺括\n双仓精华液\n\n参透皮下根源 28天校准皮肤状态\n\n了解更多",
|
||||
"overlays": [],
|
||||
"rects": [
|
||||
{
|
||||
"class": "report-title",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": 0,
|
||||
"width": 339,
|
||||
"height": 86,
|
||||
"top": 0,
|
||||
"right": 357,
|
||||
"bottom": 86,
|
||||
"left": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "card card-summary",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": 86,
|
||||
"width": 339,
|
||||
"height": 140,
|
||||
"top": 86,
|
||||
"right": 357,
|
||||
"bottom": 226,
|
||||
"left": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "card card-dims",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": 238,
|
||||
"width": 339,
|
||||
"height": 201.1875,
|
||||
"top": 238,
|
||||
"right": 357,
|
||||
"bottom": 439.1875,
|
||||
"left": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "card card-detail",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": 451.1875,
|
||||
"width": 339,
|
||||
"height": 334.96875,
|
||||
"top": 451.1875,
|
||||
"right": 357,
|
||||
"bottom": 786.15625,
|
||||
"left": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "card card-care",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": 852.15625,
|
||||
"width": 339,
|
||||
"height": 510,
|
||||
"top": 852.15625,
|
||||
"right": 357,
|
||||
"bottom": 1362.15625,
|
||||
"left": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "card card-essence",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": 1374.15625,
|
||||
"width": 339,
|
||||
"height": 119.5,
|
||||
"top": 1374.15625,
|
||||
"right": 357,
|
||||
"bottom": 1493.65625,
|
||||
"left": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "card card-product",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": 1608.34375,
|
||||
"width": 339,
|
||||
"height": 215.671875,
|
||||
"top": 1608.34375,
|
||||
"right": 357,
|
||||
"bottom": 1824.015625,
|
||||
"left": 18
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"09-report-tab-0": {
|
||||
"screen": "s-report",
|
||||
"text": "您的肌肤状态表现\n70分\n综合评分\n肌肤年龄\n27岁\n比实际年龄年轻1岁\n紧塑力\n7.7\n优先关注\n活力感\n8.7\n稳定力\n9.1\n近期优先关注:紧塑力\n紧塑提拉力,轮廓上扬不松垮。\n紧塑力\n活力感\n稳定力\n轮廓\n\n下颌线角度左104.54°/右103.75°(标准区间116–120°,双侧均偏离标准,提示下颌缘支撑力下降);眼袋1级、黑眼圈1级;中面部支撑与轮廓清晰度处于预警水平。建议加强轮廓紧致护理:重点针对下颌线与下颌缘区域使用紧致类精华,配合由下向上的提拉按摩手法,同时加强眼周支撑护理,将紧致防线前置。\n\n皱纹\n\n全脸皱纹159条、细纹150条,最差区域为左嘴角纹(分区分77,最高严重度3级),部分区域已现静态纹趋势。建议针对眼周、法令纹等高发区使用视黄醇/胜肽类淡纹精华,遏制静态纹转化。\n\n*您的肌肤状态会随年龄、环境、作息和生活节奏动态变化。\n*本次检测仅代表当前肌肤状态参考。\n\n护理建议\n\n1、你的日常肤质为干性肌肤:全脸皮脂分泌偏少,角质层保水能力弱,易紧绷起屑。护理以高效保湿与封闭锁水为核心,洁面选用温和低泡产品,避免频繁去角质,面霜可选择含神经酰胺、角鲨烷等封闭性成分的配方。\n\n2、加强纹路分级管理,重点针对眼周、法令纹等动态纹高发区域,使用视黄醇/胜肽类淡纹精华并配合点按手法,遏制静态纹转化。\n\n3、加强细腻度管理,避免过度清洁破坏角质屏障,重点关注毛孔显著区域,配合低浓度酸类(如水杨酸)周期护理与柔润精华。\n\n4、建议搭配使用35+支撑精华:专为轻熟肌打造,提升弹韧表现、维持面部轮廓清晰度,改善疲态并延缓衰老速度。\n\n5、保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳\n\n好状态,是内外同调的长期节奏。\n\n精华推荐\n\n35+支撑精华\n\n推荐依据:皱纹得分偏低\n\n感受高光时刻\n从日常的好状态开始\n以我自在有势挺括\n双仓精华液\n\n参透皮下根源 28天校准皮肤状态\n\n了解更多",
|
||||
"overlays": [],
|
||||
"rects": [
|
||||
{
|
||||
"class": "report-title",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": -60,
|
||||
"width": 339,
|
||||
"height": 86,
|
||||
"top": -60,
|
||||
"right": 357,
|
||||
"bottom": 26,
|
||||
"left": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "card card-summary",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": 26,
|
||||
"width": 339,
|
||||
"height": 140,
|
||||
"top": 26,
|
||||
"right": 357,
|
||||
"bottom": 166,
|
||||
"left": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "card card-dims",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": 178,
|
||||
"width": 339,
|
||||
"height": 201.1875,
|
||||
"top": 178,
|
||||
"right": 357,
|
||||
"bottom": 379.1875,
|
||||
"left": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "card card-detail",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": 391.1875,
|
||||
"width": 339,
|
||||
"height": 334.96875,
|
||||
"top": 391.1875,
|
||||
"right": 357,
|
||||
"bottom": 726.15625,
|
||||
"left": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "card card-care",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": 792.15625,
|
||||
"width": 339,
|
||||
"height": 510,
|
||||
"top": 792.15625,
|
||||
"right": 357,
|
||||
"bottom": 1302.15625,
|
||||
"left": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "card card-essence",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": 1314.15625,
|
||||
"width": 339,
|
||||
"height": 119.5,
|
||||
"top": 1314.15625,
|
||||
"right": 357,
|
||||
"bottom": 1433.65625,
|
||||
"left": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "card card-product",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": 1548.34375,
|
||||
"width": 339,
|
||||
"height": 215.671875,
|
||||
"top": 1548.34375,
|
||||
"right": 357,
|
||||
"bottom": 1764.015625,
|
||||
"left": 18
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"09-report-tab-1": {
|
||||
"screen": "s-report",
|
||||
"text": "您的肌肤状态表现\n70分\n综合评分\n肌肤年龄\n27岁\n比实际年龄年轻1岁\n紧塑力\n7.7\n优先关注\n活力感\n8.7\n稳定力\n9.1\n近期优先关注:紧塑力\n紧塑提拉力,轮廓上扬不松垮。\n紧塑力\n活力感\n稳定力\n光滑\n\n皮肤表面平整度较低,最差毛孔区位于额头(分区毛孔分32),粗大毛孔1041颗、黑头113颗,T区出油面积57%、左右脸颊20%/2%,纹理分仅82,整体细腻度不足。建议避免过度清洁,重点关注额头,配合低浓度酸类周期护理与柔润精华。\n\n肤色水润\n\n肤色ITA 58.6(极白,HA 56.2),色素分92,整体稳定但存在局部暗沉倾向(未见明显色斑标记,检出褐色斑37处);水分分78,缺水面积19.8%、严重度22。建议加强抗氧化与光泽管理,做好基础防晒与补水。\n\n*您的肌肤状态会随年龄、环境、作息和生活节奏动态变化。\n*本次检测仅代表当前肌肤状态参考。\n\n护理建议\n\n1、你的日常肤质为干性肌肤:全脸皮脂分泌偏少,角质层保水能力弱,易紧绷起屑。护理以高效保湿与封闭锁水为核心,洁面选用温和低泡产品,避免频繁去角质,面霜可选择含神经酰胺、角鲨烷等封闭性成分的配方。\n\n2、加强纹路分级管理,重点针对眼周、法令纹等动态纹高发区域,使用视黄醇/胜肽类淡纹精华并配合点按手法,遏制静态纹转化。\n\n3、加强细腻度管理,避免过度清洁破坏角质屏障,重点关注毛孔显著区域,配合低浓度酸类(如水杨酸)周期护理与柔润精华。\n\n4、建议搭配使用35+支撑精华:专为轻熟肌打造,提升弹韧表现、维持面部轮廓清晰度,改善疲态并延缓衰老速度。\n\n5、保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳\n\n好状态,是内外同调的长期节奏。\n\n精华推荐\n\n35+支撑精华\n\n推荐依据:皱纹得分偏低\n\n感受高光时刻\n从日常的好状态开始\n以我自在有势挺括\n双仓精华液\n\n参透皮下根源 28天校准皮肤状态\n\n了解更多",
|
||||
"overlays": [],
|
||||
"rects": [
|
||||
{
|
||||
"class": "report-title",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": -60,
|
||||
"width": 339,
|
||||
"height": 86,
|
||||
"top": -60,
|
||||
"right": 357,
|
||||
"bottom": 26,
|
||||
"left": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "card card-summary",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": 26,
|
||||
"width": 339,
|
||||
"height": 140,
|
||||
"top": 26,
|
||||
"right": 357,
|
||||
"bottom": 166,
|
||||
"left": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "card card-dims",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": 178,
|
||||
"width": 339,
|
||||
"height": 201.1875,
|
||||
"top": 178,
|
||||
"right": 357,
|
||||
"bottom": 379.1875,
|
||||
"left": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "card card-detail",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": 391.1875,
|
||||
"width": 339,
|
||||
"height": 306.53125,
|
||||
"top": 391.1875,
|
||||
"right": 357,
|
||||
"bottom": 697.71875,
|
||||
"left": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "card card-care",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": 763.71875,
|
||||
"width": 339,
|
||||
"height": 510,
|
||||
"top": 763.71875,
|
||||
"right": 357,
|
||||
"bottom": 1273.71875,
|
||||
"left": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "card card-essence",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": 1285.71875,
|
||||
"width": 339,
|
||||
"height": 119.5,
|
||||
"top": 1285.71875,
|
||||
"right": 357,
|
||||
"bottom": 1405.21875,
|
||||
"left": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "card card-product",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": 1519.90625,
|
||||
"width": 339,
|
||||
"height": 215.671875,
|
||||
"top": 1519.90625,
|
||||
"right": 357,
|
||||
"bottom": 1735.578125,
|
||||
"left": 18
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"09-report-tab-2": {
|
||||
"screen": "s-report",
|
||||
"text": "您的肌肤状态表现\n70分\n综合评分\n肌肤年龄\n27岁\n比实际年龄年轻1岁\n紧塑力\n7.7\n优先关注\n活力感\n8.7\n稳定力\n9.1\n近期优先关注:紧塑力\n紧塑提拉力,轮廓上扬不松垮。\n紧塑力\n活力感\n稳定力\n敏感\n\n泛红面积0.6%,红区分99,屏障状态稳定,未见明显敏感信号。建议维持现有温和护理,避免过度去角质。\n\n痘痘\n\n检出9处皮损(闭口粉刺6处、丘疹3处)。建议尽快进行针对性抗痘护理,避免挤压与刺激,必要时寻求皮肤科专业处理。\n\n*您的肌肤状态会随年龄、环境、作息和生活节奏动态变化。\n*本次检测仅代表当前肌肤状态参考。\n\n护理建议\n\n1、你的日常肤质为干性肌肤:全脸皮脂分泌偏少,角质层保水能力弱,易紧绷起屑。护理以高效保湿与封闭锁水为核心,洁面选用温和低泡产品,避免频繁去角质,面霜可选择含神经酰胺、角鲨烷等封闭性成分的配方。\n\n2、加强纹路分级管理,重点针对眼周、法令纹等动态纹高发区域,使用视黄醇/胜肽类淡纹精华并配合点按手法,遏制静态纹转化。\n\n3、加强细腻度管理,避免过度清洁破坏角质屏障,重点关注毛孔显著区域,配合低浓度酸类(如水杨酸)周期护理与柔润精华。\n\n4、建议搭配使用35+支撑精华:专为轻熟肌打造,提升弹韧表现、维持面部轮廓清晰度,改善疲态并延缓衰老速度。\n\n5、保持侧卧改仰卧的睡姿习惯,减少枕巾摩擦;控制屏幕时间,缓解眼周疲劳\n\n好状态,是内外同调的长期节奏。\n\n精华推荐\n\n35+支撑精华\n\n推荐依据:皱纹得分偏低\n\n感受高光时刻\n从日常的好状态开始\n以我自在有势挺括\n双仓精华液\n\n参透皮下根源 28天校准皮肤状态\n\n了解更多",
|
||||
"overlays": [],
|
||||
"rects": [
|
||||
{
|
||||
"class": "report-title",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": -60,
|
||||
"width": 339,
|
||||
"height": 86,
|
||||
"top": -60,
|
||||
"right": 357,
|
||||
"bottom": 26,
|
||||
"left": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "card card-summary",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": 26,
|
||||
"width": 339,
|
||||
"height": 140,
|
||||
"top": 26,
|
||||
"right": 357,
|
||||
"bottom": 166,
|
||||
"left": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "card card-dims",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": 178,
|
||||
"width": 339,
|
||||
"height": 201.1875,
|
||||
"top": 178,
|
||||
"right": 357,
|
||||
"bottom": 379.1875,
|
||||
"left": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "card card-detail",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": 391.1875,
|
||||
"width": 339,
|
||||
"height": 303,
|
||||
"top": 391.1875,
|
||||
"right": 357,
|
||||
"bottom": 694.1875,
|
||||
"left": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "card card-care",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": 760.1875,
|
||||
"width": 339,
|
||||
"height": 510,
|
||||
"top": 760.1875,
|
||||
"right": 357,
|
||||
"bottom": 1270.1875,
|
||||
"left": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "card card-essence",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": 1282.1875,
|
||||
"width": 339,
|
||||
"height": 119.5,
|
||||
"top": 1282.1875,
|
||||
"right": 357,
|
||||
"bottom": 1401.6875,
|
||||
"left": 18
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "card card-product",
|
||||
"rect": {
|
||||
"x": 18,
|
||||
"y": 1516.375,
|
||||
"width": 339,
|
||||
"height": 215.671875,
|
||||
"top": 1516.375,
|
||||
"right": 357,
|
||||
"bottom": 1732.046875,
|
||||
"left": 18
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"splash-375x844": {
|
||||
"screen": "s-splash",
|
||||
"text": "Life in Full\n为更好的生命表现\n\n肌肤状态检测\n开始检测",
|
||||
"overlays": [],
|
||||
"rects": [
|
||||
{
|
||||
"class": "splash-slogan",
|
||||
"rect": {
|
||||
"x": 0,
|
||||
"y": 90.296875,
|
||||
"width": 375,
|
||||
"height": 63.46875,
|
||||
"top": 90.296875,
|
||||
"right": 375,
|
||||
"bottom": 153.765625,
|
||||
"left": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "splash-bottom",
|
||||
"rect": {
|
||||
"x": 0,
|
||||
"y": 655.359375,
|
||||
"width": 375,
|
||||
"height": 84,
|
||||
"top": 655.359375,
|
||||
"right": 375,
|
||||
"bottom": 739.359375,
|
||||
"left": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"splash-390x844": {
|
||||
"screen": "s-splash",
|
||||
"text": "Life in Full\n为更好的生命表现\n\n肌肤状态检测\n开始检测",
|
||||
"overlays": [],
|
||||
"rects": [
|
||||
{
|
||||
"class": "splash-slogan",
|
||||
"rect": {
|
||||
"x": 0,
|
||||
"y": 90.296875,
|
||||
"width": 390,
|
||||
"height": 66,
|
||||
"top": 90.296875,
|
||||
"right": 390,
|
||||
"bottom": 156.296875,
|
||||
"left": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "splash-bottom",
|
||||
"rect": {
|
||||
"x": 0,
|
||||
"y": 652.265625,
|
||||
"width": 390,
|
||||
"height": 87.09375,
|
||||
"top": 652.265625,
|
||||
"right": 390,
|
||||
"bottom": 739.359375,
|
||||
"left": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"splash-414x844": {
|
||||
"screen": "s-splash",
|
||||
"text": "Life in Full\n为更好的生命表现\n\n肌肤状态检测\n开始检测",
|
||||
"overlays": [],
|
||||
"rects": [
|
||||
{
|
||||
"class": "splash-slogan",
|
||||
"rect": {
|
||||
"x": 0,
|
||||
"y": 90.296875,
|
||||
"width": 414,
|
||||
"height": 70.0625,
|
||||
"top": 90.296875,
|
||||
"right": 414,
|
||||
"bottom": 160.359375,
|
||||
"left": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"class": "splash-bottom",
|
||||
"rect": {
|
||||
"x": 0,
|
||||
"y": 645.859375,
|
||||
"width": 414,
|
||||
"height": 93.5,
|
||||
"top": 645.859375,
|
||||
"right": 414,
|
||||
"bottom": 739.359375,
|
||||
"left": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"errors": []
|
||||
}
|
||||
|
After Width: | Height: | Size: 446 KiB |
|
After Width: | Height: | Size: 466 KiB |
|
After Width: | Height: | Size: 490 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 3.6 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 315 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 428 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"description": "Smaller product-card delivery images. Original PSD crops preserved.",
|
||||
"retained": "Background/scan WebPs are smaller than JPEG; transparent icons/masks remain PNG.",
|
||||
"assets": [
|
||||
{
|
||||
"source": "h5/assets/img/ui-20261012/product-25.webp",
|
||||
"source_sha256": "c18827342d23bbff2de04c3a2ecbeeae9b16eb96f3a9f7e30f2a66cdc21beddc",
|
||||
"source_bytes": 276778,
|
||||
"file": "h5/assets/img/ui-20261012/product-25.jpg",
|
||||
"sha256": "2c399cd6bd7bd783f698a91b9203a7ba42b92295d58bbeb0b1c2a884e862f00b",
|
||||
"bytes": 63959,
|
||||
"size": [
|
||||
504,
|
||||
503
|
||||
],
|
||||
"quality": 90,
|
||||
"subsampling": 0,
|
||||
"progressive": true,
|
||||
"matte": "#FFFFFF",
|
||||
"psnr_db": 40.98
|
||||
},
|
||||
{
|
||||
"source": "h5/assets/img/ui-20261012/product-35.webp",
|
||||
"source_sha256": "c1dc455c8072da17ac06cee386248b358947da65bd190b714f2ccb25360afea4",
|
||||
"source_bytes": 267094,
|
||||
"file": "h5/assets/img/ui-20261012/product-35.jpg",
|
||||
"sha256": "ab4c5623235020b1129f564b733fe843c539088e7b62b2d38ed42a204e4af113",
|
||||
"bytes": 66077,
|
||||
"size": [
|
||||
504,
|
||||
503
|
||||
],
|
||||
"quality": 90,
|
||||
"subsampling": 0,
|
||||
"progressive": true,
|
||||
"matte": "#FFFFFF",
|
||||
"psnr_db": 41.47
|
||||
},
|
||||
{
|
||||
"source": "h5/assets/img/ui-20261012/product-50.webp",
|
||||
"source_sha256": "86647c8882caf62c43eafb1efe495bad3289126cab73a88c8963b7ad64ffa084",
|
||||
"source_bytes": 331924,
|
||||
"file": "h5/assets/img/ui-20261012/product-50.jpg",
|
||||
"sha256": "78687985751afbf60c374f2605dee372ac64ffc5271aa54ddca7897443de3047",
|
||||
"bytes": 153787,
|
||||
"size": [
|
||||
504,
|
||||
503
|
||||
],
|
||||
"quality": 96,
|
||||
"subsampling": 0,
|
||||
"progressive": true,
|
||||
"matte": "#FFFFFF",
|
||||
"psnr_db": 41.94
|
||||
}
|
||||
],
|
||||
"source_bytes": 875796,
|
||||
"delivery_bytes": 283823
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
{
|
||||
"source": "UI外发版10.09.psd",
|
||||
"source_sha256": "c7698f5900adc7476286818450d928e8fa2ce2e58cda09375ff2b861629fd58f",
|
||||
"document_size": [
|
||||
7330,
|
||||
16411
|
||||
],
|
||||
"native_header_px": 248,
|
||||
"design_scale": 2.88,
|
||||
"assets": [
|
||||
{
|
||||
"file": "h5/assets/img/ui-20261012/splash-slogan.png",
|
||||
"source_layers": [
|
||||
"0/5",
|
||||
"0/4"
|
||||
],
|
||||
"bbox": [
|
||||
1415,
|
||||
391,
|
||||
2133,
|
||||
608
|
||||
],
|
||||
"size": [
|
||||
718,
|
||||
217
|
||||
],
|
||||
"bytes": 12781,
|
||||
"sha256": "c9dee693eb3c1d566bb08d3770e7dd2f0c22e7d4e0b81bbe5e10ad8219b26044"
|
||||
},
|
||||
{
|
||||
"file": "h5/assets/img/ui-20261012/brand-lockup.png",
|
||||
"source_layers": [
|
||||
"0/1"
|
||||
],
|
||||
"bbox": [
|
||||
1515,
|
||||
1861,
|
||||
2032,
|
||||
1920
|
||||
],
|
||||
"size": [
|
||||
517,
|
||||
59
|
||||
],
|
||||
"bytes": 7013,
|
||||
"sha256": "3cb697d6c5542562a9cea20554b0772f2c0137b9c3977ff258908024d5a8e9ed"
|
||||
},
|
||||
{
|
||||
"file": "h5/assets/img/ui-20261012/button-start-label.png",
|
||||
"source_layers": [
|
||||
"0/3/1"
|
||||
],
|
||||
"bbox": [
|
||||
1696,
|
||||
2005,
|
||||
1857,
|
||||
2045
|
||||
],
|
||||
"size": [
|
||||
161,
|
||||
40
|
||||
],
|
||||
"bytes": 2163,
|
||||
"sha256": "fd88b174f120845d8045a78a36bc7665516d91011f7ea0ebc7fe200271e17738"
|
||||
},
|
||||
{
|
||||
"file": "h5/assets/img/ui-20261012/button-history-label.png",
|
||||
"source_layers": [
|
||||
"0/2/2/2"
|
||||
],
|
||||
"bbox": [
|
||||
1696,
|
||||
2252,
|
||||
1859,
|
||||
2291
|
||||
],
|
||||
"size": [
|
||||
163,
|
||||
39
|
||||
],
|
||||
"bytes": 2024,
|
||||
"sha256": "0253d761f63bda89301c1eec9df84352a2f5dd1aa97884e27ebf91092c0816d8"
|
||||
},
|
||||
{
|
||||
"file": "h5/assets/img/ui-20261012/product-25.webp",
|
||||
"source_layers": [
|
||||
"8/1/0"
|
||||
],
|
||||
"bbox": [
|
||||
1765,
|
||||
14677,
|
||||
2269,
|
||||
15180
|
||||
],
|
||||
"size": [
|
||||
504,
|
||||
503
|
||||
],
|
||||
"bytes": 276778,
|
||||
"sha256": "c18827342d23bbff2de04c3a2ecbeeae9b16eb96f3a9f7e30f2a66cdc21beddc"
|
||||
},
|
||||
{
|
||||
"file": "h5/assets/img/ui-20261012/product-35.webp",
|
||||
"source_layers": [
|
||||
"8/1/1"
|
||||
],
|
||||
"bbox": [
|
||||
1765,
|
||||
15228,
|
||||
2269,
|
||||
15731
|
||||
],
|
||||
"size": [
|
||||
504,
|
||||
503
|
||||
],
|
||||
"bytes": 267094,
|
||||
"sha256": "c1dc455c8072da17ac06cee386248b358947da65bd190b714f2ccb25360afea4"
|
||||
},
|
||||
{
|
||||
"file": "h5/assets/img/ui-20261012/product-50.webp",
|
||||
"source_layers": [
|
||||
"8/1/2"
|
||||
],
|
||||
"bbox": [
|
||||
1765,
|
||||
15778,
|
||||
2269,
|
||||
16281
|
||||
],
|
||||
"size": [
|
||||
504,
|
||||
503
|
||||
],
|
||||
"bytes": 331924,
|
||||
"sha256": "86647c8882caf62c43eafb1efe495bad3289126cab73a88c8963b7ad64ffa084"
|
||||
},
|
||||
{
|
||||
"file": "h5/assets/img/ui-20261012/scan-waiting.webp",
|
||||
"source_layers": [
|
||||
"9"
|
||||
],
|
||||
"bbox": [
|
||||
6250,
|
||||
5500,
|
||||
7330,
|
||||
7591
|
||||
],
|
||||
"size": [
|
||||
1080,
|
||||
2091
|
||||
],
|
||||
"bytes": 11690,
|
||||
"sha256": "6b661f3d454e9b696b70ca8846fed11243d3f53b95506c2d78b11b6b1a6d9f77"
|
||||
}
|
||||
],
|
||||
"text_styles": [
|
||||
{
|
||||
"path": "0/4",
|
||||
"text": "以我为始 自有表现",
|
||||
"bbox": [
|
||||
1502,
|
||||
545,
|
||||
2045,
|
||||
608
|
||||
],
|
||||
"transform": [
|
||||
0.7386429422796229,
|
||||
0.0,
|
||||
-1.0090361144648896e-16,
|
||||
0.7386429422796231,
|
||||
1772.6682649161658,
|
||||
602.0113961671652
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"font": "MiSans-Demibold",
|
||||
"font_size": 90.25561,
|
||||
"leading": 104.16666
|
||||
},
|
||||
{
|
||||
"font": "MiSans-Demibold",
|
||||
"font_size": 90.25561,
|
||||
"leading": 104.16666
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "0/5",
|
||||
"text": " ONE ME,\rFROM THE INSIDE OUT",
|
||||
"bbox": [
|
||||
1415,
|
||||
391,
|
||||
2133,
|
||||
520
|
||||
],
|
||||
"transform": [
|
||||
0.7386429422796229,
|
||||
0.0,
|
||||
-1.0090361144648896e-16,
|
||||
0.7386429422796231,
|
||||
1771.9296219738862,
|
||||
442.37366417300353
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"font": "MiSans-Regular",
|
||||
"font_size": 90.25561,
|
||||
"leading": 104.16666
|
||||
},
|
||||
{
|
||||
"font": "MiSans-Regular",
|
||||
"font_size": 90.25561,
|
||||
"leading": 104.16666
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "0/1/1",
|
||||
"text": "肌肤状态检测",
|
||||
"bbox": [
|
||||
1664,
|
||||
1862,
|
||||
2032,
|
||||
1920
|
||||
],
|
||||
"transform": [
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.9999999999999999,
|
||||
1662.281376176642,
|
||||
1868.3475610977566
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"font": "MiSans-Demibold",
|
||||
"font_size": 62.5,
|
||||
"leading": null
|
||||
},
|
||||
{
|
||||
"font": "MiSans-Demibold",
|
||||
"font_size": 62.5,
|
||||
"leading": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "1/3/0",
|
||||
"text": "欢迎体验1'me\r肌肤状态评估服务!",
|
||||
"bbox": [
|
||||
2664,
|
||||
832,
|
||||
3218,
|
||||
979
|
||||
],
|
||||
"transform": [
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.9999999999999999,
|
||||
2662.281376176642,
|
||||
840.3475610977557
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"font": "MiSans-Demibold",
|
||||
"font_size": 66.66666,
|
||||
"leading": 83.33334
|
||||
},
|
||||
{
|
||||
"font": "MiSans-Demibold",
|
||||
"font_size": 66.66666,
|
||||
"leading": 83.33334
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "1/3/1",
|
||||
"text": "我们将采集您的面部影像,为您生成专属肌肤分析与状态管理方案。所有数据严格保密,仅用于本次检测分析。",
|
||||
"bbox": [
|
||||
2663,
|
||||
1045,
|
||||
3361,
|
||||
1177
|
||||
],
|
||||
"transform": [
|
||||
0.9999999999999999,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
2662.281376176642,
|
||||
1049.3475610977548
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"font": "MiSans-Regular",
|
||||
"font_size": 33.33333,
|
||||
"leading": 50.0
|
||||
},
|
||||
{
|
||||
"font": "MiSans-Regular",
|
||||
"font_size": 33.33333,
|
||||
"leading": 50.0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "4/7",
|
||||
"text": "请拍摄或上传\r正面及左右侧面照片",
|
||||
"bbox": [
|
||||
1442,
|
||||
6160,
|
||||
2036,
|
||||
6307
|
||||
],
|
||||
"transform": [
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.9999999999999999,
|
||||
1439.281376176642,
|
||||
6167.347561097756
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"font": "MiSans-Demibold",
|
||||
"font_size": 66.66666,
|
||||
"leading": 83.33334
|
||||
},
|
||||
{
|
||||
"font": "MiSans-Demibold",
|
||||
"font_size": 66.66666,
|
||||
"leading": 83.33334
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "4/8",
|
||||
"text": "为使测试结果更准确,请关闭美颜/滤镜、并确保光线充足均匀。",
|
||||
"bbox": [
|
||||
1443,
|
||||
6372,
|
||||
2119,
|
||||
6454
|
||||
],
|
||||
"transform": [
|
||||
0.9999999999999999,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
1442.281376176642,
|
||||
6376.347561097755
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"font": "MiSans-Regular",
|
||||
"font_size": 33.33333,
|
||||
"leading": 50.0
|
||||
},
|
||||
{
|
||||
"font": "MiSans-Regular",
|
||||
"font_size": 33.33333,
|
||||
"leading": 50.0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "8/1/0/5",
|
||||
"text": "以我本然有光蓄活\r双仓精华液",
|
||||
"bbox": [
|
||||
1362,
|
||||
14850,
|
||||
1627,
|
||||
14932
|
||||
],
|
||||
"transform": [
|
||||
1.7691030436028,
|
||||
0.0,
|
||||
0.0,
|
||||
1.769103043602818,
|
||||
1361.2813761766417,
|
||||
14854.052217587123
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"font": "MiSans-Semibold",
|
||||
"font_size": 18.84194,
|
||||
"leading": 28.26291
|
||||
},
|
||||
{
|
||||
"font": "MiSans-Semibold",
|
||||
"font_size": 18.84194,
|
||||
"leading": 28.26291
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "8/1/0/6",
|
||||
"text": "1'me 主场精华 I",
|
||||
"bbox": [
|
||||
1363,
|
||||
14779,
|
||||
1653,
|
||||
14820
|
||||
],
|
||||
"transform": [
|
||||
1.5358216667040816,
|
||||
0.0,
|
||||
0.0,
|
||||
1.5358216667040878,
|
||||
1361.2813761766417,
|
||||
14784.058678621632
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"font": "MiSans-Semibold",
|
||||
"font_size": 27.12989,
|
||||
"leading": 28.26291
|
||||
},
|
||||
{
|
||||
"font": "MiSans-Semibold",
|
||||
"font_size": 27.12989,
|
||||
"leading": 28.26291
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"shape_metrics": [
|
||||
{
|
||||
"path": "0/3/0",
|
||||
"bbox": [
|
||||
1516,
|
||||
1971,
|
||||
2038,
|
||||
2083
|
||||
],
|
||||
"corner_radius_psd_px": 20.000065
|
||||
},
|
||||
{
|
||||
"path": "0/2/0",
|
||||
"bbox": [
|
||||
1516,
|
||||
2104,
|
||||
2038,
|
||||
2216
|
||||
],
|
||||
"corner_radius_psd_px": 20.000065
|
||||
},
|
||||
{
|
||||
"path": "1/2/0",
|
||||
"bbox": [
|
||||
2753,
|
||||
1410,
|
||||
3275,
|
||||
1522
|
||||
],
|
||||
"corner_radius_psd_px": 20.000065
|
||||
},
|
||||
{
|
||||
"path": "4/2/0",
|
||||
"bbox": [
|
||||
1517,
|
||||
6604,
|
||||
2039,
|
||||
6716
|
||||
],
|
||||
"corner_radius_psd_px": 20.000065
|
||||
},
|
||||
{
|
||||
"path": "8/1/0/0",
|
||||
"bbox": [
|
||||
1285,
|
||||
14677,
|
||||
2269,
|
||||
15180
|
||||
],
|
||||
"corner_radius_psd_px": 15.000158
|
||||
},
|
||||
{
|
||||
"path": "1/1",
|
||||
"bbox": [
|
||||
2571,
|
||||
719,
|
||||
3458,
|
||||
1621
|
||||
],
|
||||
"corner_radius_psd_px": 24.999972
|
||||
},
|
||||
{
|
||||
"path": "4/1",
|
||||
"bbox": [
|
||||
1334,
|
||||
5971,
|
||||
2221,
|
||||
6873
|
||||
],
|
||||
"corner_radius_psd_px": 24.999972
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 60 KiB |