feat(sae): add cloud-gateway adapters and forward mode (0.0.4)

SAE 上这一版只做云网关适配层:/api/taobao/* 字节级透传给 1me.xajuzi.com 上
现有的 H5 后端(旷视调用、评分、报告都留在那边),Java 不再重写测肤逻辑。

- TaobaoEnvelope:客户端要求的 data.ok + data.http_status 信封(HTTP 恒 200)
- TaobaoAuthController:challenge/session/me/logout(native 模式)
- TaobaoSessionService:一次性 challenge + 30 天 Bearer token,按 open_id 限流
  (网关后面所有请求同源 IP,按 IP 计数会变成全站共享 30 次/分钟)
- TaobaoForwardController:mode=forward 时透传,保留原始 query 与 body 字节
  (签名覆盖原始 body,重新序列化会让验签失败)
- TaobaoSignatureVerifier:缺省 sign_method 按 md5 校验
- 打包名固定为 taobao-mvp-backend-0.0.4-forward,SAE 不需要任何环境变量
- pom 版本 0.0.4;mvn test 11/11(新增 8 个用例)
- docs:更新 SAE 操作步骤,新增本次交接文档
This commit is contained in:
xsl
2026-11-01 23:30:35 +08:00
parent 65ba3cc7a5
commit f5c416dd9d
15 changed files with 1021 additions and 8 deletions
+1 -1
View File
@@ -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
```
启动后:
+3 -1
View File
@@ -13,7 +13,7 @@
<groupId>com.juzi</groupId>
<artifactId>taobao-mvp-backend</artifactId>
<version>0.0.1-SNAPSHOT</version>
<version>0.0.4</version>
<name>taobao-mvp-backend</name>
<description>Taobao mini program + SAE + cloud gateway minimal MVP backend</description>
@@ -38,6 +38,8 @@
</dependencies>
<build>
<!-- 文件名带 forward 标识,避免和 0.0.2(native 模式)混淆 -->
<finalName>taobao-mvp-backend-0.0.4-forward</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
@@ -0,0 +1,123 @@
package com.juzi.mvp.api;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.juzi.mvp.gateway.TaobaoSignatureVerifier;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 淘宝小程序登录相关的四个接口:challenge / session / me / logout。
*
* <p>契约来自参考实现 h5/lib/taobao-routes.js 与客户端 taobao/utils/api.js:
* <ul>
* <li>HTTP 永远 200,逻辑状态放 data.http_status(见 {@link TaobaoEnvelope})。</li>
* <li>客户端不发身份,身份来自云网关注入的 query 参数(app_key / open_id / sign)。</li>
* <li>session 必须带 consent=true 和一次性的 challenge。</li>
* <li>登录后所有接口用 Authorization: Bearer &lt;token&gt;。</li>
* </ul>
*/
@RestController
@RequestMapping("/api/taobao")
@ConditionalOnProperty(name = "taobao.mode", havingValue = "native", matchIfMissing = true)
public class TaobaoAuthController {
private static final String OPEN_ID_PATTERN = "[A-Za-z0-9_-]{4,256}";
private static final String IDENTITY_FAILED = "淘宝身份校验失败,请重新授权登录";
private static final String SESSION_EXPIRED = "检测会话已失效,请重新登录";
private final TaobaoSignatureVerifier verifier;
private final TaobaoSessionService sessions;
private final ObjectMapper objectMapper;
public TaobaoAuthController(TaobaoSignatureVerifier verifier,
TaobaoSessionService sessions,
ObjectMapper objectMapper) {
this.verifier = verifier;
this.sessions = sessions;
this.objectMapper = objectMapper;
}
@PostMapping(value = "/challenge", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ApiResponse<Map<String, Object>> challenge(HttpServletRequest request) {
String id = sessions.challenge(sessions.rateKey(request));
if (id == null) {
return ApiResponse.ok(TaobaoEnvelope.fail(429, "操作频繁,请稍后重试"));
}
Map<String, Object> data = TaobaoEnvelope.ok(200);
data.put("challenge", id);
return ApiResponse.ok(data);
}
@PostMapping(value = "/session", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ApiResponse<Map<String, Object>> session(@RequestBody(required = false) String rawBody,
HttpServletRequest request) {
Map<String, Object> body = parseBody(rawBody);
if (!Boolean.TRUE.equals(body.get("consent"))) {
return ApiResponse.ok(TaobaoEnvelope.fail(400, "请先阅读并同意隐私协议"));
}
TaobaoSignatureVerifier.Result check = verifier.verify(request, rawBody);
if (!check.accepted()) {
return ApiResponse.ok(TaobaoEnvelope.fail(401, IDENTITY_FAILED));
}
String openId = request.getParameter("open_id");
if (openId == null || !openId.matches(OPEN_ID_PATTERN)) {
// 只有经由云网关的请求才带 open_id;本地直连需要自己拼 ?open_id=...
return ApiResponse.ok(TaobaoEnvelope.fail(401, "未取得淘宝身份,请在手机淘宝内重新打开"));
}
Object challenge = body.get("challenge");
if (!(challenge instanceof String challengeId) || !sessions.consume(challengeId)) {
return ApiResponse.ok(TaobaoEnvelope.fail(401, IDENTITY_FAILED));
}
TaobaoSessionService.Token token = sessions.issue(openId);
Map<String, Object> data = TaobaoEnvelope.ok(200);
data.put("token", token.value());
data.put("expires_in", token.expiresInSeconds());
data.put("user", user(token.userId()));
return ApiResponse.ok(data);
}
@PostMapping(value = "/me", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ApiResponse<Map<String, Object>> me(HttpServletRequest request) {
TaobaoSessionService.Session session = sessions.find(TaobaoSessionService.bearerToken(request));
if (session == null) {
return ApiResponse.ok(TaobaoEnvelope.fail(401, SESSION_EXPIRED));
}
Map<String, Object> data = TaobaoEnvelope.ok(200);
data.put("user", user(session.userId()));
return ApiResponse.ok(data);
}
@PostMapping(value = "/logout", consumes = MediaType.ALL_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ApiResponse<Map<String, Object>> logout(HttpServletRequest request) {
sessions.revoke(TaobaoSessionService.bearerToken(request));
return ApiResponse.ok(TaobaoEnvelope.ok(200));
}
private Map<String, Object> user(String userId) {
Map<String, Object> user = new LinkedHashMap<>();
user.put("id", userId);
user.put("kind", "taobao");
return user;
}
private Map<String, Object> parseBody(String raw) {
if (raw == null || raw.isBlank()) {
return Map.of();
}
try {
return objectMapper.readValue(raw, new TypeReference<Map<String, Object>>() {
});
} catch (Exception e) {
return Map.of();
}
}
}
@@ -0,0 +1,33 @@
package com.juzi.mvp.api;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 淘宝小程序客户端读取的 data 结构约定。
*
* <p>客户端(taobao/utils/api.js:13-16)只认 data.ok 和 data.http_status:
* HTTP 状态码永远是 200,逻辑状态(202/400/401/404/409/413/429)放在 data.http_status 里。
* 失败时 message 就是 toast 文案,status 401 还会触发客户端自动登出。
* 这与参考实现 h5/lib/taobao-routes.js:8 保持一致。
*/
public final class TaobaoEnvelope {
private TaobaoEnvelope() {
}
public static Map<String, Object> ok(int httpStatus) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("ok", true);
data.put("http_status", httpStatus);
return data;
}
public static Map<String, Object> fail(int httpStatus, String message) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("ok", false);
data.put("http_status", httpStatus);
data.put("message", message);
return data;
}
}
@@ -0,0 +1,117 @@
package com.juzi.mvp.api;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
/**
* taobao.mode=forward 时启用:把 /api/taobao/* 业务接口字节级转发给外层 H5 后端(Node)。
*
* <p>为什么必须字节级透传:对端用「原始 query string + 原始 body」验淘宝签名
* (h5/lib/taobao-auth.js:16-29)。Java 一旦重新序列化 JSON 或重排 query,
* 签名字节就变了,session 会直接 401。所以这里不读 body 语义、不动 JSON。
*
* <p>/ping、/whoami、/echo 仍然由 {@link MvpController} 自己处理(更具体的映射优先)。
*/
@RestController
@ConditionalOnProperty(name = "taobao.mode", havingValue = "forward")
public class TaobaoForwardController {
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3);
/** 云网关单请求 10 秒上限,留出余量。 */
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(9);
private final HttpClient client = HttpClient.newBuilder()
.connectTimeout(CONNECT_TIMEOUT)
.followRedirects(HttpClient.Redirect.NEVER)
.build();
private final String forwardBase;
public TaobaoForwardController(@Value("${taobao.forward-base}") String forwardBase) {
this.forwardBase = forwardBase.endsWith("/")
? forwardBase.substring(0, forwardBase.length() - 1)
: forwardBase;
}
@RequestMapping("/api/taobao/{*path}")
public ResponseEntity<byte[]> forward(HttpServletRequest request) {
byte[] body = readBody(request);
String target = forwardBase + request.getRequestURI()
+ (request.getQueryString() == null ? "" : "?" + request.getQueryString());
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(target))
.timeout(REQUEST_TIMEOUT)
.method(request.getMethod(), body.length == 0
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofByteArray(body));
copyHeader(request, builder, "Content-Type");
copyHeader(request, builder, "Authorization");
HttpResponse<byte[]> response;
try {
response = client.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray());
} catch (IOException e) {
return envelope(502, "测肤服务连接失败");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return envelope(502, "测肤服务被中断");
}
// 对端正常返回时已经是客户端要的信封(h5/lib/taobao-routes.js:8),原样回传。
if (looksLikeEnvelope(response.body())) {
return ResponseEntity.status(response.statusCode())
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8")
.header(HttpHeaders.CACHE_CONTROL, "no-store")
.body(response.body());
}
// 对端没部署 / 返回 404 页面 / 网关错误:包成统一信封,避免小程序 JSON.parse 失败。
return envelope(502, "测肤服务暂不可用(上游 " + response.statusCode() + ")");
}
private void copyHeader(HttpServletRequest request, HttpRequest.Builder builder, String name) {
String value = request.getHeader(name);
if (value != null && !value.isBlank()) {
builder.header(name, value);
}
}
private static byte[] readBody(HttpServletRequest request) {
try {
return request.getInputStream().readAllBytes();
} catch (IOException e) {
return new byte[0];
}
}
private static boolean looksLikeEnvelope(byte[] body) {
if (body == null || body.length == 0) {
return false;
}
String text = new String(body, StandardCharsets.UTF_8).stripLeading();
return text.startsWith("{") && text.contains("\"success\"");
}
private static ResponseEntity<byte[]> envelope(int status, String message) {
String json = "{\"success\":true,\"data\":{\"ok\":false,\"http_status\":" + status
+ ",\"message\":\"" + message + "\"}}";
return ResponseEntity.status(200)
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8")
.header(HttpHeaders.CACHE_CONTROL, "no-store")
.body(json.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,154 @@
package com.juzi.mvp.api;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 淘宝小程序会话:一次性的 challenge + 30 天有效的 Bearer token。
*
* <p>参考实现 h5/lib/taobao-session.js 的取值:challenge 60 秒、单次消费、
* 每来源 30 次/分钟;token 32 字节随机十六进制、有效期 30 天。
*
* <p>注意:全部状态都在进程内存里,SAE 只能单副本运行;多副本/发布不丢会话
* 需要换 Redis 或 RDS。这一点和现有 Node 实现一致,不是新增的退化。
*/
@Component
public class TaobaoSessionService {
private static final long CHALLENGE_TTL_SECONDS = 60;
private static final long TOKEN_TTL_SECONDS = 30L * 24 * 60 * 60;
private static final int MAX_OUTSTANDING_CHALLENGES = 1000;
private static final int RATE_LIMIT_PER_MINUTE = 30;
private final SecureRandom random = new SecureRandom();
private final Map<String, Instant> challenges = new ConcurrentHashMap<>();
private final Map<String, Session> sessions = new ConcurrentHashMap<>();
private final Map<String, Rate> rates = new ConcurrentHashMap<>();
@Value("${taobao.app-key:}")
private String appKey;
private record Rate(Instant window, int count) {
}
public record Session(String userId, String openId, Instant expires) {
}
public record Token(String value, long expiresInSeconds, String userId) {
}
/**
* 限流计数用的来源标识。
*
* <p>云网关后面所有请求的来源 IP 都是同一个代理 IP,按 IP 计数会变成全站共享
* 30 次/分钟,所以在有 open_id 时优先按用户计数。
*/
public String rateKey(HttpServletRequest request) {
String openId = request.getParameter("open_id");
return openId != null && !openId.isBlank() ? "open:" + openId : "ip:" + request.getRemoteAddr();
}
/**
* 发放一次性 challenge;超出频率或存量上限时返回 null(调用方回 429)。
*/
public String challenge(String rateKey) {
sweep();
Instant now = Instant.now();
Rate rate = rates.compute(rateKey, (key, current) ->
current == null || !current.window().plusSeconds(CHALLENGE_TTL_SECONDS).isAfter(now)
? new Rate(now, 1)
: new Rate(current.window(), current.count() + 1));
if (rate.count() > RATE_LIMIT_PER_MINUTE || challenges.size() >= MAX_OUTSTANDING_CHALLENGES) {
return null;
}
String id = randomHex(24);
challenges.put(id, now.plusSeconds(CHALLENGE_TTL_SECONDS));
return id;
}
/**
* 消费 challenge;remove 返回非空即视为抢到,天然只成功一次。
*/
public boolean consume(String challenge) {
if (challenge == null || challenge.isBlank()) {
return false;
}
Instant expires = challenges.remove(challenge);
return expires != null && expires.isAfter(Instant.now());
}
public Token issue(String openId) {
String userId = userId(openId);
String value = randomHex(32);
sessions.put(value, new Session(userId, openId, Instant.now().plusSeconds(TOKEN_TTL_SECONDS)));
return new Token(value, TOKEN_TTL_SECONDS, userId);
}
public Session find(String token) {
if (token == null || token.isBlank()) {
return null;
}
Session session = sessions.get(token);
if (session == null) {
return null;
}
if (!session.expires().isAfter(Instant.now())) {
sessions.remove(token);
return null;
}
return session;
}
public void revoke(String token) {
if (token != null) {
sessions.remove(token);
}
}
/**
* 稳定的用户 id:sha256(app_key + ':' + open_id),与 Node 端 provider_key 口径一致。
*/
public String userId(String openId) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
String seed = (appKey == null ? "" : appKey) + ":" + openId;
return HexFormat.of().formatHex(digest.digest(seed.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
/** 从 Authorization: Bearer xxx 里取 token。 */
public static String bearerToken(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (header == null) {
return null;
}
String prefix = "Bearer ";
return header.regionMatches(true, 0, prefix, 0, prefix.length())
? header.substring(prefix.length()).trim()
: null;
}
private void sweep() {
Instant now = Instant.now();
challenges.entrySet().removeIf(entry -> !entry.getValue().isAfter(now));
sessions.entrySet().removeIf(entry -> !entry.getValue().expires().isAfter(now));
rates.entrySet().removeIf(entry -> !entry.getValue().window().plusSeconds(CHALLENGE_TTL_SECONDS).isAfter(now));
}
private String randomHex(int bytes) {
byte[] buffer = new byte[bytes];
random.nextBytes(buffer);
return HexFormat.of().formatHex(buffer);
}
}
@@ -90,8 +90,11 @@ public class TaobaoSignatureVerifier {
}
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)) {
// 真实网关请求不带 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)));
}
@@ -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:}
@@ -0,0 +1,139 @@
package com.juzi.mvp;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 登录链路(challenge → session → me → logout)的端到端契约测试。
*
* <p>本地/测试环境 verify-sign=false,所以用 ?open_id= 模拟云网关注入的身份;
* 打开 verify-sign 后,同样的请求需要带正确的 sign。
*/
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
// 部署用的 application-local.yml 里写的是 mode: forward(生产走透传)。
// 这个用例测的是 Java 自己实现登录的那条路,所以显式钉住 native。
properties = "taobao.mode=native")
class TaobaoAuthFlowTests {
private static final String GATEWAY_QUERY =
"?app_key=35486228&open_id=AAG8-test-open-id&sign_method=hmac_sha256";
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate rest;
private String url(String path) {
return "http://127.0.0.1:" + port + path;
}
private HttpEntity<String> json(String body) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
return new HttpEntity<>(body, headers);
}
private HttpEntity<String> json(String body, String token) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
if (token != null) {
headers.set("Authorization", "Bearer " + token);
}
return new HttpEntity<>(body, headers);
}
@SuppressWarnings("unchecked")
private Map<String, Object> data(ResponseEntity<Map> response) {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("success", true);
return (Map<String, Object>) response.getBody().get("data");
}
@SuppressWarnings("unchecked")
private String login() {
Map<String, Object> challenge = data(rest.postForEntity(
url("/api/taobao/challenge" + GATEWAY_QUERY), json("{}"), Map.class));
assertThat(challenge).containsEntry("ok", true).containsEntry("http_status", 200);
String challengeId = (String) challenge.get("challenge");
assertThat(challengeId).hasSize(48);
Map<String, Object> session = data(rest.postForEntity(
url("/api/taobao/session" + GATEWAY_QUERY),
json("{\"consent\":true,\"challenge\":\"" + challengeId + "\"}"),
Map.class));
assertThat(session).containsEntry("ok", true).containsEntry("http_status", 200);
assertThat((String) session.get("token")).hasSize(64);
assertThat(((Number) session.get("expires_in")).longValue()).isEqualTo(2592000L);
Map<String, Object> user = (Map<String, Object>) session.get("user");
assertThat(user).containsEntry("kind", "taobao");
assertThat((String) user.get("id")).hasSize(64);
return (String) session.get("token");
}
@Test
@SuppressWarnings("unchecked")
void challengeSessionMeLogoutRoundTrip() {
String token = login();
Map<String, Object> me = data(rest.postForEntity(
url("/api/taobao/me"), json("{}", token), Map.class));
assertThat(me).containsEntry("ok", true);
assertThat(((Map<String, Object>) me.get("user"))).containsEntry("kind", "taobao");
assertThat(data(rest.postForEntity(
url("/api/taobao/logout"), json("{}", token), Map.class))).containsEntry("ok", true);
Map<String, Object> afterLogout = data(rest.postForEntity(
url("/api/taobao/me"), json("{}", token), Map.class));
assertThat(afterLogout).containsEntry("ok", false).containsEntry("http_status", 401);
}
@Test
void sessionRejectsMissingConsent() {
Map<String, Object> challenge = data(rest.postForEntity(
url("/api/taobao/challenge" + GATEWAY_QUERY), json("{}"), Map.class));
Map<String, Object> session = data(rest.postForEntity(
url("/api/taobao/session" + GATEWAY_QUERY),
json("{\"consent\":false,\"challenge\":\"" + challenge.get("challenge") + "\"}"),
Map.class));
assertThat(session).containsEntry("ok", false).containsEntry("http_status", 400);
assertThat(session.get("message")).isEqualTo("请先阅读并同意隐私协议");
}
@Test
void challengeCannotBeReused() {
Map<String, Object> challenge = data(rest.postForEntity(
url("/api/taobao/challenge" + GATEWAY_QUERY), json("{}"), Map.class));
String body = "{\"consent\":true,\"challenge\":\"" + challenge.get("challenge") + "\"}";
assertThat(data(rest.postForEntity(url("/api/taobao/session" + GATEWAY_QUERY), json(body), Map.class)))
.containsEntry("ok", true);
Map<String, Object> replay = data(rest.postForEntity(
url("/api/taobao/session" + GATEWAY_QUERY), json(body), Map.class));
assertThat(replay).containsEntry("ok", false).containsEntry("http_status", 401);
}
@Test
void meWithoutTokenIsUnauthorized() {
Map<String, Object> me = data(rest.postForEntity(
url("/api/taobao/me"), json("{}"), Map.class));
assertThat(me).containsEntry("ok", false).containsEntry("http_status", 401);
assertThat(me.get("message")).isEqualTo("检测会话已失效,请重新登录");
}
}
@@ -0,0 +1,123 @@
package com.juzi.mvp;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* taobao.mode=forward 时,Java 必须把请求「字节级」转发给外层 H5 后端:
* 原始 query string、原始 body、Authorization 都要原样过去,回来的信封也要原样返回。
*
* <p>用一个本地 stub 冒充 Node 后端来验证这件事。
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class TaobaoForwardTests {
private static final HttpServer STUB;
private static volatile String lastQuery;
private static volatile String lastBody;
private static volatile String lastAuthorization;
private static volatile String lastPath;
static {
try {
STUB = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
STUB.createContext("/api/taobao/", exchange -> {
lastPath = exchange.getRequestURI().getPath();
lastQuery = exchange.getRequestURI().getRawQuery();
lastAuthorization = exchange.getRequestHeaders().getFirst("Authorization");
try (InputStream in = exchange.getRequestBody()) {
lastBody = new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
byte[] payload = ("{\"success\":true,\"data\":{\"ok\":true,\"http_status\":200,"
+ "\"echo_path\":\"" + lastPath + "\","
+ "\"echo_query\":\"" + lastQuery + "\","
+ "\"echo_body\":" + (lastBody.isEmpty() ? "\"\"" : lastBody) + "}}")
.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json;charset=utf-8");
exchange.sendResponseHeaders(200, payload.length);
exchange.getResponseBody().write(payload);
exchange.close();
});
STUB.start();
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("taobao.mode", () -> "forward");
registry.add("taobao.forward-base", () -> "http://127.0.0.1:" + STUB.getAddress().getPort());
}
@AfterAll
static void stopStub() {
STUB.stop(0);
}
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate rest;
private String url(String path) {
return "http://127.0.0.1:" + port + path;
}
@Test
@SuppressWarnings("unchecked")
void forwardsBodyQueryAndAuthorizationVerbatim() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer abc123");
// 故意带空格和不常见字段顺序:任何重新序列化都会改变这里的字节
String body = "{\"consent\":true, \"challenge\":\"x-1\", \"note\":\"带空格\"}";
ResponseEntity<Map> response = rest.postForEntity(
url("/api/taobao/session?app_key=35486228&open_id=op-x&sign_method=hmac_sha256&sign=ABC"),
new HttpEntity<>(body, headers),
Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("success", true);
assertThat(lastPath).isEqualTo("/api/taobao/session");
assertThat(lastQuery).isEqualTo("app_key=35486228&open_id=op-x&sign_method=hmac_sha256&sign=ABC");
assertThat(lastBody).isEqualTo(body);
assertThat(lastAuthorization).isEqualTo("Bearer abc123");
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
assertThat(data).containsEntry("ok", true).containsEntry("echo_path", "/api/taobao/session");
}
@Test
void localDiagnosticsEndpointsStayLocal() {
ResponseEntity<Map> ping = rest.postForEntity(url("/api/taobao/ping"), Map.of(), Map.class);
assertThat(ping.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) ping.getBody().get("data");
assertThat(data).containsEntry("server", "java");
assertThat(lastPath).isNotEqualTo("/api/taobao/ping");
}
}
@@ -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<String, String> params = new TreeMap<>();
params.put("app_key", "app");
params.put("open_id", "open-account");
params.put("env", "online");
String body = "{\"consent\":true,\"challenge\":\"nonce\"}";
assertThat(TaobaoSignatureVerifier.sign(params, body, "secret"))
.isEqualTo("DD2B95886A5E8F61D6111A5659E4FBD5");
}
}