feat(sae): add Java MVP backend and Taobao connectivity page
Spring Boot 3.5.6 / Java 17 JAR with cloud gateway envelope and ping/whoami/echo; Taobao gateway signature verification matching the Node implementation; /pages/mvp page, splash dev entry and local transport contract test; SAE + 聚石塔 runbook and portable JDK/Maven setup; Windows 11 wmic shim for Taobao DevTools.
This commit is contained in:
@@ -14,6 +14,14 @@ aipkey.bash
|
||||
h5/data/
|
||||
temp/
|
||||
|
||||
# Java MVP 构建产物
|
||||
/backend-java/target/
|
||||
|
||||
# 便携工具链(体积大,本机安装,不入库)
|
||||
/tools/jdk17/
|
||||
/tools/maven/
|
||||
/tools/downloads/
|
||||
|
||||
# 大型设计源文件,不入库
|
||||
new_feedback/第三轮反馈20261012/UI外发版10.09.psd
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
- `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)。
|
||||
- `rednote/`:复用 H5 的持久浏览器身份入口,独立运行和存储。说明见 [rednote/README.md](rednote/README.md)。
|
||||
- `h5/js/skin-score.js`、`report-ui.js`、`report-copy.js`:业务规则的源文件。小程序内的副本由同步脚本生成并检查哈希。
|
||||
- `h5/lib/taobao-*.js`:淘宝网关验签、短期上传/异步任务和独立 API 路由。
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# taobao-mvp-backend
|
||||
|
||||
淘宝小程序 + 淘宝云网关 + SAE 的最小 Java 后端验证工程。
|
||||
|
||||
## 快速启动
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy -Scope Process Bypass
|
||||
. D:\juzi\tools\env-java.ps1
|
||||
cd D:\juzi\backend-java
|
||||
mvn -DskipTests package
|
||||
java -jar target\taobao-mvp-backend-0.0.1-SNAPSHOT.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,48 @@
|
||||
<?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.1-SNAPSHOT</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>
|
||||
<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,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,123 @@
|
||||
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 {
|
||||
String method = params.getOrDefault("sign_method", "");
|
||||
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 = ("md5".equals(method) ? 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,29 @@
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: taobao-mvp-backend
|
||||
jackson:
|
||||
time-zone: Asia/Shanghai
|
||||
default-property-inclusion: non_null
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
|
||||
taobao:
|
||||
# 云端部署时通过 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,26 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
# 淘宝小程序 + Java + SAE 最小 MVP 操作步骤
|
||||
|
||||
> 目标:先用一个最小页面,把 **淘宝小程序 → 淘宝云网关(或本地直连)→ Java 后端 → 统一 envelope 返回** 这条链路跑通。
|
||||
>
|
||||
> 本文不包含:真实测肤算法、Face++、账号体系、图片分片上传。这些等链路验证通过后,再按现有 Node 实现的接口契约迁到 Java。
|
||||
>
|
||||
> 当前时间:2026-10。淘宝开放平台在 2026-05 把聚石塔的部署运维迁到了 ACK/SAE,网上 2023 年以前的“容器部署平台”教程不要照抄。
|
||||
|
||||
---
|
||||
|
||||
## 0. 本次交付物
|
||||
|
||||
| 路径 | 作用 |
|
||||
|---|---|
|
||||
| `backend-java/` | Spring Boot 3.5.6 + Java 17 的最小后端,含 `ping/whoami/echo` |
|
||||
| `backend-java/src/main/java/com/juzi/mvp/gateway/TaobaoSignatureVerifier.java` | 云网关签名校验,算法与现有 Node 实现一致 |
|
||||
| `taobao/pages/mvp/` | 淘宝小程序连通性页面,直接复用现有 `utils/transport.js` |
|
||||
| `taobao/config.js` | 新增 `mvpLocal` / `mvpBaseUrl`,不影响主应用原来的 18180 配置 |
|
||||
| `taobao/app.json` | 注册 `pages/mvp/mvp` |
|
||||
| `tools/env-java.ps1` | 一键加载 JDK 17 + Maven 3.9.9 |
|
||||
| `backend-java/README.md` | 后端速查 |
|
||||
| `docs/SAE-Java-MVP-操作步骤.md` | 本文 |
|
||||
|
||||
现有 H5、Node 后端、淘宝主流程页面均未改动;只新增 MVP 页面和两个配置字段。
|
||||
|
||||
---
|
||||
|
||||
## 1. 环境安装
|
||||
|
||||
本机已有:
|
||||
|
||||
- Node.js `v22.23.2`:`C:\Program Files\nodejs`
|
||||
- JDK 17:`D:\juzi\tools\jdk17`
|
||||
- Maven 3.9.9:`D:\juzi\tools\maven\apache-maven-3.9.9`
|
||||
|
||||
PowerShell 默认执行策略是 `Restricted`,直接运行 `.ps1` 或 `npm.ps1` 会被拦。每次新开窗口先执行:
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy -Scope Process Bypass
|
||||
. D:\juzi\tools\env-java.ps1
|
||||
```
|
||||
|
||||
然后确认:
|
||||
|
||||
```powershell
|
||||
java -version
|
||||
mvn -version
|
||||
node -v
|
||||
npm.cmd -v
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
- `java version "17.x"`
|
||||
- `Apache Maven 3.9.9`
|
||||
- `v22.23.2`
|
||||
- `10.9.x`
|
||||
|
||||
> 如果 `env-java.ps1` 报 “JDK 17 not found”,检查 `D:\juzi\tools\jdk17\<jdk目录>\bin\java.exe` 是否存在;脚本会自动向下找一层目录。
|
||||
>
|
||||
> 不要用系统里的 JDK 25 来跑这个 MVP;SAE 运行时选 17,编译也用 17,避免 `UnsupportedClassVersionError`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 后端本地启动
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy -Scope Process Bypass
|
||||
. D:\juzi\tools\env-java.ps1
|
||||
cd D:\juzi\backend-java
|
||||
mvn -DskipTests package
|
||||
java -jar target\taobao-mvp-backend-0.0.1-SNAPSHOT.jar
|
||||
```
|
||||
|
||||
第一次 `mvn package` 会下载 Spring Boot 依赖,耐心等几分钟。看到类似日志即成功:
|
||||
|
||||
```text
|
||||
Tomcat started on port 8080 (http)
|
||||
Started MvpApplication in ...
|
||||
```
|
||||
|
||||
想跑自动化测试(签名算法 + envelope + health):`mvn test`。
|
||||
|
||||
### 2.1 另开一个窗口验证
|
||||
|
||||
```powershell
|
||||
curl.exe http://127.0.0.1:8080/actuator/health
|
||||
curl.exe -X POST -H "Content-Type: application/json" -d "{}" http://127.0.0.1:8080/api/taobao/ping
|
||||
Invoke-RestMethod -Method Post -Uri http://127.0.0.1:8080/api/taobao/echo -ContentType 'application/json' -Body '{"hello":"world"}' | ConvertTo-Json -Depth 5
|
||||
```
|
||||
|
||||
> PowerShell 调原生 `curl.exe` 时,`-d` 里的双引号可能被吞掉(会看到 `{hello:world}` 解析失败);发 JSON 建议用 `Invoke-RestMethod`,或者把 body 写进文件后用 `--data-binary "@body.json"`。
|
||||
|
||||
预期 `ping`:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"ok": true,
|
||||
"pong": true,
|
||||
"server": "java",
|
||||
"time": "2026-10-30T...+08:00",
|
||||
"transport": "direct",
|
||||
"appKey": "",
|
||||
"openId": "",
|
||||
"env": "",
|
||||
"signVerified": false,
|
||||
"signMessage": "verify-sign=false, skipped (local/direct mode)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`echo` 预期能看到 `body.hello = "world"`。
|
||||
|
||||
> 所有 `/api/taobao/**` 返回的都是云网关要求的 envelope;`/actuator/health` 是给 SAE 健康检查用的普通 JSON,不是 envelope。
|
||||
|
||||
### 2.2 不装 IDE 也能跑的前端契约测试
|
||||
|
||||
```powershell
|
||||
cd D:\juzi
|
||||
node taobao\scripts\mvp-local-check.cjs
|
||||
```
|
||||
|
||||
这个脚本用 Node 模拟 `my.request`,加载真实的 `taobao/utils/transport.js`,直连 8080,验证 `ping/echo/whoami` 和 `unwrap` 解包。预期输出 `PASS`,其中 `ping.server = "java"`。如果这里都不过,先别开 IDE。
|
||||
|
||||
---
|
||||
|
||||
## 3. 前端本地联调(淘宝 IDE)
|
||||
|
||||
### 3.1 安装依赖
|
||||
|
||||
```powershell
|
||||
cd D:\juzi\taobao
|
||||
npm.cmd ci
|
||||
```
|
||||
|
||||
如果 `npm.cmd` 不在 PATH,用全路径:
|
||||
|
||||
```powershell
|
||||
& "C:\Program Files\nodejs\npm.cmd" ci
|
||||
```
|
||||
|
||||
`@tbmp/mp-cloud-sdk` 已经是 `1.5.10`,与当前 npm 最新版一致。
|
||||
|
||||
### 3.2 IDE 导入与启动页
|
||||
|
||||
1. 打开淘宝小程序开发者工具,导入 `D:\juzi\taobao`。
|
||||
2. 填入真实小程序 AppID(`mini.project.json` 里的 `appid` 也可以留空,以 IDE 设置为准)。
|
||||
3. 工具 → 构建 npm(把 `node_modules` 构建成小程序可用的包)。
|
||||
4. 本地 `mvpLocal: true` 时,启动页右上角会出现「SAE MVP 连通性」入口,点它即可进入;也可以把启动页临时设为 `pages/mvp/mvp`。
|
||||
5. 用**桌面模拟器**跑;手机上的 `127.0.0.1` 指向手机自己,连不到你电脑。
|
||||
|
||||
### 3.3 当前配置
|
||||
|
||||
`taobao/config.js` 新增了:
|
||||
|
||||
```js
|
||||
mvpLocal: true,
|
||||
mvpBaseUrl: 'http://127.0.0.1:8080',
|
||||
```
|
||||
|
||||
`pages/mvp/mvp.js` 在 `onLoad` 时如果 `mvpLocal === true`,会临时把 `transport` 改成 `direct`、`baseUrl` 改成 `mvpBaseUrl`,离开页面时恢复。这样不会破坏主应用原来指向 `18180` 的 Node 后端配置。
|
||||
|
||||
进入页面后会自动调用一次 `/api/taobao/ping`,也可以点三个按钮:
|
||||
|
||||
- `POST /api/taobao/ping`
|
||||
- `POST /api/taobao/whoami`
|
||||
- `POST /api/taobao/echo`
|
||||
|
||||
预期页面显示 `server: "java"`、`pong: true`。
|
||||
|
||||
> 如果 IDE 拦截 localhost 请求,打开 IDE 的“不校验合法域名”/“开发环境不校验请求域名”开关(不同版本叫法不同)。桌面模拟器一般可以直接访问回环地址。
|
||||
|
||||
### 3.4 这一步验证了什么
|
||||
|
||||
- 小程序的 `utils/transport.js` 能走 `direct` 分支;
|
||||
- `my.request` 能发 `POST application/json`;
|
||||
- `transport.unwrap` 能正确解包 `{success,data}`;
|
||||
- Java 后端端口、路径、返回结构都对。
|
||||
|
||||
---
|
||||
|
||||
## 4. 云端链路(SAE + 聚石塔云应用)
|
||||
|
||||
### 4.1 先理解三层关系
|
||||
|
||||
```text
|
||||
淘宝小程序
|
||||
└─ @tbmp/mp-cloud-sdk
|
||||
└─ 淘宝云网关(校验 appkey/sign,注入 open_id/access_token)
|
||||
└─ 聚石塔云应用(应用/环境/流量接入)
|
||||
└─ SAE(真正的 Java 容器,跑 JAR)
|
||||
```
|
||||
|
||||
- **SAE 只是算力层**,不是“建一个公网 URL 就能被小程序调用”。
|
||||
- 小程序要调通,SAE 应用必须挂在**聚石塔云应用**下,或者走**空应用(不入塔)+ 域名白名单**。
|
||||
- 2026-05-11 公告:原聚石塔“应用创建链路”2026-06-20 下线,部署运维迁到 ACK/SAE;聚石塔保留云应用管理、环境管理、流量接入。
|
||||
- 参考:[迁移通知](https://api.alidayu.com/support/announcementDetail.htm?source=search&id=25839)、[新链路快速创建云应用](https://open.taobao.com/doc.htm?docId=122332&docType=1)、[云服务产品概述](https://miniapp.open.taobao.com/docV3.htm?docId=118541&docType=1)。
|
||||
|
||||
### 4.2 构建 JAR
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy -Scope Process Bypass
|
||||
. D:\juzi\tools\env-java.ps1
|
||||
cd D:\juzi\backend-java
|
||||
mvn -DskipTests clean package
|
||||
```
|
||||
|
||||
产物:
|
||||
|
||||
```text
|
||||
D:\juzi\backend-java\target\taobao-mvp-backend-0.0.1-SNAPSHOT.jar
|
||||
```
|
||||
|
||||
### 4.3 SAE 创建应用
|
||||
|
||||
在 SAE 控制台“创建应用 → 代码包部署 → 设置代码包部署”里按下面选:
|
||||
|
||||
| 配置项 | 选择 |
|
||||
|---|---|
|
||||
| 技术栈语言 | **Java** |
|
||||
| 代码包类型 | **JAR 包部署** |
|
||||
| 应用运行环境 | **标准 Java 应用运行环境** |
|
||||
| Java 环境 | **Open JDK 17** |
|
||||
| 文件上传方式 | 上传 JAR 包 |
|
||||
| 启动命令 | 默认 `java -jar` |
|
||||
| 应用端口 | **8080** |
|
||||
| 健康检查 | HTTP `GET /actuator/health`,期望 200 |
|
||||
| 实例数 | **2**(至少 2,避免单点) |
|
||||
| 时区 | **Asia/Shanghai** |
|
||||
| 地域/命名空间/VPC | 用聚石塔创建云应用时分配的那套,不要自己新建默认命名空间 |
|
||||
|
||||
**不要选**:
|
||||
|
||||
- WAR 包部署
|
||||
- `apache-tomcat-*`
|
||||
- `EDAS-Container-*`
|
||||
- Open JDK 25
|
||||
|
||||
原因:WAR/Tomcat 是给外置 Tomcat 的老项目;EDAS-Container 是 HSF/Pandora Boot 专用;Open JDK 25 与 Spring Boot 3.5 的兼容性没有 17 稳。
|
||||
|
||||
环境变量(SAE → 应用设置 → 环境变量):
|
||||
|
||||
```text
|
||||
TAOBAO_APP_KEY=你的AppKey
|
||||
TAOBAO_APP_SECRET=你的AppSecret
|
||||
TAOBAO_VERIFY_SIGN=true
|
||||
```
|
||||
|
||||
> `TAOBAO_APP_SECRET` 只能放服务端,绝不能进小程序代码或 `config.js`。
|
||||
|
||||
建议 JVM 参数(按实例规格调整,堆小于容器内存):
|
||||
|
||||
```text
|
||||
-Xms512m -Xmx1024m -Duser.timezone=Asia/Shanghai
|
||||
```
|
||||
|
||||
### 4.4 流量接入
|
||||
|
||||
- 云应用走**内网私网 SLB**:聚石塔“流量接入”里绑定私网 SLB,后端端口 8080。
|
||||
- 不要给云应用开公网入口;公网只用于你自己调试。
|
||||
- 如果后端要调 TOP/奇门/外部 API,给 SAE 配 **NAT 网关或 EIP**,否则容器只入不出,调用会超时。
|
||||
- 数据库在 RDS 时,把 SAE 的网段/安全组加进 RDS 白名单。
|
||||
|
||||
### 4.5 聚石塔/开放平台关联
|
||||
|
||||
1. 聚石塔控制台 → 云应用 → 新建云应用(按 2026-05 新链路向导)。
|
||||
2. 部署方式选 SAE;创建测试环境、正式环境。
|
||||
3. 在 SAE 里把 JAR 部署到对应应用/环境。
|
||||
4. 聚石塔 → 流量接入 → 绑定私网 SLB(测试/正式各一套,或共用一个正式 SLB)。
|
||||
5. 开放平台控制台 → 云托管 → 关联云应用 → 选择你的云应用和对应 SLB。
|
||||
6. 在“服务测试”里调用 `POST /api/taobao/ping`,确认返回 `success: true`。
|
||||
7. 在 `taobao/config.js` 里把 `mvpLocal` 改成 `false`,并配置:
|
||||
|
||||
```js
|
||||
transport: 'cloud',
|
||||
baseUrl: 'https://你的HTTPS域名',
|
||||
cloudAppId: '控制台拿到的 cloudAppId',
|
||||
cloudEnv: 'test', // 先 test,验收后改 online
|
||||
```
|
||||
|
||||
8. 进入 `pages/mvp/mvp`,点 `ping`,看到 `transport: "gateway"`、`signVerified: true` 即通。
|
||||
|
||||
> 账号必须一致:创建云应用的聚石塔账号,要和开放平台小程序账号一致;子账号看不到云应用。小程序上线后默认云应用不能再改,选择时确认好。
|
||||
|
||||
### 4.6 空应用(不入塔)作为备选
|
||||
|
||||
如果这个 SAE 应用不在聚石塔,不能直接“关联云应用”。只能走空应用:
|
||||
|
||||
1. 开放平台 → 云托管 → 云应用管理 → 新建空应用。
|
||||
2. 关联一个**没有敏感数据权限**的 AppKey。
|
||||
3. 设置域名白名单,提交审批(HTTPS、已备案域名)。
|
||||
4. 调用时 `exts.domain` 传你的域名;现有 `transport.js` 已经会传 `domain: config.baseUrl`。
|
||||
|
||||
注意:现有 `transport.js` 的 cloud 分支还要求 `config.cloudAppId` 非空。如果空应用控制台没有给 cloudAppId,需要先把这行校验放宽(或暂时先用聚石塔云应用)。
|
||||
|
||||
空应用只适合内容/工具/轻服务;电商交易、用户敏感数据类大概率不适合,还是得入塔。
|
||||
|
||||
---
|
||||
|
||||
## 5. 云网关接口契约(Java 后端必须遵守)
|
||||
|
||||
现有 Node 实现已经按这个契约写了,Java 迁移时照抄:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {},
|
||||
"errorCode": "xxxx",
|
||||
"errorMessage": "xxxx"
|
||||
}
|
||||
```
|
||||
|
||||
- 只支持 **GET/POST**;POST 必须 `application/json`。
|
||||
- 请求体 **≤128KB**;超时默认 3s、最大 10s。
|
||||
- 平台会往 URL 追加 `app_key`、`open_id`、`access_token`、`sign`、`env`、`mini_app_id` 等系统参数;自定义参数不要重名。
|
||||
- 成功时小程序 SDK 只把 `data` 返回给页面;`errorCode/errorMessage` 在失败时返回。
|
||||
- 发布前把 `cloudEnv` 从 `test` 改成 `online`;默认云应用上线后不能改。
|
||||
- 图片/长任务不要塞进一个请求:现有实现是 64KiB 分片上传 + 异步任务 + 轮询。
|
||||
|
||||
参考:[云应用调用](https://open.m.taobao.com/docs/doc.htm?treeId=622&articleId=118538&docType=1)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 签名校验
|
||||
|
||||
Java 的 `TaobaoSignatureVerifier` 与现有 `h5/lib/taobao-auth.js` 算法一致:
|
||||
|
||||
1. 取全部 query 参数,排除 `sign` 和空值,按 key 升序,拼成 `key1value1key2value2...`。
|
||||
2. 末尾追加**原始 JSON body**(不要重新序列化)。
|
||||
3. `sign_method=md5`:`MD5(secret + content + secret)`;`sign_method` 为空:`MD5(content + secret)`;`hmac*` 用 HMAC。
|
||||
4. 结果转大写十六进制,与 `sign` 比较。
|
||||
|
||||
本地 `TAOBAO_VERIFY_SIGN=false`,没有 `app_key/sign`,直接放行,返回 `signVerified:false`。
|
||||
|
||||
云端 `TAOBAO_VERIFY_SIGN=true`:
|
||||
|
||||
- `app_key` 必须等于 `TAOBAO_APP_KEY`;
|
||||
- `open_id` 必须符合 `[A-Za-z0-9_-]{4,256}`;
|
||||
- `sign` 必须是 32–64 位十六进制;
|
||||
- 重复 query 参数直接拒绝;
|
||||
- 校验失败返回 `success:false, errorCode:"SIGN_INVALID"`。
|
||||
|
||||
如果云端一直 `SIGN_INVALID`,按顺序查:
|
||||
|
||||
1. SAE 环境变量是否真的注入(重启后生效)。
|
||||
2. `TAOBAO_APP_KEY` 是否和云应用/小程序一致。
|
||||
3. 网关用的 `sign_method` 与后端是否匹配。
|
||||
4. body 是否被反向代理/框架重新序列化(必须保留原始字节)。
|
||||
5. 是否有重复的 `open_id`/`app_key` 参数。
|
||||
|
||||
---
|
||||
|
||||
## 7. 常见问题
|
||||
|
||||
| 现象 | 排查 |
|
||||
|---|---|
|
||||
| `UnsupportedClassVersionError` | 编译 JDK 高于 SAE 运行时;统一用 17 |
|
||||
| `404` | 路径不对;云应用 path 是否带 `/api/taobao`;WAR 的 context path |
|
||||
| `502/504` | SAE 端口不是 8080、健康检查失败、SLB 后端端口不对 |
|
||||
| `success:false` + `SIGN_INVALID` | 见第 6 节 |
|
||||
| `cloud init failed` | IDE 未登录、AppID/cloudAppId 不匹配、cloudEnv 不对 |
|
||||
| `请先配置淘宝云应用和 HTTPS 服务地址` | `transport:'cloud'` 但 `baseUrl` 不是 HTTPS 或 `cloudAppId` 为空 |
|
||||
| `直连接口仅用于本机联调` | `transport:'direct'` 但 `baseUrl` 不是 localhost/127.0.0.1 |
|
||||
| `Cannot find module '@tbmp/mp-cloud-sdk'` | `npm.cmd ci` 后,在 IDE 里执行“构建 npm” |
|
||||
| 手机真机请求失败 | 127.0.0.1 是手机自己;真机必须走云端 HTTPS |
|
||||
| 请求超过 10 秒 | 云网关最大 10s;改异步任务 + 轮询 |
|
||||
| 返回体超过 128KB | 分页/裁剪字段,不要把大图 base64 放进响应 |
|
||||
|
||||
### 7.1 淘宝 IDE 报 `install miniapp extension fail: ... wmic ...`
|
||||
|
||||
Windows 11 24H2 起默认移除了 `wmic.exe`,而淘宝开发者工具在安装小程序扩展时会执行:
|
||||
|
||||
```text
|
||||
wmic path win32_VideoController get caption
|
||||
```
|
||||
|
||||
找不到 `wmic` 就报这个错。本机已经放了一个兼容 shim:
|
||||
|
||||
```text
|
||||
C:\Users\youmi\.local\bin\wmic.cmd
|
||||
```
|
||||
|
||||
修复步骤:
|
||||
|
||||
1. 完全退出淘宝开发者工具(任务管理器里不要留 `淘宝开发者工具.exe`)。
|
||||
2. 重新打开 IDE,再导入 `D:\juzi\taobao`。
|
||||
|
||||
如果 shim 丢了,重新运行:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File D:\juzi\tools\fix-taobao-ide-wmic.ps1
|
||||
```
|
||||
|
||||
永久方案(需要管理员 PowerShell):
|
||||
|
||||
```powershell
|
||||
DISM /Online /Add-Capability /CapabilityName:WMIC~~~~
|
||||
```
|
||||
|
||||
装完后 `C:\Windows\System32\wmic.exe` 会优先于 shim,shim 可以删除。如果 DISM 提示找不到这个能力,说明系统镜像没带 WMIC FoD,继续用 shim 即可。
|
||||
|
||||
---
|
||||
|
||||
## 8. 下一步(MVP 通过后)
|
||||
|
||||
1. 把 `h5/lib/taobao-routes.js` 的接口逐个迁到 Java:
|
||||
- `POST /api/taobao/challenge`
|
||||
- `POST /api/taobao/session`
|
||||
- `POST /api/taobao/me`
|
||||
- `POST /api/taobao/photo`(64KiB 分片)
|
||||
- `POST /api/taobao/skin-analyze`(异步任务)
|
||||
- `POST /api/taobao/analysis`(轮询)
|
||||
- `POST /api/taobao/reports`
|
||||
- `POST /api/taobao/logout`
|
||||
2. 所有 `/api/taobao/**` 统一走签名校验 + envelope。
|
||||
3. 账号/历史/图片存到 RDS/OSS,不要放容器本地磁盘。
|
||||
4. 保留 H5 的 Node 路由不变,淘宝走 Java;两边共用同一套业务规则时,先以现有 Node 实现为契约。
|
||||
5. 接 SLS 日志、Actuator 健康检查、告警,再切正式流量。
|
||||
|
||||
---
|
||||
|
||||
## 9. 命令速查
|
||||
|
||||
```powershell
|
||||
# 每个新窗口
|
||||
Set-ExecutionPolicy -Scope Process Bypass
|
||||
. D:\juzi\tools\env-java.ps1
|
||||
|
||||
# 后端
|
||||
cd D:\juzi\backend-java
|
||||
mvn -DskipTests clean package
|
||||
java -jar target\taobao-mvp-backend-0.0.1-SNAPSHOT.jar
|
||||
|
||||
# 本地验证
|
||||
curl.exe http://127.0.0.1:8080/actuator/health
|
||||
curl.exe -X POST -H "Content-Type: application/json" -d "{}" http://127.0.0.1:8080/api/taobao/ping
|
||||
|
||||
# 前端依赖
|
||||
cd D:\juzi\taobao
|
||||
npm.cmd ci
|
||||
```
|
||||
+2
-1
@@ -4,7 +4,8 @@
|
||||
"pages/profile/profile",
|
||||
"pages/camera/camera",
|
||||
"pages/report/report",
|
||||
"pages/history/history"
|
||||
"pages/history/history",
|
||||
"pages/mvp/mvp"
|
||||
],
|
||||
"window": {
|
||||
"defaultTitle": "1'me在线肌肤测试",
|
||||
|
||||
@@ -8,6 +8,10 @@ module.exports = {
|
||||
cloudAppId: '',
|
||||
cloudEnv: 'online', // External/empty cloud applications use online
|
||||
basePath: '', // E.g. '/state' behind an existing reverse proxy
|
||||
// SAE/Java MVP page only: force the local Java backend without changing the main app config.
|
||||
// Set false and edit transport/baseUrl/cloudAppId above when testing the cloud gateway.
|
||||
mvpLocal: true,
|
||||
mvpBaseUrl: 'http://127.0.0.1:8080',
|
||||
mock: false,
|
||||
productItemIds: {} // Optional real Taobao item IDs keyed by tier 1/2/3
|
||||
};
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
.mvp {
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 48rpx 32rpx 64rpx;
|
||||
background: #101823;
|
||||
color: #F5F5F5;
|
||||
}
|
||||
|
||||
.mvp-head {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.mvp-kicker {
|
||||
display: block;
|
||||
color: #F5F078;
|
||||
font-size: 24rpx;
|
||||
letter-spacing: 2rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.mvp-title {
|
||||
display: block;
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.mvp-sub {
|
||||
display: block;
|
||||
color: #9AA4B2;
|
||||
font-size: 26rpx;
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
|
||||
.mvp-card {
|
||||
background: #1B2634;
|
||||
border: 1rpx solid #2A3748;
|
||||
border-radius: 20rpx;
|
||||
padding: 28rpx;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.mvp-label {
|
||||
color: #9AA4B2;
|
||||
font-size: 24rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.mvp-code {
|
||||
display: block;
|
||||
color: #E8EDF4;
|
||||
font-family: Menlo, Consolas, monospace;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.mvp-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.mvp-btn {
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
border-radius: 44rpx;
|
||||
background: #243244;
|
||||
color: #E8EDF4;
|
||||
font-size: 28rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.mvp-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.mvp-btn-primary {
|
||||
background: #F5F078;
|
||||
color: #101823;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mvp-tip {
|
||||
color: #7F8A99;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.7;
|
||||
padding: 0 8rpx;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<view class="mvp">
|
||||
<view class="mvp-head">
|
||||
<text class="mvp-kicker">SAE + 淘宝云应用</text>
|
||||
<text class="mvp-title">最小连通性验证</text>
|
||||
<text class="mvp-sub">Java 后端 / 云网关统一响应</text>
|
||||
</view>
|
||||
|
||||
<view class="mvp-card">
|
||||
<view class="mvp-label">当前 config.js</view>
|
||||
<text class="mvp-code">{{configText}}</text>
|
||||
</view>
|
||||
|
||||
<view class="mvp-actions">
|
||||
<button class="mvp-btn mvp-btn-primary" onTap="onPing" loading="{{loading}}">POST /api/taobao/ping</button>
|
||||
<button class="mvp-btn" onTap="onWhoami" loading="{{loading}}">POST /api/taobao/whoami</button>
|
||||
<button class="mvp-btn" onTap="onEcho" loading="{{loading}}">POST /api/taobao/echo</button>
|
||||
<button class="mvp-btn" onTap="onBack">返回首页</button>
|
||||
</view>
|
||||
|
||||
<view class="mvp-card">
|
||||
<view class="mvp-label">返回(已由 transport.unwrap 解包)</view>
|
||||
<text class="mvp-code">{{resultText}}</text>
|
||||
</view>
|
||||
|
||||
<view class="mvp-tip">
|
||||
本地调试:config.transport = direct,baseUrl 指向 127.0.0.1。云端:transport = cloud,baseUrl 必须是 HTTPS,并填写 cloudAppId/cloudEnv。
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,63 @@
|
||||
var config = require('../../config');
|
||||
var transport = require('../../utils/transport');
|
||||
var original = { transport: config.transport, baseUrl: config.baseUrl };
|
||||
|
||||
if (config.mvpLocal) {
|
||||
config.transport = 'direct';
|
||||
config.baseUrl = config.mvpBaseUrl;
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
configText: '',
|
||||
resultText: '',
|
||||
loading: false
|
||||
},
|
||||
|
||||
onLoad: function () {
|
||||
this.setData({
|
||||
configText: JSON.stringify({
|
||||
environment: config.environment,
|
||||
transport: config.transport,
|
||||
baseUrl: config.baseUrl,
|
||||
cloudAppId: config.cloudAppId || '(empty)',
|
||||
cloudEnv: config.cloudEnv
|
||||
}, null, 2)
|
||||
});
|
||||
this.onPing();
|
||||
},
|
||||
|
||||
call: function (path, body) {
|
||||
var that = this;
|
||||
this.setData({ loading: true, resultText: '请求中...' });
|
||||
transport.request(path, body || {}).then(function (data) {
|
||||
that.setData({ loading: false, resultText: JSON.stringify(data, null, 2) });
|
||||
}).catch(function (err) {
|
||||
that.setData({
|
||||
loading: false,
|
||||
resultText: 'ERROR: ' + (err && err.message ? err.message : String(err))
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
onPing: function () {
|
||||
this.call('/api/taobao/ping', {});
|
||||
},
|
||||
|
||||
onWhoami: function () {
|
||||
this.call('/api/taobao/whoami', {});
|
||||
},
|
||||
|
||||
onEcho: function () {
|
||||
this.call('/api/taobao/echo', { hello: 'world', at: Date.now() });
|
||||
},
|
||||
|
||||
onBack: function () {
|
||||
my.navigateBack();
|
||||
},
|
||||
|
||||
onUnload: function () {
|
||||
config.transport = original.transport;
|
||||
config.baseUrl = original.baseUrl;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"defaultTitle": "SAE 连通性 MVP",
|
||||
"titleBarColor": "#101823",
|
||||
"backgroundColor": "#101823"
|
||||
}
|
||||
@@ -142,3 +142,4 @@
|
||||
.brand-row { justify-content: center; }
|
||||
.brand-logo { width: 450rpx; height: 52rpx; margin: 0; }
|
||||
.camera-debug-entry { position: absolute; z-index: 2; top: 24rpx; right: 24rpx; padding: 16rpx 24rpx; border-radius: 16rpx; color: #fff; background: rgba(0,0,0,.65); font-size: 24rpx; }
|
||||
.mvp-debug-entry { top: 100rpx; }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<!-- ═════ 01 启动页 ═════ -->
|
||||
<view class="splash">
|
||||
<view class="camera-debug-entry" a:if="{{showCameraDebug}}" onTap="onCameraDebug">开发调试 · 相机验收</view>
|
||||
<view class="camera-debug-entry mvp-debug-entry" a:if="{{showMvpDebug}}" onTap="onMvpDebug">SAE MVP 连通性</view>
|
||||
<image class="splash-bg" src="{{bgUrl}}" mode="aspectFill" />
|
||||
<view class="splash-slogan">
|
||||
<text>1'me,</text>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* ═════ 启动页:品牌底图 + Slogan + 隐私授权弹窗(对齐 H5 #s-splash + #p-privacy) ═════ */
|
||||
var api = require('../../utils/api.js');
|
||||
var development = require('../../utils/development');
|
||||
var config = require('../../config');
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -8,7 +9,8 @@ Page({
|
||||
logoUrl: api.assetUrl('img/ui-20261012/brand-lockup.png'),
|
||||
showPrivacy: false, // 隐私授权弹窗
|
||||
agreed: false, // 隐私协议勾选
|
||||
showPolicy: false, busy: false, signedIn: false, showCameraDebug: development.cameraDebugEnabled()
|
||||
showPolicy: false, busy: false, signedIn: false, showCameraDebug: development.cameraDebugEnabled(),
|
||||
showMvpDebug: config.mvpLocal === true
|
||||
},
|
||||
onShow: function () {
|
||||
var that = this;
|
||||
@@ -65,6 +67,10 @@ Page({
|
||||
if (!development.cameraDebugEnabled()) return;
|
||||
my.navigateTo({ url: '/pages/camera/camera?debug=camera' });
|
||||
},
|
||||
onMvpDebug: function () {
|
||||
if (!config.mvpLocal) return;
|
||||
my.navigateTo({ url: '/pages/mvp/mvp' });
|
||||
},
|
||||
|
||||
noop: function () {} // 弹窗卡片内点击拦截冒泡用
|
||||
});
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
'use strict';
|
||||
|
||||
// 不依赖淘宝 IDE 的本地契约测试:
|
||||
// 用 Node 模拟 my.request,加载真实的 utils/transport.js,直连 Java 后端。
|
||||
// 运行前先启动 backend-java,然后:node scripts/mvp-local-check.cjs
|
||||
|
||||
const http = require('node:http');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const BASE = process.env.MVP_BASE_URL || 'http://127.0.0.1:8080';
|
||||
const config = {
|
||||
environment: 'development',
|
||||
transport: 'direct',
|
||||
baseUrl: BASE,
|
||||
assetBase: BASE + '/assets/',
|
||||
cloudAppId: '',
|
||||
cloudEnv: 'test',
|
||||
basePath: '',
|
||||
mock: false
|
||||
};
|
||||
|
||||
function httpRequest(url, options, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(url, options, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch (e) {
|
||||
reject(new Error('non-JSON response: ' + text.slice(0, 200)));
|
||||
return;
|
||||
}
|
||||
resolve({ status: res.statusCode, statusCode: res.statusCode, data });
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
const my = {
|
||||
request(options) {
|
||||
httpRequest(options.url, { method: options.method, headers: options.headers }, options.data)
|
||||
.then((res) => options.success(res))
|
||||
.catch((err) => options.fail(err));
|
||||
}
|
||||
};
|
||||
|
||||
const transportModule = { exports: {} };
|
||||
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../utils/transport.js'), 'utf8'), {
|
||||
module: transportModule,
|
||||
Promise,
|
||||
my,
|
||||
require(name) {
|
||||
return name === '../config' ? config : {};
|
||||
}
|
||||
});
|
||||
|
||||
const transport = transportModule.exports;
|
||||
|
||||
(async () => {
|
||||
const ping = await transport.request('/api/taobao/ping', {});
|
||||
assert.equal(ping.server, 'java', 'ping.server should be java');
|
||||
assert.equal(ping.pong, true, 'ping.pong should be true');
|
||||
|
||||
const echo = await transport.request('/api/taobao/echo', { hello: 'mvp' });
|
||||
assert.equal(echo.body.hello, 'mvp', 'echo.body.hello should round-trip');
|
||||
|
||||
const whoami = await transport.request('/api/taobao/whoami', {});
|
||||
assert.equal(whoami.ok, true, 'whoami.ok should be true');
|
||||
|
||||
console.log('PASS');
|
||||
console.log(JSON.stringify({ ping, echo, whoami }, null, 2));
|
||||
})().catch((err) => {
|
||||
console.error('FAIL:', err && err.stack ? err.stack : err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
# 在当前 PowerShell 会话中加载 JDK 17 + Maven 3.9.9
|
||||
# 用法:. D:\juzi\tools\env-java.ps1
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$jdkRoot = Join-Path $projectRoot 'tools\jdk17'
|
||||
$jdkCandidates = @($jdkRoot)
|
||||
if (Test-Path $jdkRoot) {
|
||||
$jdkCandidates += Get-ChildItem $jdkRoot -Directory -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { $_.FullName }
|
||||
}
|
||||
|
||||
$jdkHome = $jdkCandidates |
|
||||
Where-Object { Test-Path (Join-Path $_ 'bin\java.exe') } |
|
||||
Select-Object -First 1
|
||||
|
||||
if (-not $jdkHome) {
|
||||
throw "JDK 17 not found under $jdkRoot. Run the portable JDK setup first."
|
||||
}
|
||||
|
||||
$mavenHome = Join-Path $projectRoot 'tools\maven\apache-maven-3.9.9'
|
||||
if (-not (Test-Path (Join-Path $mavenHome 'bin\mvn.cmd'))) {
|
||||
throw "Maven not found at $mavenHome. Run the portable Maven setup first."
|
||||
}
|
||||
|
||||
$env:JAVA_HOME = $jdkHome
|
||||
$env:MAVEN_HOME = $mavenHome
|
||||
$env:PATH = "$jdkHome\bin;$mavenHome\bin;$env:PATH"
|
||||
|
||||
Write-Host "JAVA_HOME=$env:JAVA_HOME"
|
||||
Write-Host "MAVEN_HOME=$env:MAVEN_HOME"
|
||||
java -version
|
||||
mvn -version
|
||||
@@ -0,0 +1,44 @@
|
||||
# Fix Taobao DevTools on Windows 11 24H2+:
|
||||
# install miniapp extension fail: Error: Command failed: wmic path win32_VideoController get caption
|
||||
#
|
||||
# Windows 11 24H2 removed wmic.exe. Taobao DevTools still calls it during
|
||||
# extension installation. This creates a small user-level wmic.cmd shim in a
|
||||
# directory that is already on the user PATH (%USERPROFILE%\.local\bin).
|
||||
# No administrator rights are required.
|
||||
#
|
||||
# Run:
|
||||
# powershell -ExecutionPolicy Bypass -File D:\juzi\tools\fix-taobao-ide-wmic.ps1
|
||||
# Then fully quit and reopen Taobao DevTools.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$bin = Join-Path $env:USERPROFILE '.local\bin'
|
||||
New-Item -ItemType Directory -Force $bin | Out-Null
|
||||
$shim = Join-Path $bin 'wmic.cmd'
|
||||
|
||||
$content = @'
|
||||
@echo off
|
||||
setlocal
|
||||
if /i "%~1"=="path" if /i "%~2"=="win32_VideoController" if /i "%~3"=="get" if /i "%~4"=="caption" (
|
||||
powershell.exe -NoProfile -NonInteractive -Command "Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Caption"
|
||||
exit /b 0
|
||||
)
|
||||
if /i "%~1"=="os" if /i "%~2"=="get" if /i "%~3"=="locale" (
|
||||
powershell.exe -NoProfile -NonInteractive -Command "[System.Globalization.CultureInfo]::CurrentCulture.LCID.ToString('X4')"
|
||||
exit /b 0
|
||||
)
|
||||
exit /b 0
|
||||
'@
|
||||
|
||||
Set-Content -Path $shim -Value $content -Encoding ASCII
|
||||
Write-Host "Created: $shim"
|
||||
|
||||
$video = & $shim path win32_VideoController get caption
|
||||
Write-Host ("GPU: " + ($video -join ', '))
|
||||
|
||||
$locale = & $shim os get locale
|
||||
Write-Host "Locale: $locale"
|
||||
|
||||
Write-Host ''
|
||||
Write-Host 'Done. Fully quit Taobao DevTools (no Taobao DevTools process left in Task Manager), then reopen it.'
|
||||
Write-Host 'Permanent alternative (elevated PowerShell): DISM /Online /Add-Capability /CapabilityName:WMIC~~~~'
|
||||
Reference in New Issue
Block a user