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:
xsl
2026-10-31 18:35:04 +08:00
parent 459e21dad4
commit ac0b7ca42b
25 changed files with 1334 additions and 2 deletions
+56
View File
@@ -0,0 +1,56 @@
# taobao-mvp-backend
淘宝小程序 + 淘宝云网关 + SAE 的最小 Java 后端验证工程。
## 快速启动
```powershell
Set-ExecutionPolicy -Scope Process Bypass
. D:\juzi\tools\env-java.ps1
cd D:\juzi\backend-java
mvn -DskipTests package
java -jar target\taobao-mvp-backend-0.0.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 实例。
+48
View File
@@ -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");
}
}