From ac0b7ca42b0d09c769ff7211c9034c6fd5647879 Mon Sep 17 00:00:00 2001 From: xsl Date: Sat, 31 Oct 2026 18:35:04 +0800 Subject: [PATCH] feat(sae): add Java MVP backend and Taobao connectivity page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 8 + README.md | 1 + backend-java/README.md | 56 +++ backend-java/pom.xml | 48 ++ .../java/com/juzi/mvp/MvpApplication.java | 12 + .../java/com/juzi/mvp/api/ApiResponse.java | 20 + .../java/com/juzi/mvp/api/MvpController.java | 123 +++++ .../java/com/juzi/mvp/config/CorsConfig.java | 21 + .../mvp/gateway/TaobaoSignatureVerifier.java | 123 +++++ .../src/main/resources/application.yml | 29 ++ .../com/juzi/mvp/MvpApplicationTests.java | 62 +++ .../gateway/TaobaoSignatureVerifierTest.java | 26 + docs/SAE-Java-MVP-操作步骤.md | 443 ++++++++++++++++++ taobao/app.json | 3 +- taobao/config.js | 4 + taobao/pages/mvp/mvp.acss | 91 ++++ taobao/pages/mvp/mvp.axml | 28 ++ taobao/pages/mvp/mvp.js | 63 +++ taobao/pages/mvp/mvp.json | 5 + taobao/pages/splash/splash.acss | 1 + taobao/pages/splash/splash.axml | 1 + taobao/pages/splash/splash.js | 8 +- taobao/scripts/mvp-local-check.cjs | 83 ++++ tools/env-java.ps1 | 33 ++ tools/fix-taobao-ide-wmic.ps1 | 44 ++ 25 files changed, 1334 insertions(+), 2 deletions(-) create mode 100644 backend-java/README.md create mode 100644 backend-java/pom.xml create mode 100644 backend-java/src/main/java/com/juzi/mvp/MvpApplication.java create mode 100644 backend-java/src/main/java/com/juzi/mvp/api/ApiResponse.java create mode 100644 backend-java/src/main/java/com/juzi/mvp/api/MvpController.java create mode 100644 backend-java/src/main/java/com/juzi/mvp/config/CorsConfig.java create mode 100644 backend-java/src/main/java/com/juzi/mvp/gateway/TaobaoSignatureVerifier.java create mode 100644 backend-java/src/main/resources/application.yml create mode 100644 backend-java/src/test/java/com/juzi/mvp/MvpApplicationTests.java create mode 100644 backend-java/src/test/java/com/juzi/mvp/gateway/TaobaoSignatureVerifierTest.java create mode 100644 docs/SAE-Java-MVP-操作步骤.md create mode 100644 taobao/pages/mvp/mvp.acss create mode 100644 taobao/pages/mvp/mvp.axml create mode 100644 taobao/pages/mvp/mvp.js create mode 100644 taobao/pages/mvp/mvp.json create mode 100644 taobao/scripts/mvp-local-check.cjs create mode 100644 tools/env-java.ps1 create mode 100644 tools/fix-taobao-ide-wmic.ps1 diff --git a/.gitignore b/.gitignore index f8cd0b7..7f769a1 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 84fba1a..6e0c9e6 100644 --- a/README.md +++ b/README.md @@ -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 路由。 diff --git a/backend-java/README.md b/backend-java/README.md new file mode 100644 index 0000000..5cd9419 --- /dev/null +++ b/backend-java/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.1-SNAPSHOT.jar +``` + +启动后: + +- 健康检查: +- 云网关测试接口:`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 实例。 diff --git a/backend-java/pom.xml b/backend-java/pom.xml new file mode 100644 index 0000000..d34651b --- /dev/null +++ b/backend-java/pom.xml @@ -0,0 +1,48 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.6 + + + + com.juzi + taobao-mvp-backend + 0.0.1-SNAPSHOT + taobao-mvp-backend + Taobao mini program + SAE + cloud gateway minimal MVP backend + + + 17 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/backend-java/src/main/java/com/juzi/mvp/MvpApplication.java b/backend-java/src/main/java/com/juzi/mvp/MvpApplication.java new file mode 100644 index 0000000..1bd1d04 --- /dev/null +++ b/backend-java/src/main/java/com/juzi/mvp/MvpApplication.java @@ -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); + } +} diff --git a/backend-java/src/main/java/com/juzi/mvp/api/ApiResponse.java b/backend-java/src/main/java/com/juzi/mvp/api/ApiResponse.java new file mode 100644 index 0000000..6d51c60 --- /dev/null +++ b/backend-java/src/main/java/com/juzi/mvp/api/ApiResponse.java @@ -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(boolean success, T data, String errorCode, String errorMessage) { + + public static ApiResponse ok(T data) { + return new ApiResponse<>(true, data, null, null); + } + + public static ApiResponse error(String errorCode, String errorMessage) { + return new ApiResponse<>(false, null, errorCode, errorMessage); + } +} diff --git a/backend-java/src/main/java/com/juzi/mvp/api/MvpController.java b/backend-java/src/main/java/com/juzi/mvp/api/MvpController.java new file mode 100644 index 0000000..e222da5 --- /dev/null +++ b/backend-java/src/main/java/com/juzi/mvp/api/MvpController.java @@ -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> 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 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> pingGet(HttpServletRequest request) { + return ping(null, request); + } + + @PostMapping(value = "/whoami", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public ApiResponse> 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 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> 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 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() { + }); + } catch (Exception e) { + Map 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 sortedParams(HttpServletRequest request) { + Map out = new TreeMap<>(); + request.getParameterMap().forEach((key, values) -> { + if (values != null && values.length > 0 && !"sign".equals(key)) { + out.put(key, values[0]); + } + }); + return out; + } +} diff --git a/backend-java/src/main/java/com/juzi/mvp/config/CorsConfig.java b/backend-java/src/main/java/com/juzi/mvp/config/CorsConfig.java new file mode 100644 index 0000000..6f8104c --- /dev/null +++ b/backend-java/src/main/java/com/juzi/mvp/config/CorsConfig.java @@ -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); + } +} diff --git a/backend-java/src/main/java/com/juzi/mvp/gateway/TaobaoSignatureVerifier.java b/backend-java/src/main/java/com/juzi/mvp/gateway/TaobaoSignatureVerifier.java new file mode 100644 index 0000000..662b0c5 --- /dev/null +++ b/backend-java/src/main/java/com/juzi/mvp/gateway/TaobaoSignatureVerifier.java @@ -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 params = new TreeMap<>(); + for (Map.Entry 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 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(); + } +} diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml new file mode 100644 index 0000000..e054611 --- /dev/null +++ b/backend-java/src/main/resources/application.yml @@ -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 diff --git a/backend-java/src/test/java/com/juzi/mvp/MvpApplicationTests.java b/backend-java/src/test/java/com/juzi/mvp/MvpApplicationTests.java new file mode 100644 index 0000000..471ff5b --- /dev/null +++ b/backend-java/src/test/java/com/juzi/mvp/MvpApplicationTests.java @@ -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 response = rest.postForEntity(url("/api/taobao/ping"), Map.of(), Map.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsEntry("success", true); + Map data = (Map) 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 response = rest.postForEntity( + url("/api/taobao/echo"), + new HttpEntity<>("{\"hello\":\"world\"}", headers), + Map.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + Map data = (Map) response.getBody().get("data"); + Map body = (Map) data.get("body"); + assertThat(body).containsEntry("hello", "world"); + } + + @Test + void actuatorHealthIsUp() { + ResponseEntity response = rest.getForEntity(url("/actuator/health"), String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).contains("\"status\":\"UP\""); + } +} diff --git a/backend-java/src/test/java/com/juzi/mvp/gateway/TaobaoSignatureVerifierTest.java b/backend-java/src/test/java/com/juzi/mvp/gateway/TaobaoSignatureVerifierTest.java new file mode 100644 index 0000000..f1cc81c --- /dev/null +++ b/backend-java/src/test/java/com/juzi/mvp/gateway/TaobaoSignatureVerifierTest.java @@ -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 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"); + } +} diff --git a/docs/SAE-Java-MVP-操作步骤.md b/docs/SAE-Java-MVP-操作步骤.md new file mode 100644 index 0000000..292d68c --- /dev/null +++ b/docs/SAE-Java-MVP-操作步骤.md @@ -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\\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 +``` diff --git a/taobao/app.json b/taobao/app.json index 63fa8e4..f8d8462 100644 --- a/taobao/app.json +++ b/taobao/app.json @@ -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在线肌肤测试", diff --git a/taobao/config.js b/taobao/config.js index f043a90..eabb880 100644 --- a/taobao/config.js +++ b/taobao/config.js @@ -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 }; diff --git a/taobao/pages/mvp/mvp.acss b/taobao/pages/mvp/mvp.acss new file mode 100644 index 0000000..1d42e09 --- /dev/null +++ b/taobao/pages/mvp/mvp.acss @@ -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; +} diff --git a/taobao/pages/mvp/mvp.axml b/taobao/pages/mvp/mvp.axml new file mode 100644 index 0000000..eb7d55c --- /dev/null +++ b/taobao/pages/mvp/mvp.axml @@ -0,0 +1,28 @@ + + + SAE + 淘宝云应用 + 最小连通性验证 + Java 后端 / 云网关统一响应 + + + + 当前 config.js + {{configText}} + + + + + + + + + + + 返回(已由 transport.unwrap 解包) + {{resultText}} + + + + 本地调试:config.transport = direct,baseUrl 指向 127.0.0.1。云端:transport = cloud,baseUrl 必须是 HTTPS,并填写 cloudAppId/cloudEnv。 + + diff --git a/taobao/pages/mvp/mvp.js b/taobao/pages/mvp/mvp.js new file mode 100644 index 0000000..51486ac --- /dev/null +++ b/taobao/pages/mvp/mvp.js @@ -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; + } +}); diff --git a/taobao/pages/mvp/mvp.json b/taobao/pages/mvp/mvp.json new file mode 100644 index 0000000..6b5b05b --- /dev/null +++ b/taobao/pages/mvp/mvp.json @@ -0,0 +1,5 @@ +{ + "defaultTitle": "SAE 连通性 MVP", + "titleBarColor": "#101823", + "backgroundColor": "#101823" +} diff --git a/taobao/pages/splash/splash.acss b/taobao/pages/splash/splash.acss index 5a35de4..73fcdec 100644 --- a/taobao/pages/splash/splash.acss +++ b/taobao/pages/splash/splash.acss @@ -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; } diff --git a/taobao/pages/splash/splash.axml b/taobao/pages/splash/splash.axml index 7227a92..ac07a02 100644 --- a/taobao/pages/splash/splash.axml +++ b/taobao/pages/splash/splash.axml @@ -1,6 +1,7 @@ 开发调试 · 相机验收 + SAE MVP 连通性 1'me, diff --git a/taobao/pages/splash/splash.js b/taobao/pages/splash/splash.js index a237e7b..61f1112 100644 --- a/taobao/pages/splash/splash.js +++ b/taobao/pages/splash/splash.js @@ -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 () {} // 弹窗卡片内点击拦截冒泡用 }); diff --git a/taobao/scripts/mvp-local-check.cjs b/taobao/scripts/mvp-local-check.cjs new file mode 100644 index 0000000..f3cc490 --- /dev/null +++ b/taobao/scripts/mvp-local-check.cjs @@ -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); +}); diff --git a/tools/env-java.ps1 b/tools/env-java.ps1 new file mode 100644 index 0000000..adb96ba --- /dev/null +++ b/tools/env-java.ps1 @@ -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 diff --git a/tools/fix-taobao-ide-wmic.ps1 b/tools/fix-taobao-ide-wmic.ps1 new file mode 100644 index 0000000..7b77a45 --- /dev/null +++ b/tools/fix-taobao-ide-wmic.ps1 @@ -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~~~~'