diff --git a/.gitignore b/.gitignore index 7f769a1..85be072 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ temp/ # Java MVP 构建产物 /backend-java/target/ +# 临时打进 JAR 的淘宝配置(只用于联调,绝不提交) +/backend-java/src/main/resources/application-local.yml + # 便携工具链(体积大,本机安装,不入库) /tools/jdk17/ /tools/maven/ diff --git a/README.md b/README.md index 6e0c9e6..e37d8f5 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,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)。 +- 当前交接:[docs/HANDOFF-2026-11-01.md](docs/HANDOFF-2026-11-01.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 index 5cd9419..03c5903 100644 --- a/backend-java/README.md +++ b/backend-java/README.md @@ -9,7 +9,7 @@ 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 +java -jar target\taobao-mvp-backend-0.0.2.jar ``` 启动后: diff --git a/backend-java/pom.xml b/backend-java/pom.xml index d34651b..3a97f91 100644 --- a/backend-java/pom.xml +++ b/backend-java/pom.xml @@ -13,7 +13,7 @@ com.juzi taobao-mvp-backend - 0.0.1-SNAPSHOT + 0.0.4 taobao-mvp-backend Taobao mini program + SAE + cloud gateway minimal MVP backend @@ -38,6 +38,8 @@ + + taobao-mvp-backend-0.0.4-forward org.springframework.boot diff --git a/backend-java/src/main/java/com/juzi/mvp/api/TaobaoAuthController.java b/backend-java/src/main/java/com/juzi/mvp/api/TaobaoAuthController.java new file mode 100644 index 0000000..985ee62 --- /dev/null +++ b/backend-java/src/main/java/com/juzi/mvp/api/TaobaoAuthController.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.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 淘宝小程序登录相关的四个接口:challenge / session / me / logout。 + * + *

契约来自参考实现 h5/lib/taobao-routes.js 与客户端 taobao/utils/api.js: + *

    + *
  • HTTP 永远 200,逻辑状态放 data.http_status(见 {@link TaobaoEnvelope})。
  • + *
  • 客户端不发身份,身份来自云网关注入的 query 参数(app_key / open_id / sign)。
  • + *
  • session 必须带 consent=true 和一次性的 challenge。
  • + *
  • 登录后所有接口用 Authorization: Bearer <token>。
  • + *
+ */ +@RestController +@RequestMapping("/api/taobao") +@ConditionalOnProperty(name = "taobao.mode", havingValue = "native", matchIfMissing = true) +public class TaobaoAuthController { + + private static final String OPEN_ID_PATTERN = "[A-Za-z0-9_-]{4,256}"; + private static final String IDENTITY_FAILED = "淘宝身份校验失败,请重新授权登录"; + private static final String SESSION_EXPIRED = "检测会话已失效,请重新登录"; + + private final TaobaoSignatureVerifier verifier; + private final TaobaoSessionService sessions; + private final ObjectMapper objectMapper; + + public TaobaoAuthController(TaobaoSignatureVerifier verifier, + TaobaoSessionService sessions, + ObjectMapper objectMapper) { + this.verifier = verifier; + this.sessions = sessions; + this.objectMapper = objectMapper; + } + + @PostMapping(value = "/challenge", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public ApiResponse> challenge(HttpServletRequest request) { + String id = sessions.challenge(sessions.rateKey(request)); + if (id == null) { + return ApiResponse.ok(TaobaoEnvelope.fail(429, "操作频繁,请稍后重试")); + } + Map data = TaobaoEnvelope.ok(200); + data.put("challenge", id); + return ApiResponse.ok(data); + } + + @PostMapping(value = "/session", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public ApiResponse> session(@RequestBody(required = false) String rawBody, + HttpServletRequest request) { + Map body = parseBody(rawBody); + if (!Boolean.TRUE.equals(body.get("consent"))) { + return ApiResponse.ok(TaobaoEnvelope.fail(400, "请先阅读并同意隐私协议")); + } + TaobaoSignatureVerifier.Result check = verifier.verify(request, rawBody); + if (!check.accepted()) { + return ApiResponse.ok(TaobaoEnvelope.fail(401, IDENTITY_FAILED)); + } + String openId = request.getParameter("open_id"); + if (openId == null || !openId.matches(OPEN_ID_PATTERN)) { + // 只有经由云网关的请求才带 open_id;本地直连需要自己拼 ?open_id=... + return ApiResponse.ok(TaobaoEnvelope.fail(401, "未取得淘宝身份,请在手机淘宝内重新打开")); + } + Object challenge = body.get("challenge"); + if (!(challenge instanceof String challengeId) || !sessions.consume(challengeId)) { + return ApiResponse.ok(TaobaoEnvelope.fail(401, IDENTITY_FAILED)); + } + TaobaoSessionService.Token token = sessions.issue(openId); + Map data = TaobaoEnvelope.ok(200); + data.put("token", token.value()); + data.put("expires_in", token.expiresInSeconds()); + data.put("user", user(token.userId())); + return ApiResponse.ok(data); + } + + @PostMapping(value = "/me", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public ApiResponse> me(HttpServletRequest request) { + TaobaoSessionService.Session session = sessions.find(TaobaoSessionService.bearerToken(request)); + if (session == null) { + return ApiResponse.ok(TaobaoEnvelope.fail(401, SESSION_EXPIRED)); + } + Map data = TaobaoEnvelope.ok(200); + data.put("user", user(session.userId())); + return ApiResponse.ok(data); + } + + @PostMapping(value = "/logout", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public ApiResponse> logout(HttpServletRequest request) { + sessions.revoke(TaobaoSessionService.bearerToken(request)); + return ApiResponse.ok(TaobaoEnvelope.ok(200)); + } + + private Map user(String userId) { + Map user = new LinkedHashMap<>(); + user.put("id", userId); + user.put("kind", "taobao"); + return user; + } + + private Map parseBody(String raw) { + if (raw == null || raw.isBlank()) { + return Map.of(); + } + try { + return objectMapper.readValue(raw, new TypeReference>() { + }); + } catch (Exception e) { + return Map.of(); + } + } +} diff --git a/backend-java/src/main/java/com/juzi/mvp/api/TaobaoEnvelope.java b/backend-java/src/main/java/com/juzi/mvp/api/TaobaoEnvelope.java new file mode 100644 index 0000000..2e7725a --- /dev/null +++ b/backend-java/src/main/java/com/juzi/mvp/api/TaobaoEnvelope.java @@ -0,0 +1,33 @@ +package com.juzi.mvp.api; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 淘宝小程序客户端读取的 data 结构约定。 + * + *

客户端(taobao/utils/api.js:13-16)只认 data.ok 和 data.http_status: + * HTTP 状态码永远是 200,逻辑状态(202/400/401/404/409/413/429)放在 data.http_status 里。 + * 失败时 message 就是 toast 文案,status 401 还会触发客户端自动登出。 + * 这与参考实现 h5/lib/taobao-routes.js:8 保持一致。 + */ +public final class TaobaoEnvelope { + + private TaobaoEnvelope() { + } + + public static Map ok(int httpStatus) { + Map data = new LinkedHashMap<>(); + data.put("ok", true); + data.put("http_status", httpStatus); + return data; + } + + public static Map fail(int httpStatus, String message) { + Map data = new LinkedHashMap<>(); + data.put("ok", false); + data.put("http_status", httpStatus); + data.put("message", message); + return data; + } +} diff --git a/backend-java/src/main/java/com/juzi/mvp/api/TaobaoForwardController.java b/backend-java/src/main/java/com/juzi/mvp/api/TaobaoForwardController.java new file mode 100644 index 0000000..f36d3c6 --- /dev/null +++ b/backend-java/src/main/java/com/juzi/mvp/api/TaobaoForwardController.java @@ -0,0 +1,117 @@ +package com.juzi.mvp.api; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +/** + * taobao.mode=forward 时启用:把 /api/taobao/* 业务接口字节级转发给外层 H5 后端(Node)。 + * + *

为什么必须字节级透传:对端用「原始 query string + 原始 body」验淘宝签名 + * (h5/lib/taobao-auth.js:16-29)。Java 一旦重新序列化 JSON 或重排 query, + * 签名字节就变了,session 会直接 401。所以这里不读 body 语义、不动 JSON。 + * + *

/ping、/whoami、/echo 仍然由 {@link MvpController} 自己处理(更具体的映射优先)。 + */ +@RestController +@ConditionalOnProperty(name = "taobao.mode", havingValue = "forward") +public class TaobaoForwardController { + + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3); + + /** 云网关单请求 10 秒上限,留出余量。 */ + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(9); + + private final HttpClient client = HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + + private final String forwardBase; + + public TaobaoForwardController(@Value("${taobao.forward-base}") String forwardBase) { + this.forwardBase = forwardBase.endsWith("/") + ? forwardBase.substring(0, forwardBase.length() - 1) + : forwardBase; + } + + @RequestMapping("/api/taobao/{*path}") + public ResponseEntity forward(HttpServletRequest request) { + byte[] body = readBody(request); + String target = forwardBase + request.getRequestURI() + + (request.getQueryString() == null ? "" : "?" + request.getQueryString()); + + HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(target)) + .timeout(REQUEST_TIMEOUT) + .method(request.getMethod(), body.length == 0 + ? HttpRequest.BodyPublishers.noBody() + : HttpRequest.BodyPublishers.ofByteArray(body)); + copyHeader(request, builder, "Content-Type"); + copyHeader(request, builder, "Authorization"); + + HttpResponse response; + try { + response = client.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray()); + } catch (IOException e) { + return envelope(502, "测肤服务连接失败"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return envelope(502, "测肤服务被中断"); + } + + // 对端正常返回时已经是客户端要的信封(h5/lib/taobao-routes.js:8),原样回传。 + if (looksLikeEnvelope(response.body())) { + return ResponseEntity.status(response.statusCode()) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8") + .header(HttpHeaders.CACHE_CONTROL, "no-store") + .body(response.body()); + } + // 对端没部署 / 返回 404 页面 / 网关错误:包成统一信封,避免小程序 JSON.parse 失败。 + return envelope(502, "测肤服务暂不可用(上游 " + response.statusCode() + ")"); + } + + private void copyHeader(HttpServletRequest request, HttpRequest.Builder builder, String name) { + String value = request.getHeader(name); + if (value != null && !value.isBlank()) { + builder.header(name, value); + } + } + + private static byte[] readBody(HttpServletRequest request) { + try { + return request.getInputStream().readAllBytes(); + } catch (IOException e) { + return new byte[0]; + } + } + + private static boolean looksLikeEnvelope(byte[] body) { + if (body == null || body.length == 0) { + return false; + } + String text = new String(body, StandardCharsets.UTF_8).stripLeading(); + return text.startsWith("{") && text.contains("\"success\""); + } + + private static ResponseEntity envelope(int status, String message) { + String json = "{\"success\":true,\"data\":{\"ok\":false,\"http_status\":" + status + + ",\"message\":\"" + message + "\"}}"; + return ResponseEntity.status(200) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8") + .header(HttpHeaders.CACHE_CONTROL, "no-store") + .body(json.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/backend-java/src/main/java/com/juzi/mvp/api/TaobaoSessionService.java b/backend-java/src/main/java/com/juzi/mvp/api/TaobaoSessionService.java new file mode 100644 index 0000000..22d990f --- /dev/null +++ b/backend-java/src/main/java/com/juzi/mvp/api/TaobaoSessionService.java @@ -0,0 +1,154 @@ +package com.juzi.mvp.api; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.HexFormat; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 淘宝小程序会话:一次性的 challenge + 30 天有效的 Bearer token。 + * + *

参考实现 h5/lib/taobao-session.js 的取值:challenge 60 秒、单次消费、 + * 每来源 30 次/分钟;token 32 字节随机十六进制、有效期 30 天。 + * + *

注意:全部状态都在进程内存里,SAE 只能单副本运行;多副本/发布不丢会话 + * 需要换 Redis 或 RDS。这一点和现有 Node 实现一致,不是新增的退化。 + */ +@Component +public class TaobaoSessionService { + + private static final long CHALLENGE_TTL_SECONDS = 60; + private static final long TOKEN_TTL_SECONDS = 30L * 24 * 60 * 60; + private static final int MAX_OUTSTANDING_CHALLENGES = 1000; + private static final int RATE_LIMIT_PER_MINUTE = 30; + + private final SecureRandom random = new SecureRandom(); + private final Map challenges = new ConcurrentHashMap<>(); + private final Map sessions = new ConcurrentHashMap<>(); + private final Map rates = new ConcurrentHashMap<>(); + + @Value("${taobao.app-key:}") + private String appKey; + + private record Rate(Instant window, int count) { + } + + public record Session(String userId, String openId, Instant expires) { + } + + public record Token(String value, long expiresInSeconds, String userId) { + } + + /** + * 限流计数用的来源标识。 + * + *

云网关后面所有请求的来源 IP 都是同一个代理 IP,按 IP 计数会变成全站共享 + * 30 次/分钟,所以在有 open_id 时优先按用户计数。 + */ + public String rateKey(HttpServletRequest request) { + String openId = request.getParameter("open_id"); + return openId != null && !openId.isBlank() ? "open:" + openId : "ip:" + request.getRemoteAddr(); + } + + /** + * 发放一次性 challenge;超出频率或存量上限时返回 null(调用方回 429)。 + */ + public String challenge(String rateKey) { + sweep(); + Instant now = Instant.now(); + Rate rate = rates.compute(rateKey, (key, current) -> + current == null || !current.window().plusSeconds(CHALLENGE_TTL_SECONDS).isAfter(now) + ? new Rate(now, 1) + : new Rate(current.window(), current.count() + 1)); + if (rate.count() > RATE_LIMIT_PER_MINUTE || challenges.size() >= MAX_OUTSTANDING_CHALLENGES) { + return null; + } + String id = randomHex(24); + challenges.put(id, now.plusSeconds(CHALLENGE_TTL_SECONDS)); + return id; + } + + /** + * 消费 challenge;remove 返回非空即视为抢到,天然只成功一次。 + */ + public boolean consume(String challenge) { + if (challenge == null || challenge.isBlank()) { + return false; + } + Instant expires = challenges.remove(challenge); + return expires != null && expires.isAfter(Instant.now()); + } + + public Token issue(String openId) { + String userId = userId(openId); + String value = randomHex(32); + sessions.put(value, new Session(userId, openId, Instant.now().plusSeconds(TOKEN_TTL_SECONDS))); + return new Token(value, TOKEN_TTL_SECONDS, userId); + } + + public Session find(String token) { + if (token == null || token.isBlank()) { + return null; + } + Session session = sessions.get(token); + if (session == null) { + return null; + } + if (!session.expires().isAfter(Instant.now())) { + sessions.remove(token); + return null; + } + return session; + } + + public void revoke(String token) { + if (token != null) { + sessions.remove(token); + } + } + + /** + * 稳定的用户 id:sha256(app_key + ':' + open_id),与 Node 端 provider_key 口径一致。 + */ + public String userId(String openId) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + String seed = (appKey == null ? "" : appKey) + ":" + openId; + return HexFormat.of().formatHex(digest.digest(seed.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } + + /** 从 Authorization: Bearer xxx 里取 token。 */ + public static String bearerToken(HttpServletRequest request) { + String header = request.getHeader("Authorization"); + if (header == null) { + return null; + } + String prefix = "Bearer "; + return header.regionMatches(true, 0, prefix, 0, prefix.length()) + ? header.substring(prefix.length()).trim() + : null; + } + + private void sweep() { + Instant now = Instant.now(); + challenges.entrySet().removeIf(entry -> !entry.getValue().isAfter(now)); + sessions.entrySet().removeIf(entry -> !entry.getValue().expires().isAfter(now)); + rates.entrySet().removeIf(entry -> !entry.getValue().window().plusSeconds(CHALLENGE_TTL_SECONDS).isAfter(now)); + } + + private String randomHex(int bytes) { + byte[] buffer = new byte[bytes]; + random.nextBytes(buffer); + return HexFormat.of().formatHex(buffer); + } +} 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 index 662b0c5..01b6f3f 100644 --- a/backend-java/src/main/java/com/juzi/mvp/gateway/TaobaoSignatureVerifier.java +++ b/backend-java/src/main/java/com/juzi/mvp/gateway/TaobaoSignatureVerifier.java @@ -90,8 +90,11 @@ public class TaobaoSignatureVerifier { } 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)) { + // 真实网关请求不带 sign_method,缺省等价于 md5: + // MD5(secret + content + secret)。2026-11-01 用抓包参数离线复算确认。 + String rawMethod = params.getOrDefault("sign_method", ""); + String method = rawMethod.isEmpty() ? "md5" : rawMethod; + if (!Set.of("md5", "hmac", "hmac_md5", "hmac_sha256").contains(method)) { return null; } @@ -109,7 +112,7 @@ public class TaobaoSignatureVerifier { 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; + String plain = secret + content + secret; return hex(MessageDigest.getInstance("MD5").digest(plain.getBytes(StandardCharsets.UTF_8))); } diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index e054611..5e34f69 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -4,6 +4,8 @@ server: spring: application: name: taobao-mvp-backend + profiles: + active: local jackson: time-zone: Asia/Shanghai default-property-inclusion: non_null @@ -18,6 +20,10 @@ management: show-details: always taobao: + # forward: /api/taobao/* 业务接口字节级透传给外层 H5 后端(Node),Java 只做网关适配层 + # native: Java 自己实现(challenge/session/me/logout);测肤接口尚未实现 + mode: ${TAOBAO_MODE:native} + forward-base: ${TAOBAO_FORWARD_BASE:https://1me.xajuzi.com/state} # 云端部署时通过 SAE 环境变量注入;本地留空即可。 app-key: ${TAOBAO_APP_KEY:} app-secret: ${TAOBAO_APP_SECRET:} diff --git a/backend-java/src/test/java/com/juzi/mvp/TaobaoAuthFlowTests.java b/backend-java/src/test/java/com/juzi/mvp/TaobaoAuthFlowTests.java new file mode 100644 index 0000000..2c7e41f --- /dev/null +++ b/backend-java/src/test/java/com/juzi/mvp/TaobaoAuthFlowTests.java @@ -0,0 +1,139 @@ +package com.juzi.mvp; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 登录链路(challenge → session → me → logout)的端到端契约测试。 + * + *

本地/测试环境 verify-sign=false,所以用 ?open_id= 模拟云网关注入的身份; + * 打开 verify-sign 后,同样的请求需要带正确的 sign。 + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + // 部署用的 application-local.yml 里写的是 mode: forward(生产走透传)。 + // 这个用例测的是 Java 自己实现登录的那条路,所以显式钉住 native。 + properties = "taobao.mode=native") +class TaobaoAuthFlowTests { + + private static final String GATEWAY_QUERY = + "?app_key=35486228&open_id=AAG8-test-open-id&sign_method=hmac_sha256"; + + @LocalServerPort + private int port; + + @Autowired + private TestRestTemplate rest; + + private String url(String path) { + return "http://127.0.0.1:" + port + path; + } + + private HttpEntity json(String body) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + return new HttpEntity<>(body, headers); + } + + private HttpEntity json(String body, String token) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + if (token != null) { + headers.set("Authorization", "Bearer " + token); + } + return new HttpEntity<>(body, headers); + } + + @SuppressWarnings("unchecked") + private Map data(ResponseEntity response) { + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsEntry("success", true); + return (Map) response.getBody().get("data"); + } + + @SuppressWarnings("unchecked") + private String login() { + Map challenge = data(rest.postForEntity( + url("/api/taobao/challenge" + GATEWAY_QUERY), json("{}"), Map.class)); + assertThat(challenge).containsEntry("ok", true).containsEntry("http_status", 200); + + String challengeId = (String) challenge.get("challenge"); + assertThat(challengeId).hasSize(48); + + Map session = data(rest.postForEntity( + url("/api/taobao/session" + GATEWAY_QUERY), + json("{\"consent\":true,\"challenge\":\"" + challengeId + "\"}"), + Map.class)); + assertThat(session).containsEntry("ok", true).containsEntry("http_status", 200); + assertThat((String) session.get("token")).hasSize(64); + assertThat(((Number) session.get("expires_in")).longValue()).isEqualTo(2592000L); + + Map user = (Map) session.get("user"); + assertThat(user).containsEntry("kind", "taobao"); + assertThat((String) user.get("id")).hasSize(64); + return (String) session.get("token"); + } + + @Test + @SuppressWarnings("unchecked") + void challengeSessionMeLogoutRoundTrip() { + String token = login(); + + Map me = data(rest.postForEntity( + url("/api/taobao/me"), json("{}", token), Map.class)); + assertThat(me).containsEntry("ok", true); + assertThat(((Map) me.get("user"))).containsEntry("kind", "taobao"); + + assertThat(data(rest.postForEntity( + url("/api/taobao/logout"), json("{}", token), Map.class))).containsEntry("ok", true); + + Map afterLogout = data(rest.postForEntity( + url("/api/taobao/me"), json("{}", token), Map.class)); + assertThat(afterLogout).containsEntry("ok", false).containsEntry("http_status", 401); + } + + @Test + void sessionRejectsMissingConsent() { + Map challenge = data(rest.postForEntity( + url("/api/taobao/challenge" + GATEWAY_QUERY), json("{}"), Map.class)); + Map session = data(rest.postForEntity( + url("/api/taobao/session" + GATEWAY_QUERY), + json("{\"consent\":false,\"challenge\":\"" + challenge.get("challenge") + "\"}"), + Map.class)); + assertThat(session).containsEntry("ok", false).containsEntry("http_status", 400); + assertThat(session.get("message")).isEqualTo("请先阅读并同意隐私协议"); + } + + @Test + void challengeCannotBeReused() { + Map challenge = data(rest.postForEntity( + url("/api/taobao/challenge" + GATEWAY_QUERY), json("{}"), Map.class)); + String body = "{\"consent\":true,\"challenge\":\"" + challenge.get("challenge") + "\"}"; + + assertThat(data(rest.postForEntity(url("/api/taobao/session" + GATEWAY_QUERY), json(body), Map.class))) + .containsEntry("ok", true); + Map replay = data(rest.postForEntity( + url("/api/taobao/session" + GATEWAY_QUERY), json(body), Map.class)); + assertThat(replay).containsEntry("ok", false).containsEntry("http_status", 401); + } + + @Test + void meWithoutTokenIsUnauthorized() { + Map me = data(rest.postForEntity( + url("/api/taobao/me"), json("{}"), Map.class)); + assertThat(me).containsEntry("ok", false).containsEntry("http_status", 401); + assertThat(me.get("message")).isEqualTo("检测会话已失效,请重新登录"); + } +} diff --git a/backend-java/src/test/java/com/juzi/mvp/TaobaoForwardTests.java b/backend-java/src/test/java/com/juzi/mvp/TaobaoForwardTests.java new file mode 100644 index 0000000..bf43637 --- /dev/null +++ b/backend-java/src/test/java/com/juzi/mvp/TaobaoForwardTests.java @@ -0,0 +1,123 @@ +package com.juzi.mvp; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * taobao.mode=forward 时,Java 必须把请求「字节级」转发给外层 H5 后端: + * 原始 query string、原始 body、Authorization 都要原样过去,回来的信封也要原样返回。 + * + *

用一个本地 stub 冒充 Node 后端来验证这件事。 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class TaobaoForwardTests { + + private static final HttpServer STUB; + private static volatile String lastQuery; + private static volatile String lastBody; + private static volatile String lastAuthorization; + private static volatile String lastPath; + + static { + try { + STUB = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + STUB.createContext("/api/taobao/", exchange -> { + lastPath = exchange.getRequestURI().getPath(); + lastQuery = exchange.getRequestURI().getRawQuery(); + lastAuthorization = exchange.getRequestHeaders().getFirst("Authorization"); + try (InputStream in = exchange.getRequestBody()) { + lastBody = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + byte[] payload = ("{\"success\":true,\"data\":{\"ok\":true,\"http_status\":200," + + "\"echo_path\":\"" + lastPath + "\"," + + "\"echo_query\":\"" + lastQuery + "\"," + + "\"echo_body\":" + (lastBody.isEmpty() ? "\"\"" : lastBody) + "}}") + .getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json;charset=utf-8"); + exchange.sendResponseHeaders(200, payload.length); + exchange.getResponseBody().write(payload); + exchange.close(); + }); + STUB.start(); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + @DynamicPropertySource + static void properties(DynamicPropertyRegistry registry) { + registry.add("taobao.mode", () -> "forward"); + registry.add("taobao.forward-base", () -> "http://127.0.0.1:" + STUB.getAddress().getPort()); + } + + @AfterAll + static void stopStub() { + STUB.stop(0); + } + + @LocalServerPort + private int port; + + @Autowired + private TestRestTemplate rest; + + private String url(String path) { + return "http://127.0.0.1:" + port + path; + } + + @Test + @SuppressWarnings("unchecked") + void forwardsBodyQueryAndAuthorizationVerbatim() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", "Bearer abc123"); + // 故意带空格和不常见字段顺序:任何重新序列化都会改变这里的字节 + String body = "{\"consent\":true, \"challenge\":\"x-1\", \"note\":\"带空格\"}"; + + ResponseEntity response = rest.postForEntity( + url("/api/taobao/session?app_key=35486228&open_id=op-x&sign_method=hmac_sha256&sign=ABC"), + new HttpEntity<>(body, headers), + Map.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsEntry("success", true); + + assertThat(lastPath).isEqualTo("/api/taobao/session"); + assertThat(lastQuery).isEqualTo("app_key=35486228&open_id=op-x&sign_method=hmac_sha256&sign=ABC"); + assertThat(lastBody).isEqualTo(body); + assertThat(lastAuthorization).isEqualTo("Bearer abc123"); + + Map data = (Map) response.getBody().get("data"); + assertThat(data).containsEntry("ok", true).containsEntry("echo_path", "/api/taobao/session"); + } + + @Test + void localDiagnosticsEndpointsStayLocal() { + ResponseEntity ping = rest.postForEntity(url("/api/taobao/ping"), Map.of(), Map.class); + assertThat(ping.getStatusCode()).isEqualTo(HttpStatus.OK); + @SuppressWarnings("unchecked") + Map data = (Map) ping.getBody().get("data"); + assertThat(data).containsEntry("server", "java"); + assertThat(lastPath).isNotEqualTo("/api/taobao/ping"); + } +} 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 index f1cc81c..51d189f 100644 --- a/backend-java/src/test/java/com/juzi/mvp/gateway/TaobaoSignatureVerifierTest.java +++ b/backend-java/src/test/java/com/juzi/mvp/gateway/TaobaoSignatureVerifierTest.java @@ -23,4 +23,18 @@ class TaobaoSignatureVerifierTest { assertThat(TaobaoSignatureVerifier.sign(params, body, "secret")) .isEqualTo("E673310E672C9EB7E918857C6C64D247"); } + + @Test + void missingSignMethodUsesTheMd5Form() throws Exception { + // 真实网关请求不带 sign_method(2026-11-01 抓包确认),缺省等价于 md5(secret+content+secret)。 + Map params = new TreeMap<>(); + params.put("app_key", "app"); + params.put("open_id", "open-account"); + params.put("env", "online"); + + String body = "{\"consent\":true,\"challenge\":\"nonce\"}"; + + assertThat(TaobaoSignatureVerifier.sign(params, body, "secret")) + .isEqualTo("DD2B95886A5E8F61D6111A5659E4FBD5"); + } } diff --git a/docs/HANDOFF-2026-11-01.md b/docs/HANDOFF-2026-11-01.md new file mode 100644 index 0000000..60a902d --- /dev/null +++ b/docs/HANDOFF-2026-11-01.md @@ -0,0 +1,273 @@ +# 交接文档:淘宝小程序 + 聚石塔 SAE + Java MVP + +> 日期:2026-11-01 凌晨 +> 给下一个 agent:先读「一句话状态」和「明天先做什么」,再按需读后面。 + +## 一句话状态 + +**云端链路已经打通**:开放平台服务测试 `POST /api/taobao/ping` 返回: + +```json +{ + "success": true, + "data": { + "server": "java", + "signMessage": "verify-sign=false, skipped (local/direct mode)", + "appKey": "35486228", + "transport": "gateway", + "ok": true, + "pong": true, + "env": "test", + "signVerified": false + }, + "errorCode": null, + "errorMessage": null +} +``` + +SAE `nnn` 上跑的是 `0.0.2` JAR,SAE 环境变量已清空,JAR 内置 `verify-sign:false`,所以 `signVerified:false` 是**预期**。 +**还没做** IDE 真机预览;**签名校验尚未启用**(之前的 `signature mismatch` 未解决,先绕过)。 + +## 明天先做什么 + +1. **IDE 真机预览**,验证小程序 → 云网关 → 聚石塔云应用 → 私网 CLB → SAE 整条链路(第 5.1 节)。 +2. 真机通过后,再决定是否开签名:重置 35486228 的 AppSecret → 更新 `application-local.yml` → `verify-sign:true` → 打 `0.0.3` 上传(第 5.3 节)。 +3. 最后提交这轮未提交的代码(**不要提交 `application-local.yml`**)并 push。 + +## 当前链路 + +```text +淘宝小程序(taobao/) + → @tbmp/mp-cloud-sdk(cloudEnv: test) + → 淘宝云网关(AppKey 35486228) + → 聚石塔云应用 1me-net(ID 62829,默认云应用) + → 私网 CLB lb-k2j324dwux1svaf2akwqx / 192.168.65.185:80 + → SAE 应用 nnn(namespace cn-zhangjiakou:my-namespace) + → Java Spring Boot 0.0.2,容器 8080 +``` + +## 关键资源 / ID + +| 资源 | 值 | +|---|---| +| 开放平台应用(当前正确) | `1me 肌肤状态检测`,AppKey **35486228** | +| 旧应用(不再使用) | `1me肌肤检测`,AppKey 35479221;旧 Secret 不要再用 | +| 聚石塔云应用 | `1me-net`,ID **62829**,默认应用;生产/测试 SLB 都是 `192.168.65.185:80` | +| 聚石塔云应用(未用) | `app-new`,ID 62861;没接 SAE/CLB,别用 | +| SAE 应用 | `nnn`,ID `7c9f2d2a-95d0-4879-afad-323d30f85d30` | +| SAE 命名空间 | `cn-zhangjiakou:my-namespace` | +| VPC | `vpc-8vb5mcvenp8jwa1roqcsa`(`JST_cn-zhangjiakou_vpc-new`) | +| vSwitch | `vsw-8vbna5zb2rq03gaeiymch`(可用区 A) | +| 安全组 | `sg-8vbh8c96h0kotpu7y26r`(“聚石塔官方创建ssh”),需放行 8080 | +| 实例规格 | 1 Core 2 GiB | +| 代码包 | `taobao-mvp-backend-0.0.2.jar`,版本号 `1793466846250` | +| SAE 环境变量 | **已清空**(重要,见“不要做什么”) | + +## 已验证 / 未验证 + +### 已验证 + +- 后端本地:`mvn test` 4/4(0.0.1 时;版本改 0.0.2 后只 `-DskipTests` 打包,未重跑)。 +- 前端:`npm test` 17/17;`taobao/scripts/mvp-local-check.cjs` 本地直连 Java 通过。 +- JAR 本地启动:`/actuator/health` UP,`/api/taobao/ping` 正常。 +- SAE `nnn` 部署 `0.0.2` 成功,服务测试返回上面的 JSON。 +- `signVerified:false` 是预期;`signMessage` 是固定文案,不代表直连;`transport:"gateway"` 证明走了网关。 + +### 未验证 + +- IDE **真机预览**(云应用 SDK 模拟器不支持,必须真机)。 +- 开启签名后的 `signVerified:true`。 +- 真实测肤接口(challenge/session/photo/skin-analyze/analysis/reports/logout)仍在 Node,Java 只有 `ping/whoami/echo`。 + +## 代码 / 配置状态 + +### Git + +- 已推送:`ac0b7ca feat(sae): add Java MVP backend and Taobao connectivity page`(origin/main 同步)。 +- 工作区未提交改动(11 个文件,`git diff --stat`): + - `.gitignore`(忽略 `application-local.yml`) + - `README.md`(交接文档入口) + - `backend-java/README.md` + - `backend-java/pom.xml`(version `0.0.2`) + - `backend-java/src/main/resources/application.yml`(`spring.profiles.active: local`) + - `docs/SAE-Java-MVP-操作步骤.md`(0.0.2 + 4.3.1 更新 JAR) + - `taobao/config.js`(云端 test) + - `taobao/pages/mvp/mvp.js`(显示 cloudType) + - `taobao/pages/splash/splash.js`(云端 test 也显示 MVP 入口) + - `taobao/test/transport.test.cjs`(tower 测试) + - `taobao/utils/transport.js`(tower 模式) + +**`application-local.yml` 在 `.gitignore` 里,未跟踪,不要提交。** 它包含 AppSecret;JAR 里也包含,临时方案。 + +### 后端 + +- `backend-java/`,Spring Boot 3.5.6 / Java 17。 +- `application.yml`:`spring.profiles.active: local`。 +- `application-local.yml`: + - `taobao.app-key: "35486228"` + - `taobao.app-secret: <当前 Secret>` + - `taobao.verify-sign: false` +- JAR:`backend-java/target/taobao-mvp-backend-0.0.2.jar`(23.6MB),包含 `BOOT-INF/classes/application-local.yml`。 +- 云端已上传该 JAR;SAE 环境变量已清空(否则覆盖 JAR 配置)。 +- 接口:`GET/POST /api/taobao/ping`、`POST /api/taobao/whoami`、`POST /api/taobao/echo`、`GET /actuator/health`。 +- 签名校验:`TaobaoSignatureVerifier`,算法与 `h5/lib/taobao-auth.js` 一致;`verify-sign:false` 时跳过。 + +### 前端 + +- `taobao/config.js` 当前是云端 test: + - `environment: 'development'` + - `transport: 'cloud'` + - `cloudType: 'tower'` + - `cloudEnv: 'test'` + - `cloudAppId: ''`(默认云应用;若 SDK 报默认不存在,填 `62829`) + - `mvpLocal: false` + - `baseUrl: 'https://miniappcloud.taobao.com'`(tower 模式仅占位,不参与请求) + - `assetBase: 'http://127.0.0.1:18180/assets/'`(真机上底图可能加载失败,不影响 MVP 入口) +- `taobao/utils/transport.js`:`cloudType === 'tower'` 时不传 `domain`、不要求 HTTPS、`cloudAppId` 可空;`cloudType === 'empty'` 保持旧行为(空应用/外部服务)。 +- `taobao/pages/mvp/mvp`:MVP 页面;`mvpLocal` 时可强制直连 8080,云端时走 transport cloud。 +- `taobao/pages/splash/splash.js`:`showMvpDebug = mvpLocal || (transport === 'cloud' && environment !== 'production')`;当前 development+cloud,入口显示。 +- `taobao/app.json` 已注册 `pages/mvp/mvp`。 +- `taobao/scripts/mvp-local-check.cjs`:本地直连契约测试(独立配置,不依赖 `config.js`)。 +- `taobao/test/transport.test.cjs`:17 个测试含 tower 模式。 + +### 工具 / 环境 + +- JDK 17:`D:\juzi\tools\jdk17\jdk-17.0.21.0.2+1` +- Maven 3.9.9:`D:\juzi\tools\maven\apache-maven-3.9.9` +- Node 22.23.2:`C:\Program Files\nodejs` +- PowerShell 执行策略 Restricted;先: + +```powershell +Set-ExecutionPolicy -Scope Process Bypass +. D:\juzi\tools\env-java.ps1 +``` + +- Taobao IDE 的 `wmic` 兼容 shim:`C:\Users\youmi\.local\bin\wmic.cmd`;重建脚本 `D:\juzi\tools\fix-taobao-ide-wmic.ps1`。IDE 已能打开。 + +## 5. 明天详细步骤 + +### 5.1 IDE 真机预览(第一优先级) + +1. 确认 IDE 当前小程序是 **35486228**(`1me 肌肤状态检测`),不是 35479221。 +2. 重新编译。 +3. 点顶部「预览」,手机淘宝扫码(**不要用模拟器**;`cloud.application.httpRequest` 模拟器不支持)。 +4. 启动页右上角点「SAE MVP 连通性」。 +5. 页面自动调 `/api/taobao/ping`,预期: + - `transport: "gateway"` + - `server: "java"` + - `signVerified: false`(当前 JAR 配置) + - `appKey: "35486228"` +6. 如果成功,MVP 链路验收完成;记录截图。 + +### 5.2 真机常见错误 + +| 现象 | 排查 | +|---|---| +| `cloud init failed` | IDE 当前 App 不是 35486228;云应用没设默认;`cloudEnv` 不是 test | +| `cloudAppId` 默认不存在 | `taobao/config.js` 的 `cloudAppId` 填 `62829` | +| `Cannot find module '@tbmp/mp-cloud-sdk'` | `npm.cmd ci`,IDE 里「构建 npm」 | +| 模拟器报 SDK 不支持 | 正常,改用真机预览 | +| `请先配置淘宝云应用和 HTTPS 服务地址` | `cloudType` 不是 tower 或 transport 不是 cloud | +| 真机底图不显示 | `assetBase` 是 127.0.0.1,正常;MVP 入口仍可点 | +| `SIGN_INVALID` | 只会在 `verify-sign:true` 时出现;当前 false,不应出现 | + +### 5.3 开签名(真机通过后再做) + +1. 开放平台打开 **35486228** 的应用,重置 AppSecret,复制新值。 +2. 更新 `backend-java/src/main/resources/application-local.yml`: + - `app-secret: 新值` + - `verify-sign: true` +3. 改 `pom.xml` 版本为 `0.0.3`,重新打包: + +```powershell +Set-ExecutionPolicy -Scope Process Bypass +. D:\juzi\tools\env-java.ps1 +cd D:\juzi\backend-java +mvn clean -DskipTests package +``` + +4. 上传 `target\taobao-mvp-backend-0.0.3.jar` 到 SAE `nnn`,部署。 +5. 服务测试 `POST /api/taobao/ping`,期望 `appKey:35486228`、`signVerified:true`。 +6. 如果仍 `signature mismatch`: + - 确认 SAE 没有残留 `TAOBAO_*` 环境变量(它们会覆盖 JAR); + - 确认 Secret 是 35486228 的当前值,没有空格/换行; + - 点服务测试「请求详情」,核对 `sign_method`、URL query、body; + - 需要时按 `docs/SAE-Java-MVP-操作步骤.md` 第 6 节算法手工复算(仓库里目前没有 `check-gateway-sign.cjs`,之前回答提到过但未创建)。 + +### 5.4 提交代码 + +真机通过后: + +- 提交这 11 个改动(`pom.xml` 0.0.2、config、transport、splash、mvp、test、docs、两个 README、.gitignore、application.yml),外加本文档 `docs/HANDOFF-2026-11-01.md`。 +- **不要** `git add application-local.yml`;它在 `.gitignore` 里。 +- push 到 `origin/main`;远端证书已续期(notAfter 2027-01-29),凭据有效。 + +## 6. 不要做什么 / 已知坑 + +1. **不要再创建“云托管/新建云应用”**:那是云应用套件,会新建 CPU/容器并要求开通「容器镜像服务个人版」,和现有 SAE `nnn` 不是一回事。用户已提工单确认过。 +2. **不要创建聚石塔容器集群/ECS**:入淘引导的「创建集群」是旧经典流程;SAE 不需要。 +3. **不要用 `app-new` 62861**:没接 SAE/CLB;用 `1me-net` 62829。 +4. **不要用 35479221 的 AppSecret**:当前是 35486228;旧 Secret 不通用。 +5. **不要再加 `TAOBAO_VERIFY_SIGN=true` 环境变量**:会覆盖 JAR 的 `verify-sign:false`,回到 `SIGN_INVALID`。要么全删环境变量用 JAR,要么三者都配且 Secret 正确。 +6. **不要在模拟器测云应用**:必须真机预览。 +7. **不要把 AppSecret 写进小程序代码、git、聊天**:`application-local.yml` 已 gitignore;JAR 里带 Secret 只是临时验证,验证完重置。 +8. **主应用其他页面现在不能云测**:Java 只有 ping/whoami/echo;challenge/session/photo/skin-analyze 等还在 Node。只测 MVP 页。 +9. **`signMessage:"verify-sign=false..."` 不是直连**:固定文案;看 `transport`。 +10. **SAE 应用 `nnn` 的“更多”里没有部署入口时**:用完整 SAE 控制台 `https://saenext.console.aliyun.com/cn-zhangjiakou/app-list/micro`,或应用列表行 `nnn` 的 ⋮;上传走「部署应用 → 代码包部署」。 +11. **云托管入淘引导的「关联云应用」灰按钮**:不要按它去创建集群;已经通过「云应用列表 → 关联云应用」关联成功(1me-net 默认)。 +12. **`new-new` SAE 应用/自动 VPC**:之前失败的应用,未使用;可删,别拿它当后端。 +13. **`application-local.yml` 是临时方案**:正式环境改回环境变量/保密字典,重置 Secret;JAR 不要外发。 + +## 7. 常用命令 + +```powershell +# 环境 +Set-ExecutionPolicy -Scope Process Bypass +. D:\juzi\tools\env-java.ps1 + +# 后端构建 +cd D:\juzi\backend-java +mvn clean -DskipTests package + +# 本地启动 +java -jar target\taobao-mvp-backend-0.0.2.jar + +# 本地健康/接口 +curl.exe http://127.0.0.1:8080/actuator/health +curl.exe -X POST -H "Content-Type: application/json" -d "{}" http://127.0.0.1:8080/api/taobao/ping + +# 前端测试 +cd D:\juzi\taobao +npm.cmd test +node scripts\mvp-local-check.cjs +``` + +## 8. 下一阶段(MVP 验收后) + +1. 把 `h5/lib/taobao-routes.js` 的接口迁到 Java: + - `POST /api/taobao/challenge` + - `POST /api/taobao/session` + - `POST /api/taobao/me` + - `POST /api/taobao/photo`(64KiB 分片) + - `POST /api/taobao/skin-analyze`(异步任务) + - `POST /api/taobao/analysis`(轮询) + - `POST /api/taobao/reports` + - `POST /api/taobao/logout` +2. 所有 `/api/taobao/**` 统一信封 + 签名;128KB/10s 限制。 +3. 账号/历史/图片存 RDS/OSS,不放容器本地盘。 +4. 保留 H5 Node 路由不变,Java 只接淘宝;以现有 Node 实现为契约。 +5. 接 SLS 日志、Actuator 健康检查、告警,再切正式流量。 + +## 9. 交接检查清单 + +- [ ] 真机预览 `transport:"gateway"`、`server:"java"` +- [ ] 记录真机截图/返回 +- [ ] 决定是否开签名;若开,重置 Secret、0.0.3、`signVerified:true` +- [ ] 提交 10 个改动,push +- [ ] 清理 `new-new`/`app-new`/旧 35479221(确认无用后) +- [ ] 重置 AppSecret,移除 JAR 内 Secret,改回环境变量/保密字典 +- [ ] 开始迁移真实接口 + +--- + +**给下一个 agent 的第一句话**:当前服务测试已通过(`transport:"gateway"`、`server:"java"`、`signVerified:false`),先做 IDE 真机预览;不要开签名、不要创建云托管/集群,先读第 6 节“不要做什么”。 diff --git a/docs/SAE-Java-MVP-操作步骤.md b/docs/SAE-Java-MVP-操作步骤.md index 292d68c..bc21e79 100644 --- a/docs/SAE-Java-MVP-操作步骤.md +++ b/docs/SAE-Java-MVP-操作步骤.md @@ -69,7 +69,7 @@ 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 +java -jar target\taobao-mvp-backend-0.0.2.jar ``` 第一次 `mvn package` 会下载 Spring Boot 依赖,耐心等几分钟。看到类似日志即成功: @@ -210,7 +210,7 @@ mvn -DskipTests clean package 产物: ```text -D:\juzi\backend-java\target\taobao-mvp-backend-0.0.1-SNAPSHOT.jar +D:\juzi\backend-java\target\taobao-mvp-backend-0.0.2.jar ``` ### 4.3 SAE 创建应用 @@ -256,6 +256,28 @@ TAOBAO_VERIFY_SIGN=true -Xms512m -Xmx1024m -Duser.timezone=Asia/Shanghai ``` +### 4.3.1 更新代码包(重新上传 JAR) + +应用创建之后,更新 JAR 的入口: + +1. SAE 应用 `nnn` → 右上角「部署应用」/「变更」→「代码包部署」→ 上传新 JAR → 确定 → 部署。 +2. 如果右上角没有,左侧「变更记录」→「创建变更」→ 代码包部署。 +3. 聚石塔资源视图偶尔不显示部署按钮,可直接用完整 SAE 控制台: + +```text +https://saenext.console.aliyun.com/cn-zhangjiakou/app-list/micro +``` + +选 `my-namespace` → `nnn` → 部署应用。 + +每次上传的 JAR 文件名/版本要不同,SAE 才会当成一个新版本。`pom.xml` 的 `` 已改成 `0.0.2`,产物是: + +```text +taobao-mvp-backend-0.0.2.jar +``` + +SAE 部署页的「版本」可以填 `0.0.2` 或 `20261101-2`。不要重复上传同名同版本的包。 + ### 4.4 流量接入 - 云应用走**内网私网 SLB**:聚石塔“流量接入”里绑定私网 SLB,后端端口 8080。 @@ -431,7 +453,7 @@ Set-ExecutionPolicy -Scope Process Bypass # 后端 cd D:\juzi\backend-java mvn -DskipTests clean package -java -jar target\taobao-mvp-backend-0.0.1-SNAPSHOT.jar +java -jar target\taobao-mvp-backend-0.0.2.jar # 本地验证 curl.exe http://127.0.0.1:8080/actuator/health