Compare commits

..
13 Commits
Author SHA1 Message Date
chenxing 4de8eda227 merge: 合并 projman 初始化的远端仓库(CI 文件以远端为准:.gitea/workflows + docker/Dockerfile;README 保留项目文档)
build / build-push (push) Failing after 16s
2026-09-21 10:47:16 +08:00
chenxing 10374abd6d T1.6: 观察模式开关 + Filter 链组装(链序定型 台账0→Sanitize1→Routing2→SignVerify3→身份注入4(转发器内)→Dispatch5;/api/internal/** 三环旁路直达 controller;observe=true 坏签名放行、false 裸401 集成对照;400/404 协议层错误不受 observe 影响;关掉 starter AppAuthFilter(正确键 yh.app-auth.enabled=false)避免抢链首误 401) 2026-09-21 09:45:52 +08:00
chenxing 28b9bfca3c T1.5: 台账异步落库(ArrayBlockingQueue(10000) offer 不阻塞;满200/500ms 单线程批量 saveAll+按日 upsert;队满原子计数+ERROR 不抛;关停 flush 余量;hibernate batch_size=200) 2026-09-21 09:29:48 +08:00
chenxing 26f5787000 T1.4: 身份头注入 X-Caller-App-Key(verdict=PASS 才注入验签 appKey;注入点在伪造头剥除之后,FAIL/ANON 不注入;四用例绿) 2026-09-21 09:25:13 +08:00
chenxing cdc4396ec2 T1.3: StreamingForwarder 流式转发(AsyncContext+HttpClient 零缓冲透传;剥 hop-by-hop 与伪造 X-Caller-App-Key、签名头四件原样;超限裸413/连接失败裸502/超时裸504) 2026-09-21 09:22:07 +08:00
chenxing 58a1bb5358 T1.2: SignVerifyFilter v1(canonical 剥 target 首段+解码排序 query,复用 starter SignUtil ±5min;observe 只记不拦/非 observe 裸 401;共享向量为 starter 真实代码执行产出 v1.json 逐字节对拍) 2026-09-21 09:13:18 +08:00
chenxing 949296eb67 T1.1: SanitizeFilter 请求规整化(解码检查 ..///控制字符裸 400,只拒不修;attribute 契约 sanitized.uri/stripped.path) 2026-09-21 00:34:11 +08:00
chenxing a45a180008 T0.13: 全平台 oapi 盘点产出注册表初版 02_init_routes.sql(22 条前缀聚合,全最保守形态) 2026-09-21 00:32:36 +08:00
chenxing 4777d47499 T0.12: GatewayProperties 路由表 + TargetRouter(/{target}/oapi/** 路由判定与 forwardUrl 换主机不换路径计算) 2026-09-21 00:25:54 +08:00
chenxing 6cb0fa1b5b T0.11: GatewayKeyService 薄封装复用 starter SignKeyResolver(四态 fail-closed);显式引 data-redis(starter 标 optional 不传递) 2026-09-21 00:24:02 +08:00
chenxing d4bfe8a3b4 T0.10: RouteRegistry Caffeine 装载/判定(精确优先+/**通配+最严默认)与 /api/internal/routes/refresh 2026-09-21 00:21:28 +08:00
chenxing 06a02750b0 T0.9: 注册表三表 DDL(01_schema.sql) + JPA 实体/Repository + H2 MODE=MySQL round-trip 与唯一约束测试 2026-09-21 00:20:01 +08:00
chenxing f10343a4c1 T0.8: open-gateway 建仓——Spring Boot 2.7.18/JDK8 骨架 + EnvelopeWriter(裸码/信封两形态,信封与 SDK OpenApiEnvelope 字段对齐)+ CI/Dockerfile/.dockerignore 2026-09-21 00:17:49 +08:00
43 changed files with 3480 additions and 2 deletions
+4
View File
@@ -0,0 +1,4 @@
.git
.idea
*.md
logs
+4
View File
@@ -0,0 +1,4 @@
target/
.idea/
*.iml
logs/
+8 -2
View File
@@ -1,3 +1,9 @@
# open-gateway-service
# open-gateway 开放接口网关
managed by projman
容器网内 oapi 统一入口(路由 / 验签 / 转发 / 台账),后期对外 `open.yunhaixingtong.com` 唯一鉴权面。
实施依据:`开放接口中心实施方案1.0.md`(云海 workspace 根目录)。
- 端口:10010(projman 注册:空 contextPath、needDb=true、infraServices=["redis"])
- 库表:`docs/sql/01_schema.sql`(注册表/台账/日统计;T1.7 导入)
- 环境值(datasource/redis/routes/observe)一律走平台配置层,jar 内 yml 只留占位注释
- CI:push main 自动构建 app.jar 并推 `nexus:8083/docker-hosted/open-gateway-service`
+53
View File
@@ -0,0 +1,53 @@
-- =====================================================================
-- 开放接口中心 网关库表(T0.9)
-- 库:open_gateway(projman provision 建;本脚本由 T1.7 生产导入,可重复执行)
-- 方言:MySQL
-- =====================================================================
DROP TABLE IF EXISTS open_api_route;
CREATE TABLE open_api_route (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
target_code VARCHAR(32) NOT NULL COMMENT '目标应用 ctx(usermsg/notice/...)',
path_pattern VARCHAR(256) NOT NULL COMMENT 'Ant 风格;匹配剥掉 target 首段后的路径,如 /oapi/message/**',
http_method VARCHAR(8) NOT NULL DEFAULT '*' COMMENT 'GET/POST/PUT/DELETE/*',
visibility VARCHAR(16) NOT NULL DEFAULT 'INTERNAL' COMMENT 'INTERNAL/EXTERNAL',
auth_mode VARCHAR(16) NOT NULL DEFAULT 'SIGNED' COMMENT 'SIGNED/ANON/OPTIONAL',
cache_ttl_seconds INT NOT NULL DEFAULT 0,
cache_scope VARCHAR(16) NOT NULL DEFAULT 'CALLER' COMMENT 'CALLER/GLOBAL',
rate_limit_qps INT NOT NULL DEFAULT 0 COMMENT '0=不限',
timeout_ms INT NOT NULL DEFAULT 30000,
request_body_limit_mb INT NOT NULL DEFAULT 10,
enabled TINYINT NOT NULL DEFAULT 1,
remark VARCHAR(256),
gmt_create DATETIME NOT NULL,
gmt_modified DATETIME NOT NULL,
UNIQUE KEY uk_route (target_code, path_pattern, http_method)
);
DROP TABLE IF EXISTS open_api_log;
CREATE TABLE open_api_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
ts DATETIME(3) NOT NULL,
caller_app_key VARCHAR(64) COMMENT 'ANON 记 anonymous;401 且无 appKey 记 NULL',
target_code VARCHAR(32) NOT NULL,
method VARCHAR(8) NOT NULL,
path VARCHAR(256) NOT NULL COMMENT '剥掉 target 首段后的路径(与注册表 path_pattern 同一坐标系)',
status INT NOT NULL COMMENT '网关出口 HTTP 状态',
backend_code INT COMMENT '提供方业务 code(响应为 JSON 信封时尽力解析,否则 NULL)',
cost_ms INT NOT NULL,
KEY idx_ts (ts),
KEY idx_caller_ts (caller_app_key, ts),
KEY idx_target_ts (target_code, ts)
);
DROP TABLE IF EXISTS open_api_stat_daily;
CREATE TABLE open_api_stat_daily (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
stat_date DATE NOT NULL,
target_code VARCHAR(32) NOT NULL,
caller_app_key VARCHAR(64) NOT NULL,
total INT NOT NULL DEFAULT 0,
err_total INT NOT NULL DEFAULT 0,
avg_cost_ms INT NOT NULL DEFAULT 0,
UNIQUE KEY uk_stat (stat_date, target_code, caller_app_key)
);
+49
View File
@@ -0,0 +1,49 @@
-- =====================================================================
-- 开放接口中心 注册表初版(T0.13,由全平台 oapi 盘点产出,附录 B.2 为源)
-- 形态:全量最保守——visibility=INTERNAL、auth_mode=SIGNED、cache_ttl_seconds=0、
-- rate_limit_qps=0(后续逐条放开)
-- 粒度:按 controller 前缀 /** 聚合(行数 = 附录 B.2.2 表行数)
-- 坐标系:path_pattern = 剥掉 target 首段后的路径(如 /userbase/oapi/msg/x → /oapi/msg/x)
-- 可重复执行:INSERT IGNORE(uk_route 命中即跳过)
-- 生产导入:T1.7(provision 建库后与 01_schema.sql 一并执行)
-- =====================================================================
INSERT IGNORE INTO open_api_route
(target_code, path_pattern, http_method, visibility, auth_mode, cache_ttl_seconds, cache_scope,
rate_limit_qps, timeout_ms, request_body_limit_mb, enabled, remark, gmt_create, gmt_modified)
VALUES
-- userbase(UserBase 用户中心)
('userbase', '/oapi/app/**', '*', 'INTERNAL', 'SIGNED', 0, 'CALLER', 0, 30000, 10, 1, '应用信息:byId/list', NOW(), NOW()),
('userbase', '/oapi/group/**', '*', 'INTERNAL', 'SIGNED', 0, 'CALLER', 0, 30000, 10, 1, '组织:tree/byId/list/users/add/edit/delete/batchAdd/move', NOW(), NOW()),
('userbase', '/oapi/identity/**', '*', 'INTERNAL', 'SIGNED', 0, 'CALLER', 0, 30000, 10, 1, '身份:current/available/switch', NOW(), NOW()),
('userbase', '/oapi/user/**', '*', 'INTERNAL', 'SIGNED', 0, 'CALLER', 0, 30000, 10, 1, '用户:byId/byLoginName/byPhone/modules/add/edit/delete/batchAdd 等 21 端点', NOW(), NOW()),
-- userclass(BschClass 班级管理;OA 老协议兼容路径保留原大小写)
('userclass', '/oapi/DbClass/**', '*', 'INTERNAL', 'SIGNED', 0, 'CALLER', 0, 30000, 10, 1, 'OA 兼容:班级', NOW(), NOW()),
('userclass', '/oapi/Common/FamCom/**', '*', 'INTERNAL', 'SIGNED', 0, 'CALLER', 0, 30000, 10, 1, 'OA 兼容:家属', NOW(), NOW()),
('userclass', '/oapi/SchGroup/**', '*', 'INTERNAL', 'SIGNED', 0, 'CALLER', 0, 30000, 10, 1, 'OA 兼容:学校组织', NOW(), NOW()),
('userclass', '/oapi/SchInfo/**', '*', 'INTERNAL', 'SIGNED', 0, 'CALLER', 0, 30000, 10, 1, 'OA 兼容:学校信息', NOW(), NOW()),
('userclass', '/oapi/SubInfo/**', '*', 'INTERNAL', 'SIGNED', 0, 'CALLER', 0, 30000, 10, 1, 'OA 兼容:学科', NOW(), NOW()),
('userclass', '/oapi/Schoolen/**', '*', 'INTERNAL', 'SIGNED', 0, 'CALLER', 0, 30000, 10, 1, 'OA 兼容:学段', NOW(), NOW()),
('userclass', '/oapi/OAStu/**', '*', 'INTERNAL', 'SIGNED', 0, 'CALLER', 0, 30000, 10, 1, 'OA 兼容:学生信息(21 端点)', NOW(), NOW()),
('userclass', '/oapi/TeachInfo/**', '*', 'INTERNAL', 'SIGNED', 0, 'CALLER', 0, 30000, 10, 1, 'OA 兼容:任教信息', NOW(), NOW()),
('userclass', '/oapi/OATea/**', '*', 'INTERNAL', 'SIGNED', 0, 'CALLER', 0, 30000, 10, 1, 'OA 兼容:教师信息', NOW(), NOW()),
('userclass', '/oapi/UserInfo/**', '*', 'INTERNAL', 'SIGNED', 0, 'CALLER', 0, 30000, 10, 1, 'OA 兼容:用户信息', NOW(), NOW()),
('userclass', '/oapi/VSchClass/**', '*', 'INTERNAL', 'SIGNED', 0, 'CALLER', 0, 30000, 10, 1, 'OA 兼容:虚拟班级(17 端点)', NOW(), NOW()),
('userclass', '/oapi/group/**', '*', 'INTERNAL', 'SIGNED', 0, 'CALLER', 0, 30000, 10, 1, '同步:组织(4 端点)', NOW(), NOW()),
('userclass', '/oapi/user/**', '*', 'INTERNAL', 'SIGNED', 0, 'CALLER', 0, 30000, 10, 1, '同步:用户(5 端点)', NOW(), NOW()),
-- cms(Cms 门户)
('cms', '/oapi/site/**', '*', 'INTERNAL', 'SIGNED', 0, 'CALLER', 0, 30000, 10, 1, '站点选项:list', NOW(), NOW()),
-- svc-hall(办事大厅)
('svc-hall', '/oapi/svc/**', '*', 'INTERNAL', 'SIGNED', 0, 'CALLER', 0, 30000, 10, 1, '事项:items/filters/detail/view/attachments/related/snapshot', NOW(), NOW()),
-- flow-center(流程中心)
('flow-center','/oapi/flow/**', '*', 'INTERNAL', 'SIGNED', 0, 'CALLER', 0, 30000, 10, 1, '流程:list/getFlow/apply/instance/progress/diagram/withdraw/callback-reg', NOW(), NOW()),
-- usermsg(UserMsg 消息中心)
('usermsg', '/oapi/msg/**', '*', 'INTERNAL', 'SIGNED', 0, 'CALLER', 0, 30000, 10, 1, '消息:sendUserMsg/list/unreadCount/read/readAll + todo(inc/reset/list) + ws/ticket', NOW(), NOW()),
-- notice(Notice 通知公告)
('notice', '/oapi/notice/**', '*', 'INTERNAL', 'SIGNED', 0, 'CALLER', 0, 30000, 10, 1, '字典:getDict', NOW(), NOW());
+99
View File
@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<groupId>com.yunhai.platform</groupId>
<artifactId>open-gateway</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>open-gateway</name>
<description>开放接口网关:容器网内 oapi 统一入口(路由/验签/转发/台账),后期对外 open 域名唯一鉴权面</description>
<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- 白名单(开放接口中心 T0.8):本清单外依赖不得引入 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
<!-- 复用 starter 的 SignKeyResolver/SignUtil(密钥链单一事实源);密钥解析永远 fail-closed -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-app-auth-spring-boot-starter</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
<!-- starter 将 data-redis 标 optional(未引 Redis 的服务回退配置式 Provider),不传递;
网关密钥链必须走 Redis(sign:app:*),按 T0.8 白名单意图显式引入 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<finalName>app</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,18 @@
package com.yunhai.platform.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 开放接口网关入口。
* <p>
* 环境值(datasource/redis/routes/observe)一律走平台配置层(application-platform.yml),
* jar 内 application.yml 只留占位注释——环境值打进 jar 曾造成全站事故(§0 规则 8)。
*/
@SpringBootApplication
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}
@@ -0,0 +1,42 @@
package com.yunhai.platform.gateway.auth;
import com.yunhai.platform.appauth.SignKeyResolver;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 网关密钥解析薄封装(T0.11):100% 复用 starter {@link SignKeyResolver}(F3),
* 不重写密钥逻辑、不自建密钥缓存——Redis 连接走平台配置层(spring.redis.*,密钥链专用 db0)。
* <p>
* 语义与 starter 完全一致(D4:<b>密钥解析永远 fail-closed</b>):
* 未知 appKey / 禁用 / Redis 异常 / starter 未装配,一律返回 {@code null} = 拒绝,
* 绝不抛 500(上层 T1.2 SignVerifyFilter 将 null 转为裸 401)。
* 任何"Redis 挂了先放行"的逻辑都被禁止(nonce/限流降级在 T3.6,密钥永不降级)。
*/
@Component
public class GatewayKeyService {
private static final Logger log = LoggerFactory.getLogger(GatewayKeyService.class);
@Autowired(required = false)
private SignKeyResolver signKeyResolver;
/**
* @return 调用方 appSecret;解析不出(未知/禁用/异常/未装配)一律 null = 拒绝
*/
public String getSecret(String appKey) {
if (signKeyResolver == null || appKey == null || appKey.trim().isEmpty()) {
return null;
}
try {
// starter 契约:未知/禁用/Redis 异常内部已转 null(fail-closed);此处不二次包装语义
return signKeyResolver.getSecret(appKey);
} catch (Exception e) {
// 防御:starter 契约之外的意外异常同样落为拒绝,不向请求线程抛 500
log.warn("[GatewayKeyService] key resolve unexpected error, reject appKey={}", appKey, e);
return null;
}
}
}
@@ -0,0 +1,59 @@
package com.yunhai.platform.gateway.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Index;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* 网关访问台账(T0.9;观测设施,只插入不改写;索引 idx_ts / idx_caller_ts / idx_target_ts 与 DDL 对齐)。
*/
@Entity
@Table(name = "open_api_log", indexes = {
@Index(name = "idx_ts", columnList = "ts"),
@Index(name = "idx_caller_ts", columnList = "caller_app_key, ts"),
@Index(name = "idx_target_ts", columnList = "target_code, ts")
})
@Getter
@Setter
public class OpenApiLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "ts", nullable = false, columnDefinition = "DATETIME(3)")
private LocalDateTime ts;
/** ANON 记 anonymous;401 且无 appKey 记 NULL */
@Column(name = "caller_app_key", length = 64)
private String callerAppKey;
@Column(name = "target_code", nullable = false, length = 32)
private String targetCode;
@Column(name = "method", nullable = false, length = 8)
private String method;
/** 剥掉 target 首段后的路径(与注册表 path_pattern 同一坐标系) */
@Column(name = "path", nullable = false, length = 256)
private String path;
/** 网关出口 HTTP 状态 */
@Column(name = "status", nullable = false)
private int status;
/** 提供方业务 code(响应为 JSON 信封时尽力解析,否则 NULL) */
@Column(name = "backend_code")
private Integer backendCode;
@Column(name = "cost_ms", nullable = false)
private int costMs;
}
@@ -0,0 +1,96 @@
package com.yunhai.platform.gateway.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.PrePersist;
import javax.persistence.PreUpdate;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
import java.time.LocalDateTime;
/**
* 路由注册表(T0.9;DDL 见 docs/sql/01_schema.sql,字段与列逐一对齐)。
* <p>
* Java 字段默认值与 DDL DEFAULT 逐列一致——插入即生效,不依赖 DB 默认兜底
* (提供方注册初版由 02_init_routes.sql 直插,运行时仅读)。
*/
@Entity
@Table(name = "open_api_route", uniqueConstraints = @UniqueConstraint(
name = "uk_route", columnNames = {"target_code", "path_pattern", "http_method"}))
@Getter
@Setter
public class OpenApiRoute {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "target_code", nullable = false, length = 32)
private String targetCode;
@Column(name = "path_pattern", nullable = false, length = 256)
private String pathPattern;
/** GET/POST/PUT/DELETE/* */
@Column(name = "http_method", nullable = false, length = 8)
private String httpMethod = "*";
/** INTERNAL/EXTERNAL */
@Column(name = "visibility", nullable = false, length = 16)
private String visibility = "INTERNAL";
/** SIGNED/ANON/OPTIONAL */
@Column(name = "auth_mode", nullable = false, length = 16)
private String authMode = "SIGNED";
@Column(name = "cache_ttl_seconds", nullable = false)
private int cacheTtlSeconds = 0;
/** CALLER/GLOBAL */
@Column(name = "cache_scope", nullable = false, length = 16)
private String cacheScope = "CALLER";
/** 0=不限 */
@Column(name = "rate_limit_qps", nullable = false)
private int rateLimitQps = 0;
@Column(name = "timeout_ms", nullable = false)
private int timeoutMs = 30000;
@Column(name = "request_body_limit_mb", nullable = false)
private int requestBodyLimitMb = 10;
@Column(name = "enabled", nullable = false)
private boolean enabled = true;
@Column(name = "remark", length = 256)
private String remark;
@Column(name = "gmt_create", nullable = false)
private LocalDateTime gmtCreate;
@Column(name = "gmt_modified", nullable = false)
private LocalDateTime gmtModified;
@PrePersist
void preCreate() {
LocalDateTime now = LocalDateTime.now();
if (gmtCreate == null) {
gmtCreate = now;
}
if (gmtModified == null) {
gmtModified = now;
}
}
@PreUpdate
void preUpdate() {
gmtModified = LocalDateTime.now();
}
}
@@ -0,0 +1,46 @@
package com.yunhai.platform.gateway.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
import java.time.LocalDate;
/**
* 日统计(T0.9;由台账刷盘线程按日 upsert,uk_stat = stat_date + target_code + caller_app_key)。
*/
@Entity
@Table(name = "open_api_stat_daily", uniqueConstraints = @UniqueConstraint(
name = "uk_stat", columnNames = {"stat_date", "target_code", "caller_app_key"}))
@Getter
@Setter
public class OpenApiStatDaily {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "stat_date", nullable = false)
private LocalDate statDate;
@Column(name = "target_code", nullable = false, length = 32)
private String targetCode;
@Column(name = "caller_app_key", nullable = false, length = 64)
private String callerAppKey;
@Column(name = "total", nullable = false)
private int total = 0;
@Column(name = "err_total", nullable = false)
private int errTotal = 0;
@Column(name = "avg_cost_ms", nullable = false)
private int avgCostMs = 0;
}
@@ -0,0 +1,53 @@
package com.yunhai.platform.gateway.envelope;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 网关统一错误写出组件(开放接口中心 T0.8)。
* <p>
* 两形态(§1.1/D3):
* <ul>
* <li><b>裸码</b>:网关自产错误一律裸 HTTP 状态码、无响应体——401 验签失败/缺失、403 可见性拒绝、
* 404 target/路由未知、400 URI 非法、413 body 超限、429 限流(RAW)、503 熔断(RAW)、
* 502 提供方连接失败、504 提供方超时(F5:鉴权错 = 裸 HTTP,不裹信封);</li>
* <li><b>信封</b>:HTTP 200 + {@code {code, msg, data:null}}——字段结构与 SDK 侧
* {@code OpenApiEnvelope}(yh-openapi-client core,自 yh-api-std 提炼)的解析形态逐字段对齐:
* unwrap 读 {@code code}(非 0 抛业务异常)与 {@code msg},网关治理信封写同名同义字段,
* 老 SDK 无需升级即可按 DException 收到 code/msg。</li>
* </ul>
* 提供方响应<b>原样透传</b>,不经本组件。
*/
public final class EnvelopeWriter {
/** 治理错误码(D3 预留;error-style 开关 T3.1 才消费,本阶段只预留常量) */
public static final int RATE_LIMITED = 7;
public static final int CIRCUIT_OPEN = 8;
private static final ObjectMapper MAPPER = new ObjectMapper();
private EnvelopeWriter() {
}
/** 网关自产错误:裸 HTTP 状态码,无响应体(不裹信封) */
public static void writeBare(HttpServletResponse response, int status) throws IOException {
response.reset();
response.setStatus(status);
response.setContentLength(0);
}
/** 业务信封:HTTP 200 + {code, msg, data:null}(与 SDK OpenApiEnvelope 解析形态对齐) */
public static void writeEnvelope(HttpServletResponse response, int code, String msg) throws IOException {
response.reset();
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("application/json;charset=UTF-8");
ObjectNode node = MAPPER.createObjectNode();
node.put("code", code);
node.put("msg", msg);
node.putNull("data");
response.getWriter().write(MAPPER.writeValueAsString(node));
}
}
@@ -0,0 +1,116 @@
package com.yunhai.platform.gateway.filter;
import com.yunhai.platform.gateway.log.AccessLogService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import javax.servlet.AsyncEvent;
import javax.servlet.AsyncListener;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 台账采集(T1.6 链序第 0 环,最外层):只记 oapi 请求(网关自身 {@code /api/internal/**} 不记),
* 出口状态取网关真实响应码,costMs 取端到端耗时。
* <p>
* 转发是异步的(T1.3 {@code AsyncContext})——请求线程提前返回时响应尚未完成,
* 此时挂 {@link AsyncListener} 在 complete 时记录,保证台账 status 不是"半途值";
* 记录动作本身在 {@link AccessLogService} 内只入队,绝不拖慢请求。
*/
@Component
@Order(0)
public class AccessLogFilter implements Filter {
private static final String ATTR_START_NANOS = "yh.gateway.start.nanos";
@Autowired
private AccessLogService accessLogService;
/** 测试/显式装配入口 */
void setAccessLogService(AccessLogService accessLogService) {
this.accessLogService = accessLogService;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) {
chain.doFilter(request, response);
return;
}
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
if (RoutingFilter.isGatewayInternal(RoutingFilter.sanitizedUri(req))) {
chain.doFilter(request, response); // 网关自身端点不进 open_api 台账
return;
}
req.setAttribute(ATTR_START_NANOS, System.nanoTime());
try {
chain.doFilter(request, response);
} finally {
if (req.isAsyncStarted()) {
req.getAsyncContext().addListener(new LedgerListener(req, res));
} else {
record(req, res);
}
}
}
private void record(HttpServletRequest req, HttpServletResponse res) {
Object start = req.getAttribute(ATTR_START_NANOS);
int costMs = start instanceof Long ? (int) ((System.nanoTime() - (Long) start) / 1_000_000L) : 0;
// backendCode:流式转发零缓冲(T1.3),本期不解析提供方信封 code → null
accessLogService.recordRequest(req, res.getStatus(), null, costMs);
}
/** 异步收尾(complete/timeout/error)时记一次,重复触发由 claimed 挡住 */
private class LedgerListener implements AsyncListener {
private final HttpServletRequest req;
private final HttpServletResponse res;
private final java.util.concurrent.atomic.AtomicBoolean claimed =
new java.util.concurrent.atomic.AtomicBoolean();
LedgerListener(HttpServletRequest req, HttpServletResponse res) {
this.req = req;
this.res = res;
}
@Override
public void onComplete(AsyncEvent event) {
recordOnce();
}
@Override
public void onTimeout(AsyncEvent event) {
recordOnce();
}
@Override
public void onError(AsyncEvent event) {
recordOnce();
}
@Override
public void onStartAsync(AsyncEvent event) {
// 网关不嵌套异步:忽略
}
private void recordOnce() {
if (claimed.compareAndSet(false, true)) {
try {
record(req, res);
} catch (RuntimeException e) {
// 台账故障绝不影响请求收尾
}
}
}
}
}
@@ -0,0 +1,61 @@
package com.yunhai.platform.gateway.filter;
import com.yunhai.platform.gateway.envelope.EnvelopeWriter;
import com.yunhai.platform.gateway.forward.StreamingForwarder;
import com.yunhai.platform.gateway.route.TargetRouter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 链尾转发(T1.6 链序第 5 环):oapi 请求到此一律交给 {@link StreamingForwarder}
* 流式送提供方并原样回传,链在此终结(不再回到网关的 DispatcherServlet)。
* <p>
* 身份注入(链序第 4 环)在转发器内完成(T1.4 {@code CallerHeader}),所以本环只需带上
* 请求属性。缺路由 attribute(未经 RoutingFilter 的异常路径)按 404 收口,绝不裸转。
* 超时/体积暂无 per-route 覆盖(RouteSpec 不含该列),用转发器默认值。
*/
@Component
@Order(5)
public class DispatchFilter implements Filter {
@Autowired
private StreamingForwarder forwarder;
/** 测试/显式装配入口 */
void setForwarder(StreamingForwarder forwarder) {
this.forwarder = forwarder;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) {
chain.doFilter(request, response);
return;
}
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
String uri = RoutingFilter.sanitizedUri(req);
if (RoutingFilter.isGatewayInternal(uri)) {
chain.doFilter(request, response); // 自身端点:交给 controller,不经提供方
return;
}
Object routing = req.getAttribute(RoutingFilter.ATTR_ROUTING);
if (!(routing instanceof TargetRouter.Routing)) {
EnvelopeWriter.writeBare(res, 404);
return;
}
forwarder.forward(req, res, ((TargetRouter.Routing) routing).forwardUrl,
StreamingForwarder.DEFAULT_READ_TIMEOUT_MS, StreamingForwarder.DEFAULT_BODY_LIMIT_BYTES);
}
}
@@ -0,0 +1,89 @@
package com.yunhai.platform.gateway.filter;
import com.yunhai.platform.gateway.envelope.EnvelopeWriter;
import com.yunhai.platform.gateway.route.RouteRegistry;
import com.yunhai.platform.gateway.route.RouteSpec;
import com.yunhai.platform.gateway.route.TargetRouter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 路由判定(T1.6 链序第 2 环):Sanitize 之后、验签之前,把 URI 定到 target 与提供方
* ({@link TargetRouter},未知 target / 非 oapi 第二段 = <b>裸 404</b>,协议层错误永不放行),
* 并取注册表最严策略({@link RouteRegistry})存入 attribute 供后续环节消费。
* <p>
* {@code /api/internal/**} 属网关自身路径(如 T0.10 刷新端点),不进 oapi 链、不验签、不转发。
*/
@Component
@Order(2)
public class RoutingFilter implements Filter {
/** 网关自身管理端点前缀:绕开 oapi 判定链(T0.10 /api/internal/routes/refresh) */
public static final String INTERNAL_PREFIX = "/api/internal";
/** 路由产物 attribute({@link TargetRouter.Routing}) */
public static final String ATTR_ROUTING = "yh.gateway.routing";
/** 注册表策略 attribute({@link RouteSpec}) */
public static final String ATTR_ROUTE_SPEC = "yh.gateway.route.spec";
@Autowired
private TargetRouter targetRouter;
@Autowired
private RouteRegistry routeRegistry;
/** 测试/显式装配入口 */
void setTargetRouter(TargetRouter targetRouter) {
this.targetRouter = targetRouter;
}
void setRouteRegistry(RouteRegistry routeRegistry) {
this.routeRegistry = routeRegistry;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) {
chain.doFilter(request, response);
return;
}
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
String uri = sanitizedUri(req);
if (isGatewayInternal(uri)) {
chain.doFilter(request, response); // 自身端点:直达 controller
return;
}
TargetRouter.Routing routing = targetRouter.route(uri, req.getQueryString());
if (routing == null) {
EnvelopeWriter.writeBare(res, 404); // 404 属协议层,与 observe 无关
return;
}
req.setAttribute(ATTR_ROUTING, routing);
req.setAttribute(ATTR_ROUTE_SPEC,
routeRegistry.resolve(routing.target, routing.pathAfterStrip, req.getMethod()));
chain.doFilter(request, response);
}
/** 一律消费 Sanitize 归一化后的原始 URI;缺 attribute(未经 Sanitize)时退回 getRequestURI */
static String sanitizedUri(HttpServletRequest req) {
Object attr = req.getAttribute(SanitizeFilter.ATTR_SANITIZED_URI);
return attr instanceof String ? (String) attr : req.getRequestURI();
}
static boolean isGatewayInternal(String uri) {
return uri != null
&& (uri.equals(INTERNAL_PREFIX) || uri.startsWith(INTERNAL_PREFIX + "/"));
}
}
@@ -0,0 +1,104 @@
package com.yunhai.platform.gateway.filter;
import com.yunhai.platform.gateway.envelope.EnvelopeWriter;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 请求规整化(T1.1,安全链第一环):进转发链前对 URI 做解码归一化检查,
* 杜绝路径穿越/编码绕过。<b>只拒绝不修复</b>(不做自动纠错);query 部分不动
* (canonical 计算按 F1 排序规则处理)。
* <p>
* 拒绝(裸 400)条件——对 percent-decode 后的 URI 判定:
* 含 {@code ..}(含编码形态 {@code %2e%2e})、路径含双斜杠 {@code //}、
* 含控制字符(0x00-0x1f,含空字节)。
* <p>
* 通过后把<b>原始 URI</b>(逐字节不改写——T0.12 forwardUrl 与 T1.2 canonical 都消费原始形态)
* 与剥掉 target 首段后的路径存 request attribute,后续过滤器与转发器一律取此值。
*/
@Component
@Order(1)
public class SanitizeFilter implements Filter {
public static final String ATTR_SANITIZED_URI = "yh.gateway.sanitized.uri";
public static final String ATTR_STRIPPED_PATH = "yh.gateway.stripped.path";
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) {
chain.doFilter(request, response);
return;
}
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
String uri = req.getRequestURI();
String decoded = percentDecode(uri);
if (isRejected(decoded)) {
EnvelopeWriter.writeBare(res, 400);
return;
}
req.setAttribute(ATTR_SANITIZED_URI, uri);
req.setAttribute(ATTR_STRIPPED_PATH, stripTargetSegment(uri));
chain.doFilter(request, response);
}
/** 解码后含 ..、双斜杠、控制字符(含空字节)即拒 */
static boolean isRejected(String decodedUri) {
if (decodedUri == null || decodedUri.isEmpty()) {
return true;
}
if (decodedUri.contains("..")) {
return true;
}
if (decodedUri.contains("//")) {
return true;
}
for (int i = 0; i < decodedUri.length(); i++) {
if (decodedUri.charAt(i) <= '\u001f') {
return true;
}
}
return false;
}
/** percent-decode(仅 %XX;'+' 保持字面——path 部分 + 是合法字符,不走 query 规则) */
static String percentDecode(String s) {
if (s == null || s.indexOf('%') < 0) {
return s;
}
StringBuilder sb = new StringBuilder(s.length());
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '%' && i + 2 < s.length()) {
int hi = Character.digit(s.charAt(i + 1), 16);
int lo = Character.digit(s.charAt(i + 2), 16);
if (hi >= 0 && lo >= 0) {
sb.append((char) ((hi << 4) | lo));
i += 2;
continue;
}
}
sb.append(c);
}
return sb.toString();
}
/** 剥 target 首段:/userbase/oapi/x → /oapi/x;无第二段原样返回(target 判定是 TargetRouter 职责) */
static String stripTargetSegment(String uri) {
if (uri == null || uri.isEmpty()) {
return uri;
}
int second = uri.indexOf('/', 1);
return second < 0 ? uri : uri.substring(second);
}
}
@@ -0,0 +1,163 @@
package com.yunhai.platform.gateway.filter;
import com.yunhai.platform.gateway.auth.GatewayKeyService;
import com.yunhai.platform.gateway.envelope.EnvelopeWriter;
import com.yunhai.platform.gateway.route.GatewayProperties;
import com.yunhai.platform.utils.crypto.SignUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* v1 验签过滤器(T1.2,共享向量):canonical 与 starter
* {@code AppAuthFilter.buildSignedTarget + SignCanonical} 逐字节一致——
* {@code METHOD\n剥掉 target 首段后的 path[?解码排序query]\ntimestamp},HmacSHA256-Base64(F1),
* 与客户端"剥 basePath 后签名"严格对齐;比对复用 starter 侧同一 {@link SignUtil}(不重写算法),
* 时间窗 ±5min(F2,不得更宽)。
* <p>
* 头缺失 / appKey 未知或禁用({@link GatewayKeyService} null = fail-closed)/ 验签失败 /
* 时间窗超出 → 判定 FAIL:observe=true 只记台账(verdict=FAIL_OBSERVED)放行(R4),
* observe=false 裸 <b>401</b>(F5 契约)。判定结果经 request attribute 传递
* ({@link #ATTR_VERDICT} / {@link #ATTR_AUTH_APPKEY}),供 T1.4 身份注入与 T1.5 台账消费。
* <p>
* 链序第 3 环(T1.6 定型):Sanitize(1) → 路由判定(2) → 本过滤器(3) → 身份注入(4) → 转发(5)。
*/
@Component
@Order(3)
public class SignVerifyFilter implements Filter {
/** 判定结果 attribute(T1.5/T1.6 台账 verdict) */
public static final String ATTR_VERDICT = "yh.gateway.verdict";
/** 验签通过的 appKey(T1.4 身份注入唯一合法来源) */
public static final String ATTR_AUTH_APPKEY = "yh.gateway.auth.appKey";
public static final String VERDICT_PASS = "PASS";
public static final String VERDICT_FAIL = "FAIL";
public static final String VERDICT_FAIL_OBSERVED = "FAIL_OBSERVED";
public static final String VERDICT_ANON = "ANON";
@Autowired
private GatewayKeyService keyService;
@Autowired
private GatewayProperties properties;
/** 测试/显式装配入口(同 TargetRouter.setProperties 惯例) */
void setKeyService(GatewayKeyService keyService) {
this.keyService = keyService;
}
void setProperties(GatewayProperties properties) {
this.properties = properties;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) {
chain.doFilter(request, response);
return;
}
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
if (RoutingFilter.isGatewayInternal(RoutingFilter.sanitizedUri(req))) {
chain.doFilter(request, response); // 网关自身端点不经 oapi 验签
return;
}
boolean pass = false;
String appKey = req.getHeader("X-App-Key");
String timestamp = req.getHeader("X-Timestamp");
String signature = req.getHeader("X-Signature");
String strippedPath = (String) req.getAttribute(SanitizeFilter.ATTR_STRIPPED_PATH);
if (!isBlank(appKey) && !isBlank(timestamp) && !isBlank(signature) && strippedPath != null) {
String secret = keyService.getSecret(appKey); // null = 未知/禁用/异常,fail-closed
if (secret != null) {
String target = buildCanonicalTarget(strippedPath, req.getQueryString());
try {
// 复用 starter 同一验签实现:签名比对 + 时间窗(默认 ±5min,F2)
pass = SignUtil.verifyRequestWithExpire(req.getMethod(), target, timestamp, secret, signature);
} catch (RuntimeException e) {
pass = false; // 意外异常同样落 FAIL(判定组件绝不 500)
}
}
}
if (pass) {
req.setAttribute(ATTR_VERDICT, VERDICT_PASS);
req.setAttribute(ATTR_AUTH_APPKEY, appKey);
chain.doFilter(request, response);
return;
}
if (properties.isObserve()) {
// R4:P1 只观察——记台账不拦(台账写入由 T1.5/T1.6 消费本 attribute)
req.setAttribute(ATTR_VERDICT, VERDICT_FAIL_OBSERVED);
chain.doFilter(request, response);
} else {
req.setAttribute(ATTR_VERDICT, VERDICT_FAIL);
EnvelopeWriter.writeBare(res, 401);
}
}
/**
* canonical 的 target 部分 = path + (有 query 时 {@code ?解码排序query})——
* 与 starter {@code SignCanonical.buildTarget(sortRawQuery(...))} 同语义(共享向量钉死)。
*/
static String buildCanonicalTarget(String pathAfterStrip, String rawQuery) {
String sorted = sortRawQuery(rawQuery);
return sorted.isEmpty() ? pathAfterStrip : pathAfterStrip + "?" + sorted;
}
/** 复刻 starter SignCanonical.sortRawQuery:逐对解码、{@code k=v} 字典序排序、& 连接 */
static String sortRawQuery(String rawQuery) {
if (rawQuery == null || rawQuery.isEmpty()) {
return "";
}
List<String> pairs = new ArrayList<>();
for (String part : rawQuery.split("&")) {
if (part.isEmpty()) {
continue;
}
int idx = part.indexOf('=');
String k = idx < 0 ? part : part.substring(0, idx);
String v = idx < 0 ? "" : part.substring(idx + 1);
pairs.add(urlDecode(k) + "=" + urlDecode(v));
}
Collections.sort(pairs);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < pairs.size(); i++) {
if (i > 0) {
sb.append('&');
}
sb.append(pairs.get(i));
}
return sb.toString();
}
private static String urlDecode(String s) {
try {
return URLDecoder.decode(s, StandardCharsets.UTF_8.name());
} catch (UnsupportedEncodingException e) {
return s;
}
}
private static boolean isBlank(String s) {
return s == null || s.trim().isEmpty();
}
}
@@ -0,0 +1,32 @@
package com.yunhai.platform.gateway.forward;
import com.yunhai.platform.gateway.filter.SignVerifyFilter;
import javax.servlet.http.HttpServletRequest;
/**
* 身份注入判定(T1.4):仅当验签链判定 PASS 时,转发请求注入
* {@code X-Caller-App-Key: {验签 appKey}};FAIL/FAIL_OBSERVED/ANON(含 OPTIONAL 未签名路径)
* 一律不注入——匿名就是匿名。注入动作发生在 {@link StreamingForwarder} 剥除客户端伪造头
* <b>之后</b>(顺序保证身份头唯一合法来源是网关);不注入任何其他身份字段
* (oapi 是应用级调用,不做用户级透传)。
*/
final class CallerHeader {
private CallerHeader() {
}
/**
* @return 应注入的 appKey;非 PASS 或无验签 appKey → null = 不注入
*/
static String valueFor(HttpServletRequest req) {
if (!SignVerifyFilter.VERDICT_PASS.equals(req.getAttribute(SignVerifyFilter.ATTR_VERDICT))) {
return null;
}
Object appKey = req.getAttribute(SignVerifyFilter.ATTR_AUTH_APPKEY);
if (!(appKey instanceof String) || ((String) appKey).trim().isEmpty()) {
return null;
}
return (String) appKey;
}
}
@@ -0,0 +1,262 @@
package com.yunhai.platform.gateway.forward;
import com.yunhai.platform.gateway.envelope.EnvelopeWriter;
import org.apache.http.HttpEntity;
import org.apache.http.Header;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.client.methods.RequestBuilder;
import org.apache.http.entity.InputStreamEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import javax.annotation.PreDestroy;
import javax.servlet.AsyncContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* 流式转发器(T1.3):网关 → 提供方的 HTTP 转发,请求/响应 body 均<b>流式透传</b>
* (不聚合、不落盘、不改写响应体),经异步 Servlet({@link AsyncContext})+ Apache HttpClient 执行,
* 请求线程不阻塞等待后端。
* <ul>
* <li>转发 URL 取 T0.12 {@code TargetRouter.Routing.forwardUrl}(换主机不换路径);</li>
* <li>读超时默认 30s({@link #DEFAULT_READ_TIMEOUT_MS};route.timeout_ms 覆盖,本期注册表不配 = 统一默认);</li>
* <li>请求头:保留 {@code X-App-Key / X-Timestamp / X-Signature / X-Nonce} 原样透传
* (UserBase 自验签依赖,R3);剥除客户端伪造的 {@code X-Caller-App-Key}(身份头唯一合法来源是
* 网关,T1.4 注入);剥除 hop-by-hop 头(Connection/Keep-Alive/Transfer-Encoding/Content-Length
* 由客户端库重算);</li>
* <li>body 上限 route.request_body_limit_mb(默认 10MB),超限<b>裸 413</b>;</li>
* <li>响应 status/headers/body 原样透传(不经 EnvelopeWriter);连接失败 → <b>裸 502</b>,
* 超时 → <b>裸 504</b>。</li>
* </ul>
*/
@Component
public class StreamingForwarder {
/** 读超时默认 30s(route.timeout_ms 未配时的统一默认) */
public static final int DEFAULT_READ_TIMEOUT_MS = 30_000;
/** body 上限默认 10MB(route.request_body_limit_mb 未配时的统一默认) */
public static final long DEFAULT_BODY_LIMIT_BYTES = 10L * 1024 * 1024;
/** 连接建立超时(容器网内建连应极快;非卡参数,防御挂死的 SYN) */
private static final int CONNECT_TIMEOUT_MS = 5_000;
/** 身份头:转发时剥除客户端伪造值,注入权属网关(T1.4) */
public static final String HEADER_CALLER_APP_KEY = "X-Caller-App-Key";
private static final Logger log = LoggerFactory.getLogger(StreamingForwarder.class);
/** RFC 2616 hop-by-hop + 由客户端库重算的头 */
private static final Set<String> REQ_SKIP;
private static final Set<String> RES_SKIP;
static {
Set<String> s = new HashSet<>();
Collections.addAll(s, "connection", "keep-alive", "proxy-connection", "proxy-authenticate",
"proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade",
"content-length", "host", HEADER_CALLER_APP_KEY.toLowerCase());
REQ_SKIP = s;
Set<String> r = new HashSet<>();
Collections.addAll(r, "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailer", "transfer-encoding", "upgrade");
RES_SKIP = r;
}
private final CloseableHttpClient client;
private final Executor executor;
private final ExecutorService ownedPool;
/** Spring 装配:默认连接池 client + 异步执行线程池(IO 密集,缓存池) */
public StreamingForwarder() {
this.client = HttpClients.custom()
.disableRedirectHandling()
.setMaxConnTotal(400)
.setMaxConnPerRoute(200)
.build();
this.ownedPool = Executors.newCachedThreadPool(r -> {
Thread t = new Thread(r, "gw-forward");
t.setDaemon(true);
return t;
});
this.executor = ownedPool;
}
/** 测试/显式装配入口(同 TargetRouter.setProperties 惯例):注入 client 与执行器 */
StreamingForwarder(CloseableHttpClient client, Executor executor) {
this.client = client;
this.executor = executor;
this.ownedPool = null;
}
@PreDestroy
public void shutdown() throws IOException {
client.close();
if (ownedPool != null) {
ownedPool.shutdown();
try {
ownedPool.awaitTermination(5, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
/**
* 转发并原样回写。声明长度超限在<b>建连之前</b>拦(裸 413);否则转异步:
* 工作线程流式收发,完成后 {@link AsyncContext#complete()}。
*/
public void forward(HttpServletRequest req, HttpServletResponse res, String forwardUrl,
int readTimeoutMs, long bodyLimitBytes) throws IOException {
if (req.getContentLengthLong() > bodyLimitBytes) {
EnvelopeWriter.writeBare(res, 413);
return;
}
final AsyncContext ctx = req.startAsync(req, res);
executor.execute(() -> {
try {
doForward(req, res, forwardUrl, readTimeoutMs, bodyLimitBytes);
} catch (BodyTooLargeException e) {
writeBareGuard(res, 413); // 未声明长度的 chunked 请求在流式读取中超限
} catch (SocketTimeoutException e) {
writeBareGuard(res, 504);
} catch (IOException e) {
if (hasBodyTooLargeCause(e)) {
writeBareGuard(res, 413);
} else {
log.warn("[GatewayForward] backend io failure url={}", forwardUrl, e);
writeBareGuard(res, 502); // 连接失败/协议断流
}
} catch (RuntimeException e) {
log.error("[GatewayForward] unexpected forward error url={}", forwardUrl, e);
writeBareGuard(res, 502);
} finally {
ctx.complete();
}
});
}
private void doForward(HttpServletRequest req, HttpServletResponse res, String forwardUrl,
int readTimeoutMs, long bodyLimitBytes) throws IOException {
RequestBuilder rb = RequestBuilder.create(req.getMethod())
.setUri(URI.create(forwardUrl))
.setConfig(RequestConfig.custom()
.setConnectTimeout(CONNECT_TIMEOUT_MS)
.setSocketTimeout(readTimeoutMs)
.setConnectionRequestTimeout(CONNECT_TIMEOUT_MS)
.setRedirectsEnabled(false)
.build());
Enumeration<String> names = req.getHeaderNames();
while (names != null && names.hasMoreElements()) {
String name = names.nextElement();
if (REQ_SKIP.contains(name.toLowerCase())) {
continue;
}
Enumeration<String> values = req.getHeaders(name);
while (values.hasMoreElements()) {
rb.addHeader(name, values.nextElement());
}
}
// T1.4:伪造头已在上方剥除,此处为唯一合法注入点(PASS 才有值)
String callerAppKey = CallerHeader.valueFor(req);
if (callerAppKey != null) {
rb.addHeader(HEADER_CALLER_APP_KEY, callerAppKey);
}
String method = req.getMethod().toUpperCase();
if (!"GET".equals(method) && !"HEAD".equals(method)
&& !"OPTIONS".equals(method) && !"TRACE".equals(method)) {
// 长度未知 → chunked 交由 HttpClient 重算;流式读取、超上限即断
rb.setEntity(new InputStreamEntity(new LimitedInputStream(req.getInputStream(), bodyLimitBytes), -1));
}
HttpUriRequest request = rb.build();
try (CloseableHttpResponse up = client.execute(request)) {
res.setStatus(up.getStatusLine().getStatusCode());
for (Header h : up.getAllHeaders()) {
if (!RES_SKIP.contains(h.getName().toLowerCase())) {
res.addHeader(h.getName(), h.getValue());
}
}
HttpEntity entity = up.getEntity();
if (entity != null) {
entity.writeTo(res.getOutputStream());
}
}
}
private static void writeBareGuard(HttpServletResponse res, int status) {
try {
if (!res.isCommitted()) {
EnvelopeWriter.writeBare(res, status);
}
} catch (IOException ignored) {
// 裸码写出失败无从补救(响应面已坏)
}
}
private static boolean hasBodyTooLargeCause(Throwable e) {
for (Throwable t = e; t != null; t = t.getCause()) {
if (t instanceof BodyTooLargeException) {
return true;
}
}
return false;
}
/** 流式 body 上限哨兵异常 */
static class BodyTooLargeException extends IOException {
BodyTooLargeException() {
super("request body exceeds limit");
}
}
/** 计数透传流:超过 limit 立即抛哨兵异常(绝不缓冲整体) */
private static final class LimitedInputStream extends FilterInputStream {
private final long limit;
private long count;
LimitedInputStream(InputStream in, long limit) {
super(in);
this.limit = limit;
}
private void counted(long n) throws BodyTooLargeException {
if (n > 0) {
count += n;
if (count > limit) {
throw new BodyTooLargeException();
}
}
}
@Override
public int read() throws IOException {
int b = super.read();
if (b >= 0) {
counted(1);
}
return b;
}
@Override
public int read(byte[] b, int off, int len) throws IOException {
int n = super.read(b, off, len);
counted(n);
return n;
}
}
}
@@ -0,0 +1,267 @@
package com.yunhai.platform.gateway.log;
import com.yunhai.platform.gateway.entity.OpenApiLog;
import com.yunhai.platform.gateway.entity.OpenApiStatDaily;
import com.yunhai.platform.gateway.filter.SanitizeFilter;
import com.yunhai.platform.gateway.filter.SignVerifyFilter;
import com.yunhai.platform.gateway.repository.OpenApiLogRepository;
import com.yunhai.platform.gateway.repository.OpenApiStatDailyRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import javax.servlet.http.HttpServletRequest;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
/**
* 台账异步落库(T1.5,有界队列 + 背压):全量请求观测(GET/401 也记),
* 台账故障绝不拖垮请求线程——台账是观测设施,不能反杀业务。
* <ul>
* <li>{@link ArrayBlockingQueue} 容量 {@value #QUEUE_CAPACITY},{@link #record} 只 offer 不阻塞;</li>
* <li>单刷盘线程:满 {@value #BATCH_SIZE} 条或 {@value #FLUSH_INTERVAL_MS}ms 触发一批
* JPA 批量插入 {@code open_api_log},成功后按日 upsert {@code open_api_stat_daily};</li>
* <li>offer 失败(队列满)→ 原子计数 +1 + ERROR 日志(含累计丢弃数),绝不抛异常;</li>
* <li>关停钩子:应用停止时 flush 余量(尽力而为)。</li>
* </ul>
* 日统计维度 caller:台账 caller 为 NULL(401 无 appKey)时归入 {@value #STAT_CALLER_UNKNOWN}
* (stat 表该列 NOT NULL,观测聚合需要一个兜底桶)。
*/
@Component
public class AccessLogService {
public static final int QUEUE_CAPACITY = 10_000;
public static final int BATCH_SIZE = 200;
public static final long FLUSH_INTERVAL_MS = 500L;
public static final String STAT_CALLER_UNKNOWN = "unknown";
/** ANON 判定下台账 caller 口径 */
public static final String CALLER_ANONYMOUS = "anonymous";
private static final Logger log = LoggerFactory.getLogger(AccessLogService.class);
private final ArrayBlockingQueue<OpenApiLog> queue;
private final AtomicLong dropped = new AtomicLong();
private final AtomicBoolean flushPending = new AtomicBoolean();
@Autowired
private OpenApiLogRepository logRepository;
@Autowired
private OpenApiStatDailyRepository statRepository;
/** 刷盘任务派发器:生产 = 单刷盘线程;测试注入同线程执行器作同步触发点 */
private Executor flushDispatcher;
private ScheduledExecutorService scheduler;
public AccessLogService() {
this(QUEUE_CAPACITY);
}
/** 测试构造:缩小容量以复现背压(生产路径固定 QUEUE_CAPACITY) */
AccessLogService(int capacity) {
this.queue = new ArrayBlockingQueue<>(capacity);
}
void setFlushDispatcher(Executor flushDispatcher) {
this.flushDispatcher = flushDispatcher;
}
int queueSize() {
return queue.size();
}
long droppedCount() {
return dropped.get();
}
@PostConstruct
void start() {
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "gw-access-log-flush");
t.setDaemon(true);
return t;
});
if (flushDispatcher == null) {
flushDispatcher = scheduler;
}
scheduler.scheduleWithFixedDelay(() -> {
try {
flushOnce();
} catch (RuntimeException e) {
log.error("[AccessLog] scheduled flush failed", e);
}
}, FLUSH_INTERVAL_MS, FLUSH_INTERVAL_MS, TimeUnit.MILLISECONDS);
}
@PreDestroy
void stop() {
if (scheduler != null) {
scheduler.shutdown();
try {
scheduler.awaitTermination(5, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
// 关停钩子:flush 余量(尽力而为)
try {
while (flushOnce() > 0) {
// 排空
}
} catch (RuntimeException e) {
log.error("[AccessLog] shutdown flush failed", e);
}
}
/**
* 请求线程入口:只入队,绝不同步写库、绝不阻塞。
*/
public void record(OpenApiLog entry) {
if (entry.getTs() == null) {
entry.setTs(LocalDateTime.now());
}
if (!queue.offer(entry)) {
long total = dropped.incrementAndGet();
log.error("[AccessLog] queue FULL, entry dropped. accumulatedDropped={}", total);
return;
}
if (queue.size() >= BATCH_SIZE) {
scheduleFlush();
}
}
/**
* 按卡口径从请求组装并记录一条台账(T1.6 链上过滤器消费):
* path = 剥段后路径;caller = ANON 记 anonymous、PASS 记验签 appKey、
* 否则尽力取 {@code X-App-Key} 头(401 无 appKey 记 NULL);status = 网关出口状态。
*
* @param backendCode 提供方业务 code(响应为 JSON 信封时尽力解析,否则 null)
*/
public void recordRequest(HttpServletRequest req, int status, Integer backendCode, int costMs) {
OpenApiLog entry = new OpenApiLog();
entry.setTs(LocalDateTime.now());
entry.setTargetCode(truncate(targetOf(req), 32));
entry.setCallerAppKey(truncate(callerOf(req), 64));
entry.setMethod(req.getMethod());
Object stripped = req.getAttribute(SanitizeFilter.ATTR_STRIPPED_PATH);
entry.setPath(truncate(stripped instanceof String ? (String) stripped : req.getRequestURI(), 256));
entry.setStatus(status);
entry.setBackendCode(backendCode);
entry.setCostMs(costMs);
record(entry);
}
private void scheduleFlush() {
Executor dispatcher = flushDispatcher;
if (dispatcher == null) {
return; // 未初始化(单元/异常场景):500ms 周期兜底,不阻塞请求线程
}
if (flushPending.compareAndSet(false, true)) {
dispatcher.execute(() -> {
flushPending.set(false);
try {
flushOnce();
} catch (RuntimeException e) {
log.error("[AccessLog] batch flush failed", e);
}
});
}
}
/** 同步触发点(满批/周期/关停共用):排空至多一批 → 批量插入 → 按日 upsert 统计 */
synchronized int flushOnce() {
List<OpenApiLog> batch = new ArrayList<>(BATCH_SIZE);
queue.drainTo(batch, BATCH_SIZE);
if (batch.isEmpty()) {
return 0;
}
logRepository.saveAll(batch);
upsertDaily(batch);
return batch.size();
}
private void upsertDaily(List<OpenApiLog> batch) {
Map<String, OpenApiStatDaily> grouped = new LinkedHashMap<>();
for (OpenApiLog e : batch) {
String caller = e.getCallerAppKey() == null ? STAT_CALLER_UNKNOWN : e.getCallerAppKey();
String key = e.getTs().toLocalDate() + "|" + e.getTargetCode() + "|" + caller;
OpenApiStatDaily s = grouped.get(key);
if (s == null) {
s = new OpenApiStatDaily();
s.setStatDate(e.getTs().toLocalDate());
s.setTargetCode(e.getTargetCode());
s.setCallerAppKey(caller);
grouped.put(key, s);
}
s.setTotal(s.getTotal() + 1);
if (isErr(e)) {
s.setErrTotal(s.getErrTotal() + 1);
}
s.setAvgCostMs((int) ((long) s.getAvgCostMs() * (s.getTotal() - 1) + e.getCostMs()) / s.getTotal());
}
for (OpenApiStatDaily fresh : grouped.values()) {
OpenApiStatDaily existing = statRepository.findFirstByStatDateAndTargetCodeAndCallerAppKey(
fresh.getStatDate(), fresh.getTargetCode(), fresh.getCallerAppKey());
if (existing != null) {
int total = existing.getTotal() + fresh.getTotal();
existing.setErrTotal(existing.getErrTotal() + fresh.getErrTotal());
existing.setAvgCostMs((int) (((long) existing.getAvgCostMs() * existing.getTotal()
+ (long) fresh.getAvgCostMs() * fresh.getTotal()) / total));
existing.setTotal(total);
statRepository.save(existing);
} else {
statRepository.save(fresh);
}
}
}
private static boolean isErr(OpenApiLog e) {
return e.getStatus() >= 400 || (e.getBackendCode() != null && e.getBackendCode() != 0);
}
/** target = 规整化 URI 首段;取不到记 unknown(列 NOT NULL,台账一条都不能丢在写入端) */
private static String targetOf(HttpServletRequest req) {
Object uri = req.getAttribute(SanitizeFilter.ATTR_SANITIZED_URI);
String path = uri instanceof String ? (String) uri : req.getRequestURI();
if (path == null || path.length() < 2) {
return STAT_CALLER_UNKNOWN;
}
int second = path.indexOf('/', 1);
return second < 0 ? path.substring(1) : path.substring(1, second);
}
private static String callerOf(HttpServletRequest req) {
Object verdict = req.getAttribute(SignVerifyFilter.ATTR_VERDICT);
if (SignVerifyFilter.VERDICT_ANON.equals(verdict)) {
return CALLER_ANONYMOUS;
}
if (SignVerifyFilter.VERDICT_PASS.equals(verdict)) {
Object appKey = req.getAttribute(SignVerifyFilter.ATTR_AUTH_APPKEY);
if (appKey instanceof String && !((String) appKey).trim().isEmpty()) {
return (String) appKey;
}
}
String header = req.getHeader("X-App-Key");
return header == null || header.trim().isEmpty() ? null : header.trim(); // 401 无 appKey = NULL
}
private static String truncate(String s, int max) {
if (s == null) {
return null;
}
return s.length() <= max ? s : s.substring(0, max);
}
}
@@ -0,0 +1,7 @@
package com.yunhai.platform.gateway.repository;
import com.yunhai.platform.gateway.entity.OpenApiLog;
import org.springframework.data.jpa.repository.JpaRepository;
public interface OpenApiLogRepository extends JpaRepository<OpenApiLog, Long> {
}
@@ -0,0 +1,11 @@
package com.yunhai.platform.gateway.repository;
import com.yunhai.platform.gateway.entity.OpenApiRoute;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface OpenApiRouteRepository extends JpaRepository<OpenApiRoute, Long> {
List<OpenApiRoute> findByEnabledTrue();
}
@@ -0,0 +1,13 @@
package com.yunhai.platform.gateway.repository;
import com.yunhai.platform.gateway.entity.OpenApiStatDaily;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDate;
public interface OpenApiStatDailyRepository extends JpaRepository<OpenApiStatDaily, Long> {
/** uk_stat 维度定位(T1.5 按日 upsert):stat_date + target_code + caller_app_key */
OpenApiStatDaily findFirstByStatDateAndTargetCodeAndCallerAppKey(
LocalDate statDate, String targetCode, String callerAppKey);
}
@@ -0,0 +1,42 @@
package com.yunhai.platform.gateway.route;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 网关配置(T0.12 起;环境值走平台配置层,jar 内 yml 只留占位)。
* <ul>
* <li>routes:target → base-url(容器网内服务直连 URL,如 http://userbase-service:8080;
* target = F11 七个生产 ctx);</li>
* <li>observe:观察模式(T1.6 消费;默认 true = 只记不拦,R4)。</li>
* </ul>
*/
@Component
@ConfigurationProperties(prefix = "yh.gateway")
public class GatewayProperties {
/** target → base-url(值不含路径;转发 = base-url + 完整原始 URI,换主机不换路径) */
private Map<String, String> routes = new LinkedHashMap<>();
/** 观察模式:true = 全判定只记不拦(P1 只观察,R4) */
private boolean observe = true;
public Map<String, String> getRoutes() {
return routes;
}
public void setRoutes(Map<String, String> routes) {
this.routes = routes;
}
public boolean isObserve() {
return observe;
}
public void setObserve(boolean observe) {
this.observe = observe;
}
}
@@ -0,0 +1,30 @@
package com.yunhai.platform.gateway.route;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 网关内部管理端点(T0.10):/api/internal/** 属网关自身路径,收网后仅容器网内可达,
* 不注册进 open_api_route(注册表只治理 /{target}/oapi/**)。
*/
@RestController
@RequestMapping("/api/internal")
public class RouteRefreshController {
@Autowired
private RouteRegistry routeRegistry;
/** 清缓存重 load,返回加载数 */
@PostMapping("/routes/refresh")
public Map<String, Object> refresh() {
int loaded = routeRegistry.reload();
Map<String, Object> body = new LinkedHashMap<>();
body.put("loaded", loaded);
return body;
}
}
@@ -0,0 +1,104 @@
package com.yunhai.platform.gateway.route;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.yunhai.platform.gateway.entity.OpenApiRoute;
import com.yunhai.platform.gateway.repository.OpenApiRouteRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 路由注册表 Caffeine 装载与判定(T0.10):路由判定 O(1) 不查库,变更后手动刷新即生效。
* <p>
* 条目量 &lt;100,不给 Caffeine 设 expire、不引 Redis——全量重 load 即同步。
* 匹配坐标系:pathAfterStrip = 剥掉 target 首段后的路径(如 /oapi/message/list),
* 与注册表 path_pattern 同源。先精确匹配,再 {@code /**} 前缀通配;未命中返回
* {@link RouteSpec#DEFAULT}(最严默认,忘配不裸奔)。
*/
@Component
public class RouteRegistry {
private static class Entry {
final String pattern;
final String httpMethod;
final RouteSpec spec;
Entry(OpenApiRoute r) {
this.pattern = r.getPathPattern();
this.httpMethod = r.getHttpMethod();
this.spec = new RouteSpec(r.getVisibility(), r.getAuthMode(), r.getCacheTtlSeconds(),
r.getCacheScope(), r.getRateLimitQps());
}
}
@Autowired
private OpenApiRouteRepository routeRepo;
/** key = targetCode;无 expire(条目 <100,靠手动刷新) */
private final Cache<String, List<Entry>> cache = Caffeine.newBuilder().build();
@PostConstruct
public void init() {
reload();
}
/** 全量重 load(@PostConstruct 与 refresh 端点共用);返回加载数 */
public int reload() {
Map<String, List<Entry>> fresh = new HashMap<>();
int count = 0;
for (OpenApiRoute r : routeRepo.findByEnabledTrue()) {
if (!r.isEnabled()) {
continue; // 防御双保险:repo 已按 enabled=true 过滤,注册表自身再滤一层
}
fresh.computeIfAbsent(r.getTargetCode(), k -> new ArrayList<>()).add(new Entry(r));
count++;
}
cache.invalidateAll();
cache.asMap().putAll(fresh);
return count;
}
/**
* 路由判定。method 参与匹配(条目 * 或精确相等);精确 pattern 优先于 /** 通配。
*/
public RouteSpec resolve(String targetCode, String pathAfterStrip, String method) {
List<Entry> entries = cache.asMap().get(targetCode);
if (entries != null) {
Entry wildcardHit = null;
for (Entry e : entries) {
if (!methodMatches(e.httpMethod, method)) {
continue;
}
if (e.pattern.equals(pathAfterStrip)) {
return e.spec; // 精确命中,立即返回(优先于通配)
}
if (wildcardHit == null && isWildcardHit(e.pattern, pathAfterStrip)) {
wildcardHit = e; // 先记录,精确命中仍在后续条目时让位
}
}
if (wildcardHit != null) {
return wildcardHit.spec;
}
}
return RouteSpec.DEFAULT;
}
private static boolean methodMatches(String patternMethod, String method) {
return "*".equals(patternMethod) || patternMethod.equalsIgnoreCase(method);
}
/** 仅支持「prefix/**」形态的 Ant 前缀通配(本阶段注册表唯一通配形态) */
private static boolean isWildcardHit(String pattern, String path) {
if (!pattern.endsWith("/**")) {
return false;
}
String prefix = pattern.substring(0, pattern.length() - 3);
return path.equals(prefix) || path.startsWith(prefix + "/");
}
}
@@ -0,0 +1,26 @@
package com.yunhai.platform.gateway.route;
/**
* 路由判定结果(T0.10):注册表条目中参与网关判定的五字段提炼。
* 未命中时返回 {@link #DEFAULT}——默认值安全:忘配 = 最严判定(INTERNAL+SIGNED+零缓存+不限流),
* 不是裸奔。
*/
public final class RouteSpec {
public static final RouteSpec DEFAULT = new RouteSpec("INTERNAL", "SIGNED", 0, "CALLER", 0);
public final String visibility;
public final String authMode;
public final int cacheTtlSeconds;
public final String cacheScope;
public final int rateLimitQps;
public RouteSpec(String visibility, String authMode, int cacheTtlSeconds,
String cacheScope, int rateLimitQps) {
this.visibility = visibility;
this.authMode = authMode;
this.cacheTtlSeconds = cacheTtlSeconds;
this.cacheScope = cacheScope;
this.rateLimitQps = rateLimitQps;
}
}
@@ -0,0 +1,77 @@
package com.yunhai.platform.gateway.route;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 目标路由与转发 URL 计算(T0.12):{@code /{target}/oapi/**} → 提供方 base-url 的映射。
* <p>
* <b>换主机不换路径</b>:forwardUrl = base-url + 完整原始 URI(含 query)。target 首段仅用于
* 路由选择、注册表匹配与 canonical 剥离,不参与转发路径改写——提供方自带 context-path,
* URI 原样送达即天然对齐,签名 canonical 逐字节不变。<br>
* 网关只做 oapi:未知 target / 单段 URI / 第二段非 oapi(如 /{target}/api/x)一律返回 null
* = <b>裸 404</b>({@code /api} 与页面不属于网关职责)。
*/
@Component
public class TargetRouter {
/** 路由产物:判定字段 + 转发 URL */
public static class Routing {
public final String target;
/** 剥掉 target 首段后的路径(如 /oapi/message/list)——RouteRegistry 与签名 canonical 同一坐标系 */
public final String pathAfterStrip;
/** base-url + 完整原始 URI(含 query),逐字符不改写 */
public final String forwardUrl;
Routing(String target, String pathAfterStrip, String forwardUrl) {
this.target = target;
this.pathAfterStrip = pathAfterStrip;
this.forwardUrl = forwardUrl;
}
}
@Autowired
private GatewayProperties properties;
/** 测试/显式装配入口 */
void setProperties(GatewayProperties properties) {
this.properties = properties;
}
/**
* @param requestUri 原始请求 URI(未解码,如 /userbase/oapi/x)
* @param queryString 原始 query(无 '?' 前缀;null = 无)
* @return 路由产物;不匹配(未知 target / 单段 / 非 oapi 第二段)返回 null = 裸 404
*/
public Routing route(String requestUri, String queryString) {
if (requestUri == null || requestUri.isEmpty() || requestUri.equals("/")) {
return null;
}
// 剥首段 target:/userbase/oapi/x → target=userbase, rest=/oapi/x
int secondSlash = requestUri.indexOf('/', 1);
String target;
String rest;
if (secondSlash < 0) {
return null; // 单段 URI(/foo)——无 oapi 第二段
} else {
target = requestUri.substring(1, secondSlash);
rest = requestUri.substring(secondSlash);
}
if (target.isEmpty()) {
return null; // 双斜杠开头等畸形
}
// 网关只做 oapi:第二段必须恰为 oapi(/oapi、/oapi/xxx 均可)
if (!rest.equals("/oapi") && !rest.startsWith("/oapi/")) {
return null;
}
String baseUrl = properties.getRoutes().get(target);
if (baseUrl == null || baseUrl.trim().isEmpty()) {
return null; // 未知 target = 裸 404
}
String normalizedBase = baseUrl.endsWith("/")
? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
String url = normalizedBase + requestUri
+ (queryString == null || queryString.isEmpty() ? "" : "?" + queryString);
return new Routing(target, rest, url);
}
}
+61
View File
@@ -0,0 +1,61 @@
server:
# 网关默认端口(projman 注册档案:port=10010;compose 模板端口注入以盘点结论为准)
port: 10010
spring:
application:
name: open-gateway
datasource:
# 注册表/台账库(open_gateway)——连接串由平台配置层注入,jar 内不留环境值
url: # 例(平台配置层):jdbc:mysql://<host>:<port>/open_gateway?useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: #
password: #
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
# 表结构由 docs/sql/01_schema.sql 交付,应用不自动建表
ddl-auto: none
properties:
hibernate:
# 台账批量刷盘(T1.5):一批 JPA batch insert
jdbc:
batch_size: 200
order_inserts: true
redis:
# 密钥链(sign:app:{appKey})走共享 Redis db0——host/port 由平台配置层注入。
# 注意:jar 内只留注释,不留 `host:` 空键(空值绑不到 int 会让 context 起不来)
# host: <平台配置层>
# port: <平台配置层>
main:
allow-bean-definition-overriding: true
management:
endpoints:
web:
exposure:
include: health
endpoint:
health:
probes:
enabled: true
yh:
# starter 的服务端验签过滤器(AppAuthFilter,注册在 HIGHEST_PRECEDENCE+10)必须关掉:
# 网关自身的 SignVerifyFilter(T1.2) 是唯一验签面,否则它会抢在链首把合法流量按
# "完整 URI 含 target 段"验签 → 全部 401。注意前缀是 yh.app-auth(不是 yh.app.auth)。
# 密钥链 SignKeyRedisAutoConfiguration 独立装配,不受此开关影响(GatewayKeyService 仍可用)。
app-auth:
enabled: false
gateway:
# 路由表(T0.12):target -> base-url,值 = 容器网内服务直连 URL,走平台配置层
# (只留注释不留空键:空值会让 Binder 把 Map 绑成 null)
# routes:
# userbase: http://userbase-service:8080
# userclass: http://userclass-service:8080
# cms: http://cms-service:8080
# svc-hall: http://svc-hall-service:8080
# flow-center: http://flow-center-service:8080
# usermsg: http://usermsg-service:8080
# notice: http://notice-service:8080
# 观察模式(T1.6):true = 全判定只记不拦(R4:P1 只观察);生产初期保持 true
observe: true
@@ -0,0 +1,65 @@
package com.yunhai.platform.gateway.auth;
import com.yunhai.platform.appauth.SignKeyResolver;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* GatewayKeyService 四态(T0.11 TDD,mock SignKeyResolver):
* 合法 / 未知 / 禁用 / Redis 异常。D4 红线:密钥解析永远 fail-closed——
* 异常态断言"拒绝"(返回 null)而非抛异常(500)。
*/
class GatewayKeyServiceTest {
private GatewayKeyService service(SignKeyResolver resolver) {
GatewayKeyService svc = new GatewayKeyService();
ReflectionTestUtils.setField(svc, "signKeyResolver", resolver);
return svc;
}
@Test
void 合法appKey返回secret() {
SignKeyResolver resolver = mock(SignKeyResolver.class);
when(resolver.getSecret("100617")).thenReturn("secret-flow-center");
assertThat(service(resolver).getSecret("100617")).isEqualTo("secret-flow-center");
}
@Test
void 未知appKey拒绝() {
// starter 契约:Redis 无该 key(未知 app)返回 null
SignKeyResolver resolver = mock(SignKeyResolver.class);
when(resolver.getSecret("999999")).thenReturn(null);
assertThat(service(resolver).getSecret("999999")).as("未知 appKey = 拒绝").isNull();
}
@Test
void 禁用appKey拒绝() {
// starter SignKeyResolver 内部对 enabled=false / secret 空 fallen 返回 null(fail-closed 单层);
// 网关侧语义与 starter 完全一致——到达本封装即为 null
SignKeyResolver resolver = mock(SignKeyResolver.class);
when(resolver.getSecret("100009")).thenReturn(null);
assertThat(service(resolver).getSecret("100009")).as("禁用 appKey = 拒绝").isNull();
}
@Test
void redis异常拒绝而非抛500() {
SignKeyResolver resolver = mock(SignKeyResolver.class);
when(resolver.getSecret("100617")).thenThrow(new RuntimeException("redis unavailable"));
GatewayKeyService svc = service(resolver);
assertThatCode(() -> {
assertThat(svc.getSecret("100617")).as("Redis 异常 = 拒绝(fail-closed),不抛 500").isNull();
}).doesNotThrowAnyException();
Mockito.verify(resolver).getSecret("100617");
}
@Test
void starter未装配同样拒绝() {
assertThat(service(null).getSecret("100617")).as("无 SignKeyResolver bean(未配 Redis)同样 fail-closed").isNull();
}
}
@@ -0,0 +1,182 @@
package com.yunhai.platform.gateway.chain;
import com.sun.net.httpserver.HttpServer;
import com.yunhai.platform.gateway.auth.GatewayKeyService;
import com.yunhai.platform.gateway.entity.OpenApiLog;
import com.yunhai.platform.gateway.repository.OpenApiLogRepository;
import com.yunhai.platform.utils.crypto.SignUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
/**
* T1.6 链序集成测试(observe=true 默认):Sanitize(0/1) → Routing(2) → SignVerify(3) →
* 身份注入(4,转发器内) → Dispatch(5) 全链走通——假后端收到注入的 X-Caller-App-Key、
* 伪造值被剥、响应原样透传、台账异步入库;observe 语义边界:坏签名放行、
* 但协议层错误(400/404)不受 observe 影响永远拦;/api/internal/** 自身端点不被链拦截。
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class GatewayChainTest {
private static final String SECRET = "sec-chain-0123456789abcdef";
private static final HttpServer BACKEND;
private static final int BACKEND_PORT;
static final AtomicReference<String> CALLER_SEEN = new AtomicReference<>();
static final AtomicReference<String> URI_SEEN = new AtomicReference<>();
static {
try {
BACKEND = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
BACKEND.setExecutor(Executors.newFixedThreadPool(4));
BACKEND.createContext("/", exchange -> {
CALLER_SEEN.set(exchange.getRequestHeaders().getFirst("X-Caller-App-Key"));
URI_SEEN.set(exchange.getRequestURI().toString());
byte[] body = "pong".getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("X-Upstream", "v1");
exchange.sendResponseHeaders(200, body.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
exchange.close();
});
BACKEND.start();
BACKEND_PORT = BACKEND.getAddress().getPort();
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
@DynamicPropertySource
static void routes(DynamicPropertyRegistry registry) {
registry.add("yh.gateway.routes.demo", () -> "http://127.0.0.1:" + BACKEND_PORT);
}
@Autowired
TestRestTemplate rest;
@Value("${local.server.port}")
int port;
@Autowired
OpenApiLogRepository logRepository;
@MockBean
GatewayKeyService keyService;
@BeforeEach
void setUp() {
logRepository.deleteAll();
CALLER_SEEN.set(null);
URI_SEEN.set(null);
when(keyService.getSecret("100617")).thenReturn(SECRET);
}
private HttpHeaders signedHeaders() {
return signedHeaders("/oapi/hello");
}
/** 客户端签名口径 = 剥掉 target 段后的 path[?query](F1),故 query 必须进 canonical */
private HttpHeaders signedHeaders(String pathToSign) {
String ts = String.valueOf(System.currentTimeMillis());
HttpHeaders h = new HttpHeaders();
h.add("X-App-Key", "100617");
h.add("X-Timestamp", ts);
h.add("X-Signature", SignUtil.signRequest("GET", pathToSign, ts, SECRET));
return h;
}
/** 台账异步落库(500ms 周期 + 异步 complete),轮询等待 */
private List<OpenApiLog> awaitLedger() throws InterruptedException {
for (int i = 0; i < 100; i++) {
List<OpenApiLog> all = logRepository.findAll();
if (!all.isEmpty()) {
return all;
}
Thread.sleep(50);
}
return logRepository.findAll();
}
@Test
void 全链走通_身份注入_响应透传_台账入库() throws Exception {
ResponseEntity<String> resp = rest.exchange("/demo/oapi/hello", HttpMethod.GET,
new HttpEntity<>(signedHeaders()), String.class);
assertThat(resp.getStatusCodeValue()).isEqualTo(200);
assertThat(resp.getBody()).as("提供方响应体原样透传").isEqualTo("pong");
assertThat(resp.getHeaders().getFirst("X-Upstream")).as("提供方响应头原样透传").isEqualTo("v1");
assertThat(URI_SEEN.get()).as("换主机不换路径").isEqualTo("/demo/oapi/hello");
assertThat(CALLER_SEEN.get()).as("链序终值 = 网关注入的验签 appKey").isEqualTo("100617");
List<OpenApiLog> ledger = awaitLedger();
assertThat(ledger).as("台账入库").isNotEmpty();
OpenApiLog row = ledger.get(0);
assertThat(row.getTargetCode()).isEqualTo("demo");
assertThat(row.getPath()).isEqualTo("/oapi/hello");
assertThat(row.getCallerAppKey()).isEqualTo("100617");
assertThat(row.getStatus()).isEqualTo(200);
}
@Test
void 伪造身份头被剥_终值为网关注入() throws Exception {
HttpHeaders h = signedHeaders();
h.add("X-Caller-App-Key", "hack");
ResponseEntity<String> resp = rest.exchange("/demo/oapi/hello", HttpMethod.GET,
new HttpEntity<>(h), String.class);
assertThat(resp.getStatusCodeValue()).isEqualTo(200);
assertThat(CALLER_SEEN.get()).as("先剥后注入:链上终值唯一").isEqualTo("100617");
}
@Test
void observe为true坏签名放行且台账有记录() throws Exception {
String ts = String.valueOf(System.currentTimeMillis());
HttpHeaders h = new HttpHeaders();
h.add("X-App-Key", "100617");
h.add("X-Timestamp", ts);
h.add("X-Signature", "deadbeef");
ResponseEntity<String> resp = rest.exchange("/demo/oapi/hello", HttpMethod.GET,
new HttpEntity<>(h), String.class);
assertThat(resp.getStatusCodeValue()).as("R4:P1 只观察不拦").isEqualTo(200);
assertThat(CALLER_SEEN.get()).as("FAIL_OBSERVED 不注入身份").isNull();
List<OpenApiLog> ledger = awaitLedger();
assertThat(ledger).as("坏签名也进台账(caller 尽力记)").isNotEmpty();
assertThat(ledger.get(0).getCallerAppKey()).isEqualTo("100617");
}
@Test
void observe不影响协议层错误_400与404永远拦() throws Exception {
// 原始 URI 直发(不经客户端归一化):编码形态 %2e%2e 解码后是 ..,Sanitize 必裸 400
URI traversal = new URI("http://127.0.0.1:" + port + "/demo/oapi/a/%2e%2e/b");
assertThat(rest.getForEntity(traversal, String.class).getStatusCodeValue())
.as("Sanitize 400 不受 observe 影响").isEqualTo(400);
assertThat(rest.getForEntity("/noway/oapi/x", String.class).getStatusCodeValue())
.as("未知 target 404 不受 observe 影响").isEqualTo(404);
}
@Test
void 网关自身internal端点不被链拦_refresh可用() {
ResponseEntity<String> resp = rest.postForEntity("/api/internal/routes/refresh", null, String.class);
assertThat(resp.getStatusCodeValue()).as("自身端点走 controller,不进 oapi 链").isEqualTo(200);
}
}
@@ -0,0 +1,131 @@
package com.yunhai.platform.gateway.chain;
import com.sun.net.httpserver.HttpServer;
import com.yunhai.platform.gateway.auth.GatewayKeyService;
import com.yunhai.platform.gateway.entity.OpenApiLog;
import com.yunhai.platform.gateway.repository.OpenApiLogRepository;
import com.yunhai.platform.utils.crypto.SignUtil;
import org.junit.jupiter.api.BeforeEach;
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.mock.mockito.MockBean;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
/**
* T1.6 observe=false(收口形态)对照用例:坏签名不再只记,而是<b>裸 401</b> 拦下、
* 请求不到提供方;台账仍记录该次判定(status=401,caller 尽力记 appKey)。
* 与 {@link GatewayChainTest} 分属两个 Spring context(observe 属性不同)。
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = "yh.gateway.observe=false")
@ActiveProfiles("test")
class GatewayEnforceChainTest {
private static final String SECRET = "sec-enforce-0123456789abc";
private static final HttpServer BACKEND;
private static final int BACKEND_PORT;
static final AtomicReference<String> CALLER_SEEN = new AtomicReference<>();
static {
try {
BACKEND = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
BACKEND.setExecutor(Executors.newFixedThreadPool(2));
BACKEND.createContext("/", exchange -> {
CALLER_SEEN.set(exchange.getRequestHeaders().getFirst("X-Caller-App-Key"));
byte[] body = "pong".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
exchange.close();
});
BACKEND.start();
BACKEND_PORT = BACKEND.getAddress().getPort();
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
@DynamicPropertySource
static void routes(DynamicPropertyRegistry registry) {
registry.add("yh.gateway.routes.demo", () -> "http://127.0.0.1:" + BACKEND_PORT);
}
@Autowired
TestRestTemplate rest;
@Autowired
OpenApiLogRepository logRepository;
@MockBean
GatewayKeyService keyService;
@BeforeEach
void setUp() {
logRepository.deleteAll();
CALLER_SEEN.set(null);
when(keyService.getSecret("100617")).thenReturn(SECRET);
}
private List<OpenApiLog> awaitLedger() throws InterruptedException {
for (int i = 0; i < 100; i++) {
List<OpenApiLog> all = logRepository.findAll();
if (!all.isEmpty()) {
return all;
}
Thread.sleep(50);
}
return logRepository.findAll();
}
@Test
void 坏签名observe为false裸401且不到提供方() throws Exception {
HttpHeaders h = new HttpHeaders();
h.add("X-App-Key", "100617");
h.add("X-Timestamp", String.valueOf(System.currentTimeMillis()));
h.add("X-Signature", "deadbeef");
ResponseEntity<String> resp = rest.exchange("/demo/oapi/hello", HttpMethod.GET,
new HttpEntity<>(h), String.class);
assertThat(resp.getStatusCodeValue()).as("收口形态:裸 401").isEqualTo(401);
assertThat(resp.getBody()).as("裸码无信封").isNullOrEmpty();
assertThat(CALLER_SEEN.get()).as("请求不得送达提供方").isNull();
List<OpenApiLog> ledger = awaitLedger();
assertThat(ledger).as("拦截同样进台账").isNotEmpty();
assertThat(ledger.get(0).getStatus()).isEqualTo(401);
assertThat(ledger.get(0).getCallerAppKey()).isEqualTo("100617");
}
@Test
void 好签名observe为false正常放行() throws Exception {
HttpHeaders h = new HttpHeaders();
h.add("X-App-Key", "100617");
String ts = String.valueOf(System.currentTimeMillis());
h.add("X-Timestamp", ts);
h.add("X-Signature", SignUtil.signRequest("GET", "/oapi/hello", ts, SECRET));
ResponseEntity<String> resp = rest.exchange("/demo/oapi/hello", HttpMethod.GET,
new HttpEntity<>(h), String.class);
assertThat(resp.getStatusCodeValue()).isEqualTo(200);
assertThat(CALLER_SEEN.get()).isEqualTo("100617");
}
}
@@ -0,0 +1,117 @@
package com.yunhai.platform.gateway.entity;
import com.yunhai.platform.gateway.repository.OpenApiLogRepository;
import com.yunhai.platform.gateway.repository.OpenApiRouteRepository;
import com.yunhai.platform.gateway.repository.OpenApiStatDailyRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.test.context.ActiveProfiles;
import java.time.LocalDate;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 注册表 DDL ↔ 实体一致性(T0.9 TDD):
* H2 MODE=MySQL 直接执行交付的 docs/sql/01_schema.sql(非 Hibernate 生成表),
* 三实体 round-trip 全字段一致(含 Java 侧默认值 = DDL DEFAULT 逐列对齐)+ 两个唯一键冲突。
*/
@DataJpaTest
@ActiveProfiles("test")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class OpenApiRegistrySchemaTest {
@Autowired
private OpenApiRouteRepository routeRepo;
@Autowired
private OpenApiLogRepository logRepo;
@Autowired
private OpenApiStatDailyRepository statRepo;
@Test
void 三实体round_trip_默认值与DDL逐列一致() {
OpenApiRoute route = new OpenApiRoute();
route.setTargetCode("usermsg");
route.setPathPattern("/oapi/message/**");
routeRepo.saveAndFlush(route);
OpenApiRoute loaded = routeRepo.findById(route.getId()).orElse(null);
assertThat(loaded).isNotNull();
assertThat(loaded.getTargetCode()).isEqualTo("usermsg");
assertThat(loaded.getPathPattern()).isEqualTo("/oapi/message/**");
assertThat(loaded.getHttpMethod()).isEqualTo("*");
assertThat(loaded.getVisibility()).isEqualTo("INTERNAL");
assertThat(loaded.getAuthMode()).isEqualTo("SIGNED");
assertThat(loaded.getCacheTtlSeconds()).isZero();
assertThat(loaded.getCacheScope()).isEqualTo("CALLER");
assertThat(loaded.getRateLimitQps()).isZero();
assertThat(loaded.getTimeoutMs()).isEqualTo(30000);
assertThat(loaded.getRequestBodyLimitMb()).isEqualTo(10);
assertThat(loaded.isEnabled()).isTrue();
assertThat(loaded.getRemark()).isNull();
assertThat(loaded.getGmtCreate()).isNotNull();
assertThat(loaded.getGmtModified()).isNotNull();
OpenApiLog logRow = new OpenApiLog();
logRow.setTs(LocalDateTime.now());
logRow.setTargetCode("usermsg");
logRow.setMethod("GET");
logRow.setPath("/oapi/message/list");
logRow.setStatus(200);
logRow.setBackendCode(null);
logRow.setCostMs(12);
logRepo.saveAndFlush(logRow);
OpenApiLog loadedLog = logRepo.findById(logRow.getId()).orElse(null);
assertThat(loadedLog).isNotNull();
assertThat(loadedLog.getCallerAppKey()).as("401 且无 appKey 记 NULL").isNull();
assertThat(loadedLog.getBackendCode()).as("非信封响应记 NULL").isNull();
assertThat(loadedLog.getStatus()).isEqualTo(200);
assertThat(loadedLog.getCostMs()).isEqualTo(12);
assertThat(loadedLog.getTs()).isNotNull();
OpenApiStatDaily stat = new OpenApiStatDaily();
stat.setStatDate(LocalDate.now());
stat.setTargetCode("usermsg");
stat.setCallerAppKey("100617");
statRepo.saveAndFlush(stat);
OpenApiStatDaily loadedStat = statRepo.findById(stat.getId()).orElse(null);
assertThat(loadedStat).isNotNull();
assertThat(loadedStat.getTotal()).isZero();
assertThat(loadedStat.getErrTotal()).isZero();
assertThat(loadedStat.getAvgCostMs()).isZero();
}
@Test
void uk_route_同三元组二次插入冲突() {
OpenApiRoute a = new OpenApiRoute();
a.setTargetCode("notice");
a.setPathPattern("/oapi/notice/list");
routeRepo.saveAndFlush(a);
OpenApiRoute b = new OpenApiRoute();
b.setTargetCode("notice");
b.setPathPattern("/oapi/notice/list");
assertThatThrownBy(() -> routeRepo.saveAndFlush(b))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void uk_stat_同三元组二次插入冲突() {
OpenApiStatDaily a = new OpenApiStatDaily();
a.setStatDate(LocalDate.of(2026, 9, 21));
a.setTargetCode("notice");
a.setCallerAppKey("100617");
statRepo.saveAndFlush(a);
OpenApiStatDaily b = new OpenApiStatDaily();
b.setStatDate(LocalDate.of(2026, 9, 21));
b.setTargetCode("notice");
b.setCallerAppKey("100617");
assertThatThrownBy(() -> statRepo.saveAndFlush(b))
.isInstanceOf(DataIntegrityViolationException.class);
}
}
@@ -0,0 +1,49 @@
package com.yunhai.platform.gateway.envelope;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletResponse;
import java.io.IOException;
import static org.assertj.core.api.Assertions.assertThat;
/**
* EnvelopeWriter 两形态写出契约(T0.8 TDD):
* 裸码无信封 JSON;信封 HTTP 200 + code/msg/data 与 SDK OpenApiEnvelope 解析形态逐字段对齐。
*/
class EnvelopeWriterTest {
@Test
void bare_status_written() throws IOException {
MockHttpServletResponse res = new MockHttpServletResponse();
EnvelopeWriter.writeBare(res, 401);
assertThat(res.getStatus()).isEqualTo(401);
assertThat(res.getContentAsString()).as("裸码无信封 JSON").isEmpty();
MockHttpServletResponse res404 = new MockHttpServletResponse();
EnvelopeWriter.writeBare(res404, 404);
assertThat(res404.getStatus()).isEqualTo(404);
assertThat(res404.getContentAsString()).isEmpty();
MockHttpServletResponse res502 = new MockHttpServletResponse();
EnvelopeWriter.writeBare(res502, 502);
assertThat(res502.getStatus()).isEqualTo(502);
assertThat(res502.getContentAsString()).isEmpty();
}
@Test
void envelope_written_with_code_and_message() throws IOException {
MockHttpServletResponse res = new MockHttpServletResponse();
EnvelopeWriter.writeEnvelope(res, 7, "rate limited");
assertThat(res.getStatus()).as("信封恒走 HTTP 200").isEqualTo(200);
assertThat(res.getContentType()).contains("application/json");
assertThat(res.getContentAsString())
.isEqualTo("{\"code\":7,\"msg\":\"rate limited\",\"data\":null}");
}
@Test
void rate_limit_constants_reserved() {
assertThat(EnvelopeWriter.RATE_LIMITED).isEqualTo(7);
assertThat(EnvelopeWriter.CIRCUIT_OPEN).isEqualTo(8);
}
}
@@ -0,0 +1,61 @@
package com.yunhai.platform.gateway.filter;
import com.yunhai.platform.gateway.envelope.EnvelopeWriter;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import static org.assertj.core.api.Assertions.assertThat;
/**
* SanitizeFilter 五用例(T1.1 TDD):路径穿越(明文/编码)、双斜杠、控制字符均裸 400;
* 正常路径放行且两个 attribute 契约已设。
*/
class SanitizeFilterTest {
private MockHttpServletResponse run(String uri) throws Exception {
MockHttpServletRequest req = new MockHttpServletRequest("GET", uri);
MockHttpServletResponse res = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
new SanitizeFilter().doFilter(req, res, chain);
return res;
}
@Test
void 路径穿越_裸400() throws Exception {
assertThat(run("/userbase/oapi/a/../b").getStatus()).isEqualTo(400);
}
@Test
void 编码形态路径穿越_裸400() throws Exception {
assertThat(run("/userbase/oapi/%2e%2e/b").getStatus()).isEqualTo(400);
assertThat(run("/userbase/oapi/%2E%2E%2Fetc").getStatus()).isEqualTo(400);
}
@Test
void 路径双斜杠_裸400() throws Exception {
assertThat(run("/userbase//oapi/x").getStatus()).isEqualTo(400);
assertThat(run("/userbase/oapi//list").getStatus()).isEqualTo(400);
}
@Test
void 控制字符与空字节_裸400() throws Exception {
assertThat(run("/userbase/oapi/a\u0000b").getStatus()).isEqualTo(400);
assertThat(run("/userbase/oapi/a\u001fb").getStatus()).isEqualTo(400);
}
@Test
void 正常路径放行且attribute已设() throws Exception {
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/userbase/oapi/x");
MockHttpServletResponse res = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
new SanitizeFilter().doFilter(req, res, chain);
assertThat(res.getStatus()).as("正常路径放行").isEqualTo(200);
assertThat(chain.getRequest()).as("链条继续").isNotNull();
assertThat(req.getAttribute(SanitizeFilter.ATTR_SANITIZED_URI))
.as("sanitized.uri = 原始 URI(逐字节不改写)").isEqualTo("/userbase/oapi/x");
assertThat(req.getAttribute(SanitizeFilter.ATTR_STRIPPED_PATH))
.as("stripped.path = 剥 target 首段").isEqualTo("/oapi/x");
}
}
@@ -0,0 +1,172 @@
package com.yunhai.platform.gateway.filter;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.gateway.auth.GatewayKeyService;
import com.yunhai.platform.gateway.route.GatewayProperties;
import com.yunhai.platform.utils.crypto.SignUtil;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import java.io.InputStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* SignVerifyFilter v1 判定用例(T1.2 TDD):canonical 与 starter 逐字节一致
* (剥 target 首段后的 path + 解码排序 query + timestamp,{@code shared-sign-vectors/v1.json} 同源对拍);
* observe=true 只记不拦(verdict=FAIL_OBSERVED 放行),observe=false 坏签名裸 401;
* 时间窗 ±5min(F2,不得更宽)。
*/
class SignVerifyFilterTest {
private static final String SECRET = "sec-alpha-0123456789abcdef";
private SignVerifyFilter filter(boolean observe) {
SignVerifyFilter f = new SignVerifyFilter();
GatewayKeyService keyService = mock(GatewayKeyService.class);
when(keyService.getSecret("app1")).thenReturn(SECRET);
GatewayProperties props = new GatewayProperties();
props.setObserve(observe);
f.setKeyService(keyService);
f.setProperties(props);
return f;
}
/** 模拟已过 SanitizeFilter 的网关请求:attribute 契约 + 三签名头 */
private MockHttpServletRequest request(String method, String strippedPath, String query,
String appKey, String ts, String sign) {
MockHttpServletRequest req = new MockHttpServletRequest(method, "/userbase" + strippedPath);
if (query != null) {
req.setQueryString(query);
}
req.setAttribute(SanitizeFilter.ATTR_STRIPPED_PATH, strippedPath);
if (appKey != null) {
req.addHeader("X-App-Key", appKey);
}
if (ts != null) {
req.addHeader("X-Timestamp", ts);
}
if (sign != null) {
req.addHeader("X-Signature", sign);
}
return req;
}
private MockHttpServletResponse run(SignVerifyFilter f, MockHttpServletRequest req,
MockFilterChain chain) throws Exception {
MockHttpServletResponse res = new MockHttpServletResponse();
f.doFilter(req, res, chain);
return res;
}
@Test
void 合法签名判定PASS且放行() throws Exception {
String ts = String.valueOf(System.currentTimeMillis());
String sign = SignUtil.signRequest("GET", "/oapi/message/list", ts, SECRET);
MockFilterChain chain = new MockFilterChain();
MockHttpServletRequest req = request("GET", "/oapi/message/list", null, "app1", ts, sign);
MockHttpServletResponse res = run(filter(true), req, chain);
assertThat(res.getStatus()).isEqualTo(200);
assertThat(chain.getRequest()).as("链条继续").isNotNull();
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_PASS);
assertThat(req.getAttribute(SignVerifyFilter.ATTR_AUTH_APPKEY)).as("供 T1.4 身份注入").isEqualTo("app1");
}
@Test
void 合法签名含排序query判定PASS() throws Exception {
String ts = String.valueOf(System.currentTimeMillis());
// 客户端按解码排序后 query 签名(starter sortRawQuery 同语义)
String sign = SignUtil.signRequest("GET", "/oapi/user/byId?id=7&name=zhang san", ts, SECRET);
MockFilterChain chain = new MockFilterChain();
MockHttpServletRequest req = request("GET", "/oapi/user/byId", "name=zhang%20san&id=7", "app1", ts, sign);
MockHttpServletResponse res = run(filter(false), req, chain);
assertThat(res.getStatus()).as("乱序 query 经网关归一后仍 PASS").isEqualTo(200);
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_PASS);
}
@Test
void 坏签名observe下放行且记FAIL() throws Exception {
String ts = String.valueOf(System.currentTimeMillis());
MockFilterChain chain = new MockFilterChain();
MockHttpServletRequest req = request("GET", "/oapi/message/list", null, "app1", ts, "deadbeef");
MockHttpServletResponse res = run(filter(true), req, chain);
assertThat(res.getStatus()).as("观察模式不拦").isEqualTo(200);
assertThat(chain.getRequest()).isNotNull();
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT))
.as("台账 verdict=FAIL(观察形态)").isEqualTo(SignVerifyFilter.VERDICT_FAIL_OBSERVED);
}
@Test
void 坏签名非observe下裸401() throws Exception {
String ts = String.valueOf(System.currentTimeMillis());
MockFilterChain chain = new MockFilterChain();
MockHttpServletRequest req = request("GET", "/oapi/message/list", null, "app1", ts, "deadbeef");
MockHttpServletResponse res = run(filter(false), req, chain);
assertThat(res.getStatus()).isEqualTo(401);
assertThat(res.getContentAsByteArray()).as("裸码无信封").isEmpty();
assertThat(chain.getRequest()).as("链条中断").isNull();
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_FAIL);
}
@Test
void 头缺失判定FAIL() throws Exception {
MockFilterChain chain = new MockFilterChain();
MockHttpServletRequest req = request("GET", "/oapi/message/list", null, null, null, null);
MockHttpServletResponse res = run(filter(false), req, chain);
assertThat(res.getStatus()).as("三头缺失 = FAIL 裸 401").isEqualTo(401);
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_FAIL);
}
@Test
void 未知appKey判定FAIL() throws Exception {
String ts = String.valueOf(System.currentTimeMillis());
String sign = SignUtil.signRequest("GET", "/oapi/message/list", ts, SECRET);
MockFilterChain chain = new MockFilterChain();
// appKey=ghost:mock 的 getSecret 返回 null(未知/禁用 fail-closed)
MockHttpServletRequest req = request("GET", "/oapi/message/list", null, "ghost", ts, sign);
MockHttpServletResponse res = run(filter(false), req, chain);
assertThat(res.getStatus()).isEqualTo(401);
}
@Test
void 时间窗超出判定FAIL() throws Exception {
// ±5min(F2):6min 前签名 + 正确算法也必须 FAIL
String ts = String.valueOf(System.currentTimeMillis() - 6 * 60 * 1000L);
String sign = SignUtil.signRequest("GET", "/oapi/message/list", ts, SECRET);
MockFilterChain chain = new MockFilterChain();
MockHttpServletRequest req = request("GET", "/oapi/message/list", null, "app1", ts, sign);
MockHttpServletResponse res = run(filter(false), req, chain);
assertThat(res.getStatus()).as("时间窗超出 = FAIL 裸 401").isEqualTo(401);
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_FAIL);
}
@Test
void 共享向量与starter逐字节一致() throws Exception {
ObjectMapper mapper = new ObjectMapper();
JsonNode root;
try (InputStream in = getClass().getResourceAsStream("/shared-sign-vectors/v1.json")) {
assertThat(in).as("共享向量资源存在").isNotNull();
root = mapper.readTree(in);
}
for (JsonNode v : root.get("vectors")) {
String secret = v.get("secret").asText();
String method = v.get("method").asText();
String path = v.get("pathAfterStrip").asText();
String rawQuery = v.get("rawQuery").isNull() ? null : v.get("rawQuery").asText();
String ts = v.get("timestamp").asText();
String expectedTarget = v.get("canonicalTarget").asText();
String expectedSign = v.get("expectedSignature").asText();
String target = SignVerifyFilter.buildCanonicalTarget(path, rawQuery);
assertThat(target).as("canonical target 构造同源(%s %s)", method, rawQuery).isEqualTo(expectedTarget);
assertThat(SignUtil.signRequest(method, target, ts, secret))
.as("期望签名逐字节一致(%s %s)", method, path)
.isEqualTo(expectedSign);
}
}
}
@@ -0,0 +1,115 @@
package com.yunhai.platform.gateway.forward;
import com.sun.net.httpserver.HttpServer;
import com.yunhai.platform.gateway.filter.SanitizeFilter;
import com.yunhai.platform.gateway.filter.SignVerifyFilter;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 身份头注入 X-Caller-App-Key 四用例(T1.4 TDD):PASS 注入且值 = 验签 appKey;
* FAIL(observe)=FAIL_OBSERVED 不注入;ANON 路由不注入(匿名就是匿名);
* 客户端伪造头先剥后注入——假后端收到的终值 = 网关注入值(唯一来源,顺序契约)。
*/
class CallerHeaderTest {
private static HttpServer server;
private static int port;
private static volatile Map<String, String> lastHeaders;
@BeforeAll
static void startFakeBackend() throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.setExecutor(Executors.newFixedThreadPool(2));
server.createContext("/", exchange -> {
lastHeaders = new ConcurrentHashMap<>();
for (Map.Entry<String, java.util.List<String>> e : exchange.getRequestHeaders().entrySet()) {
lastHeaders.put(e.getKey().toLowerCase(), String.join(",", e.getValue()));
}
byte[] body = "ok".getBytes(java.nio.charset.StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
exchange.close();
});
server.start();
port = server.getAddress().getPort();
}
@AfterAll
static void stopFakeBackend() {
server.stop(0);
}
@BeforeEach
@AfterEach
void reset() {
lastHeaders = null;
}
/** 已过 Sanitize+SignVerify 判定链的请求(attribute 契约),执行真实转发观察假后端收到的头 */
private Map<String, String> forwardWithVerdict(String verdict, String authAppKey,
String forgedCallerHeader) throws IOException {
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/userbase/oapi/x");
req.setAsyncSupported(true);
req.setAttribute(SanitizeFilter.ATTR_STRIPPED_PATH, "/oapi/x");
if (verdict != null) {
req.setAttribute(SignVerifyFilter.ATTR_VERDICT, verdict);
}
if (authAppKey != null) {
req.setAttribute(SignVerifyFilter.ATTR_AUTH_APPKEY, authAppKey);
}
if (forgedCallerHeader != null) {
req.addHeader("X-Caller-App-Key", forgedCallerHeader);
}
MockHttpServletResponse res = new MockHttpServletResponse();
CloseableHttpClient client = HttpClients.custom().disableRedirectHandling().build();
new StreamingForwarder(client, Runnable::run)
.forward(req, res, "http://127.0.0.1:" + port + "/userbase/oapi/x", 5000, 1024 * 1024);
assertThat(res.getStatus()).as("转发本身成功").isEqualTo(200);
return lastHeaders;
}
@Test
void PASS判定注入且值等于验签appKey() throws Exception {
Map<String, String> headers = forwardWithVerdict(SignVerifyFilter.VERDICT_PASS, "100617", null);
assertThat(headers.get("x-caller-app-key")).isEqualTo("100617");
}
@Test
void FAIL_OBSERVED不注入() throws Exception {
Map<String, String> headers = forwardWithVerdict(SignVerifyFilter.VERDICT_FAIL_OBSERVED, null, null);
assertThat(headers).doesNotContainKey("x-caller-app-key");
}
@Test
void ANON路由不注入() throws Exception {
// ANON/OPTIONAL 未签名路径:匿名就是匿名(判定非 PASS,无验签 appKey)
Map<String, String> headers = forwardWithVerdict(SignVerifyFilter.VERDICT_ANON, null, null);
assertThat(headers).doesNotContainKey("x-caller-app-key");
}
@Test
void 伪造头先剥后注入_终值为网关注入值() throws Exception {
Map<String, String> headers = forwardWithVerdict(SignVerifyFilter.VERDICT_PASS, "100617", "hack");
assertThat(headers.get("x-caller-app-key"))
.as("唯一合法来源是网关:伪造值不残留、不并列").isEqualTo("100617");
}
}
@@ -0,0 +1,228 @@
package com.yunhai.platform.gateway.forward;
import com.sun.net.httpserver.HttpServer;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import static org.assertj.core.api.Assertions.assertThat;
/**
* StreamingForwarder 五用例(T1.3 TDD,假后端 = JDK 自带 com.sun.net.httpserver.HttpServer,
* 零新增测试依赖):GET 转发 forwardUrl 逐字符 + 响应透传;POST body 流式透传;
* 客户端伪造 X-Caller-App-Key 不出现在转发请求;签名头四件原样到达;
* 超时裸 504 / 连接拒绝裸 502(另附 body 超限裸 413——实现要点契约)。
*/
class StreamingForwarderTest {
private static HttpServer server;
private static int port;
/** 假后端最近一次收到的请求快照 */
private static volatile Map<String, String> lastHeaders;
private static volatile String lastMethod;
private static volatile String lastUri;
private static volatile byte[] lastBody;
/** 假后端行为控制 */
private static volatile int respStatus = 201;
private static volatile String respHeader = "X-Upstream:v1"; // name:value
private static volatile byte[] respBody = "hello-gateway".getBytes(StandardCharsets.UTF_8);
private static volatile long respSleepMs = 0;
private static volatile int hitCount = 0;
private static volatile boolean echoBody = false;
@BeforeAll
static void startFakeBackend() throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.setExecutor(Executors.newFixedThreadPool(4));
server.createContext("/", exchange -> {
hitCount++;
lastMethod = exchange.getRequestMethod();
lastUri = exchange.getRequestURI().toString();
lastHeaders = new ConcurrentHashMap<>();
for (Map.Entry<String, java.util.List<String>> e : exchange.getRequestHeaders().entrySet()) {
lastHeaders.put(e.getKey().toLowerCase(), String.join(",", e.getValue()));
}
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buf = new byte[8192];
int n;
while ((n = exchange.getRequestBody().read(buf)) > 0) {
bos.write(buf, 0, n);
}
lastBody = bos.toByteArray();
if (respSleepMs > 0) {
try {
Thread.sleep(respSleepMs);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
}
byte[] body = echoBody ? lastBody : respBody;
if (respHeader != null) {
String[] kv = respHeader.split(":", 2);
exchange.getResponseHeaders().add(kv[0], kv[1]);
}
exchange.sendResponseHeaders(respStatus, body.length == 0 ? -1 : body.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
exchange.close();
});
server.start();
port = server.getAddress().getPort();
}
@AfterAll
static void stopFakeBackend() {
server.stop(0);
}
@BeforeEach
@AfterEach
void resetFakeBackend() {
respStatus = 201;
respHeader = "X-Upstream:v1";
respBody = "hello-gateway".getBytes(StandardCharsets.UTF_8);
respSleepMs = 0;
hitCount = 0;
echoBody = false;
lastHeaders = null;
lastMethod = null;
lastUri = null;
lastBody = null;
}
/** 同线程执行器:转发逻辑同步完成,测试无需起线程等待 */
private StreamingForwarder forwarder() {
CloseableHttpClient client = HttpClients.custom().disableRedirectHandling().build();
return new StreamingForwarder(client, Runnable::run);
}
private MockHttpServletRequest request(String method, String uri) {
MockHttpServletRequest req = new MockHttpServletRequest(method, uri);
req.setAsyncSupported(true); // 异步 Servlet 契约(T1.6 链上 filter 需 async-supported)
return req;
}
@Test
void GET转发_forwardUrl逐字符且响应原样透传() throws Exception {
MockHttpServletRequest req = request("GET", "/userbase/oapi/x");
req.setQueryString("a=1&b=2");
MockHttpServletResponse res = new MockHttpServletResponse();
String forwardUrl = "http://127.0.0.1:" + port + "/userbase/oapi/x?a=1&b=2";
forwarder().forward(req, res, forwardUrl, 5000, 1024 * 1024);
assertThat(lastUri).as("换主机不换路径:URI+query 逐字符送达").isEqualTo("/userbase/oapi/x?a=1&b=2");
assertThat(lastMethod).isEqualTo("GET");
assertThat(res.getStatus()).as("响应 status 透传").isEqualTo(201);
assertThat(res.getHeader("X-Upstream")).as("响应 header 透传").isEqualTo("v1");
assertThat(res.getContentAsString()).as("响应 body 原样透传").isEqualTo("hello-gateway");
}
@Test
void POST_body流式透传() throws Exception {
echoBody = true;
respStatus = 200;
byte[] payload = new byte[100 * 1024];
for (int i = 0; i < payload.length; i++) {
payload[i] = (byte) (i % 251);
}
MockHttpServletRequest req = request("POST", "/usermsg/oapi/msg/sendUserMsg");
req.setContent(payload);
MockHttpServletResponse res = new MockHttpServletResponse();
forwarder().forward(req, res, "http://127.0.0.1:" + port + "/usermsg/oapi/msg/sendUserMsg",
5000, 4 * 1024 * 1024);
assertThat(lastBody).as("请求 body 完整到达假后端").isEqualTo(payload);
assertThat(res.getContentAsByteArray()).as("响应 body 回显一致(不改写)").isEqualTo(payload);
}
@Test
void 客户端伪造XCallerAppKey不出现在转发请求() throws Exception {
MockHttpServletRequest req = request("GET", "/userbase/oapi/x");
req.addHeader("X-Caller-App-Key", "hack");
req.addHeader("Connection", "close"); // hop-by-hop 也不得透传
MockHttpServletResponse res = new MockHttpServletResponse();
forwarder().forward(req, res, "http://127.0.0.1:" + port + "/userbase/oapi/x", 5000, 1024 * 1024);
assertThat(res.getStatus()).isEqualTo(201);
assertThat(lastHeaders).as("身份头唯一合法来源是网关(T1.4)").doesNotContainKey("x-caller-app-key");
}
@Test
void 签名头四件原样到达假后端() throws Exception {
MockHttpServletRequest req = request("GET", "/userbase/oapi/x");
req.addHeader("X-App-Key", "100617");
req.addHeader("X-Timestamp", "1758441600000");
req.addHeader("X-Signature", "Regw08O602Tp61nhH6oG/5VIvyfmU9ZbWs42P7OHzj4=");
req.addHeader("X-Nonce", "c7ce1f3e-9a1d-4b3e-8f2a-0b1c2d3e4f5a");
MockHttpServletResponse res = new MockHttpServletResponse();
forwarder().forward(req, res, "http://127.0.0.1:" + port + "/userbase/oapi/x", 5000, 1024 * 1024);
assertThat(lastHeaders.get("x-app-key")).isEqualTo("100617");
assertThat(lastHeaders.get("x-timestamp")).isEqualTo("1758441600000");
assertThat(lastHeaders.get("x-signature")).isEqualTo("Regw08O602Tp61nhH6oG/5VIvyfmU9ZbWs42P7OHzj4=");
assertThat(lastHeaders.get("x-nonce")).as("R3:UserBase 自验签依赖").isEqualTo("c7ce1f3e-9a1d-4b3e-8f2a-0b1c2d3e4f5a");
}
@Test
void 假后端超时_裸504() throws Exception {
respSleepMs = 600;
MockHttpServletRequest req = request("GET", "/userbase/oapi/slow");
MockHttpServletResponse res = new MockHttpServletResponse();
forwarder().forward(req, res, "http://127.0.0.1:" + port + "/userbase/oapi/slow", 100, 1024 * 1024);
assertThat(res.getStatus()).isEqualTo(504);
assertThat(res.getContentAsByteArray()).as("裸码无信封").isEmpty();
}
@Test
void 连接拒绝_裸502() throws Exception {
int deadPort;
try (ServerSocket ss = new ServerSocket(0)) {
deadPort = ss.getLocalPort();
}
MockHttpServletRequest req = request("GET", "/userbase/oapi/x");
MockHttpServletResponse res = new MockHttpServletResponse();
forwarder().forward(req, res, "http://127.0.0.1:" + deadPort + "/userbase/oapi/x", 5000, 1024 * 1024);
assertThat(res.getStatus()).isEqualTo(502);
assertThat(res.getContentAsByteArray()).isEmpty();
}
@Test
void body超限_裸413且不连后端() throws Exception {
MockHttpServletRequest req = request("POST", "/userbase/oapi/x");
req.setContent("0123456789".getBytes(StandardCharsets.UTF_8)); // 10B > 4B
MockHttpServletResponse res = new MockHttpServletResponse();
forwarder().forward(req, res, "http://127.0.0.1:" + port + "/userbase/oapi/x", 5000, 4);
assertThat(res.getStatus()).isEqualTo(413);
assertThat(res.getContentAsByteArray()).as("裸码").isEmpty();
assertThat(hitCount).as("上限拦截在连接之前").isZero();
}
}
@@ -0,0 +1,107 @@
package com.yunhai.platform.gateway.log;
import com.yunhai.platform.gateway.entity.OpenApiLog;
import com.yunhai.platform.gateway.entity.OpenApiStatDaily;
import com.yunhai.platform.gateway.repository.OpenApiLogRepository;
import com.yunhai.platform.gateway.repository.OpenApiStatDailyRepository;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
/**
* AccessLogService 三用例(T1.5 TDD):offer 入队成功路径(请求线程零写库);
* 满 200 条触发批量 flush(同步触发点测,不起真线程等待——注入同线程 dispatcher,
* 一批 JPA saveAll + 按日 upsert stat_daily);队列满 offer 失败 → 丢弃计数 +1 且绝不抛异常。
*/
class AccessLogServiceTest {
private AccessLogService service(int capacity, OpenApiLogRepository logRepo,
OpenApiStatDailyRepository statRepo) {
AccessLogService svc = new AccessLogService(capacity);
ReflectionTestUtils.setField(svc, "logRepository", logRepo);
ReflectionTestUtils.setField(svc, "statRepository", statRepo);
svc.setFlushDispatcher(Runnable::run); // 同步触发点:不真起线程
return svc;
}
private OpenApiLog entry(String target, String caller, int status) {
OpenApiLog e = new OpenApiLog();
e.setTs(LocalDateTime.now());
e.setTargetCode(target);
e.setCallerAppKey(caller);
e.setMethod("GET");
e.setPath("/oapi/x");
e.setStatus(status);
e.setCostMs(10);
return e;
}
@Test
void offer入队成功且请求线程零写库() {
OpenApiLogRepository logRepo = mock(OpenApiLogRepository.class);
OpenApiStatDailyRepository statRepo = mock(OpenApiStatDailyRepository.class);
AccessLogService svc = service(AccessLogService.QUEUE_CAPACITY, logRepo, statRepo);
svc.record(entry("usermsg", "100617", 200));
assertThat(svc.queueSize()).as("入队 1 条").isEqualTo(1);
assertThat(svc.droppedCount()).isZero();
verifyNoInteractions(logRepo, statRepo); // 红线:不得在请求线程直接写库
}
@Test
void 满200条触发批量flush并upsert日统计() {
OpenApiLogRepository logRepo = mock(OpenApiLogRepository.class);
OpenApiStatDailyRepository statRepo = mock(OpenApiStatDailyRepository.class);
AccessLogService svc = service(AccessLogService.QUEUE_CAPACITY, logRepo, statRepo);
for (int i = 0; i < AccessLogService.BATCH_SIZE; i++) {
svc.record(entry("usermsg", "100617", i == 199 ? 500 : 200)); // 末条 HTTP≥400 计 err
}
ArgumentCaptor<List<OpenApiLog>> batchCap = ArgumentCaptor.forClass(List.class);
verify(logRepo).saveAll(batchCap.capture());
assertThat(batchCap.getValue()).as("一批 200 条批量插入").hasSize(200);
assertThat(svc.queueSize()).as("flush 后队列清空").isZero();
ArgumentCaptor<OpenApiStatDaily> statCap = ArgumentCaptor.forClass(OpenApiStatDaily.class);
verify(statRepo).save(statCap.capture());
OpenApiStatDaily stat = statCap.getValue();
assertThat(stat.getTargetCode()).isEqualTo("usermsg");
assertThat(stat.getCallerAppKey()).isEqualTo("100617");
assertThat(stat.getTotal()).isEqualTo(200);
assertThat(stat.getErrTotal()).as("status≥400 计入 err").isEqualTo(1);
assertThat(stat.getAvgCostMs()).isEqualTo(10);
}
@Test
void 队列满丢弃计数加一且不抛异常() {
OpenApiLogRepository logRepo = mock(OpenApiLogRepository.class);
OpenApiStatDailyRepository statRepo = mock(OpenApiStatDailyRepository.class);
AccessLogService svc = service(2, logRepo, statRepo); // 测试容量:小队列复现背压
svc.record(entry("usermsg", "a", 200));
svc.record(entry("usermsg", "a", 200));
assertThat(svc.queueSize()).isEqualTo(2);
assertThatCode(() -> {
svc.record(entry("usermsg", "a", 200)); // 第 3 条 = 队列满
svc.record(entry("usermsg", "a", 200));
}).as("绝不阻塞/抛出,反杀业务不可接受").doesNotThrowAnyException();
assertThat(svc.droppedCount()).as("累计丢弃数").isEqualTo(2);
assertThat(svc.queueSize()).isEqualTo(2);
verify(logRepo, never()).saveAll(any());
}
}
@@ -0,0 +1,112 @@
package com.yunhai.platform.gateway.route;
import com.yunhai.platform.gateway.entity.OpenApiRoute;
import com.yunhai.platform.gateway.repository.OpenApiRouteRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
/**
* RouteRegistry 五用例(T0.10 TDD):精确命中 / 前缀通配命中 / 未注册取默认(五字段全断言)/
* enabled=0 不生效 / refresh 后新增条目可见。纯单测,mock repository。
*/
class RouteRegistryTest {
private OpenApiRouteRepository routeRepo;
private RouteRegistry registry;
@BeforeEach
void setUp() {
routeRepo = Mockito.mock(OpenApiRouteRepository.class);
registry = new RouteRegistry();
ReflectionTestUtils.setField(registry, "routeRepo", routeRepo);
when(routeRepo.findByEnabledTrue()).thenReturn(new ArrayList<>(Arrays.asList(
route("usermsg", "/oapi/message/list", "*", "ANON", 30),
route("usermsg", "/oapi/message/**", "*", "SIGNED", 0))));
registry.reload();
}
private static OpenApiRoute route(String target, String pattern, String method,
String authMode, int ttl) {
OpenApiRoute r = new OpenApiRoute();
r.setTargetCode(target);
r.setPathPattern(pattern);
r.setHttpMethod(method);
r.setVisibility("INTERNAL");
r.setAuthMode(authMode);
r.setCacheTtlSeconds(ttl);
r.setCacheScope("CALLER");
r.setRateLimitQps(0);
r.setEnabled(true);
return r;
}
@Test
void 精确命中_优先于通配() {
// /oapi/message/list 同时命中精确条目(ANON)与 /oapi/message/**(SIGNED)——精确优先
RouteSpec spec = registry.resolve("usermsg", "/oapi/message/list", "GET");
assertThat(spec.authMode).isEqualTo("ANON");
assertThat(spec.cacheTtlSeconds).isEqualTo(30);
assertThat(spec.visibility).isEqualTo("INTERNAL");
assertThat(spec.cacheScope).isEqualTo("CALLER");
assertThat(spec.rateLimitQps).isZero();
}
@Test
void 前缀通配命中() {
RouteSpec spec = registry.resolve("usermsg", "/oapi/message/detail", "POST");
assertThat(spec.authMode).isEqualTo("SIGNED");
assertThat(spec.cacheTtlSeconds).isZero();
}
@Test
void 未注册路径取最严默认_五字段全断言() {
RouteSpec spec = registry.resolve("usermsg", "/oapi/unknown/path", "GET");
assertThat(spec.visibility).as("visibility").isEqualTo("INTERNAL");
assertThat(spec.authMode).as("authMode").isEqualTo("SIGNED");
assertThat(spec.cacheTtlSeconds).as("cacheTtl").isZero();
assertThat(spec.cacheScope).as("cacheScope").isEqualTo("CALLER");
assertThat(spec.rateLimitQps).as("qps").isZero();
}
@Test
void 未注册target同样取默认() {
RouteSpec spec = registry.resolve("nobody", "/oapi/anything", "GET");
assertThat(spec.authMode).isEqualTo("SIGNED");
}
@Test
void disabled条目不生效() {
// repo 契约按 enabled=true 过滤;注册表自身再滤一层(防御双保险)——disabled 条目即使漏进结果集也不参与判定
OpenApiRoute disabled = route("usermsg", "/oapi/message/disabled", "*", "ANON", 60);
disabled.setEnabled(false);
when(routeRepo.findByEnabledTrue()).thenReturn(Arrays.asList(
route("usermsg", "/oapi/message/**", "*", "SIGNED", 0), disabled));
registry.reload();
assertThat(registry.resolve("usermsg", "/oapi/message/disabled", "GET").authMode)
.as("disabled 条目不生效,走通配 SIGNED").isEqualTo("SIGNED");
}
@Test
void refresh后新增条目可见() {
assertThat(registry.resolve("notice", "/oapi/notice/list", "GET").authMode)
.as("刷新前 notice 未注册,取默认").isEqualTo("SIGNED");
List<OpenApiRoute> withNotice = new ArrayList<>(Arrays.asList(
route("usermsg", "/oapi/message/**", "*", "SIGNED", 0),
route("notice", "/oapi/notice/list", "GET", "ANON", 120)));
when(routeRepo.findByEnabledTrue()).thenReturn(withNotice);
int loaded = registry.reload();
assertThat(loaded).isEqualTo(2);
RouteSpec spec = registry.resolve("notice", "/oapi/notice/list", "GET");
assertThat(spec.authMode).as("refresh 后新增条目生效").isEqualTo("ANON");
assertThat(spec.cacheTtlSeconds).isEqualTo(120);
}
}
@@ -0,0 +1,79 @@
package com.yunhai.platform.gateway.route;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* TargetRouter 用例(T0.12 TDD):已知 target forwardUrl 逐字符断言(换主机不换路径)、
* 未知 target / 单段 / 非 oapi 第二段一律裸 404(null)、base-url 尾斜杠归一化。
*/
class TargetRouterTest {
private TargetRouter router;
@BeforeEach
void setUp() {
router = new TargetRouter();
GatewayProperties props = new GatewayProperties();
Map<String, String> routes = new LinkedHashMap<>();
routes.put("userbase", "http://userbase-service:8080");
routes.put("notice", "http://notice-service:8080/");
props.setRoutes(routes);
router.setProperties(props);
}
@Test
void 已知target_forwardUrl逐字符断言() {
TargetRouter.Routing r = router.route("/userbase/oapi/x", "a=1");
assertThat(r).isNotNull();
assertThat(r.forwardUrl).as("换主机不换路径:base-url + 完整原始 URI 含 query,逐字符")
.isEqualTo("http://userbase-service:8080/userbase/oapi/x?a=1");
assertThat(r.target).isEqualTo("userbase");
assertThat(r.pathAfterStrip).as("剥段路径 = 注册表/签名坐标系").isEqualTo("/oapi/x");
}
@Test
void 无query的URI不带问号() {
TargetRouter.Routing r = router.route("/notice/oapi/notice/list", null);
assertThat(r.forwardUrl).isEqualTo("http://notice-service:8080/notice/oapi/notice/list");
assertThat(r.pathAfterStrip).isEqualTo("/oapi/notice/list");
}
@Test
void 未知target_裸404() {
assertThat(router.route("/nobody/oapi/x", null)).as("未配置的 target = 裸 404").isNull();
}
@Test
void 单段URI_裸404() {
assertThat(router.route("/", null)).isNull();
assertThat(router.route("/foo", null)).isNull();
}
@Test
void 非oapi第二段_裸404() {
assertThat(router.route("/userbase/api/x", null)).as("网关只做 oapi,/api 一律 404").isNull();
assertThat(router.route("/userbase/oapix", null)).as("前缀相同不算 oapi 段").isNull();
}
@Test
void 尾斜杠base归一化_无双斜杠() {
TargetRouter.Routing r = router.route("/notice/oapi/x", null);
assertThat(r.forwardUrl).as("base-url 尾斜杠不得产生双斜杠")
.isEqualTo("http://notice-service:8080/notice/oapi/x");
}
@Test
void 裸oapi路径可路由() {
// /{target}/oapi(无尾路径)属 /{target}/oapi/** 匹配范围
TargetRouter.Routing r = router.route("/userbase/oapi", null);
assertThat(r).isNotNull();
assertThat(r.forwardUrl).isEqualTo("http://userbase-service:8080/userbase/oapi");
assertThat(r.pathAfterStrip).isEqualTo("/oapi");
}
}
+14
View File
@@ -0,0 +1,14 @@
spring:
datasource:
url: jdbc:h2:mem:gatewaytest;MODE=MySQL;DATABASE_TO_LOWER=TRUE
driver-class-name: org.h2.Driver
username: sa
password:
sql:
init:
# 直接跑交付 DDL(docs/sql/01_schema.sql)——round-trip 钉死「DDL ↔ 实体」一致
mode: always
schema-locations: file:docs/sql/01_schema.sql
jpa:
hibernate:
ddl-auto: none
@@ -0,0 +1,52 @@
{
"protocol": "v1",
"canonical": "METHOD\\npath[?decoded-sorted-query]\\ntimestamp -> HmacSHA256 -> Base64",
"source": "由 yh-app-auth-spring-boot-starter 真实代码(SignCanonical.sortRawQuery/buildTarget,反射调用)+ yh-utils-core SignUtil.signRequest 于 2026-09-21 执行生成(starter jar 1.0.0-SNAPSHOT,与 AppAuthFilter.buildSignedTarget 同一链路);三方对拍基线(§9 A4)",
"vectors": [
{
"secret": "sec-alpha-0123456789abcdef",
"method": "GET",
"pathAfterStrip": "/oapi/message/list",
"rawQuery": null,
"timestamp": "1758441600000",
"canonicalTarget": "/oapi/message/list",
"expectedSignature": "Regw08O602Tp61nhH6oG/5VIvyfmU9ZbWs42P7OHzj4="
},
{
"secret": "sec-alpha-0123456789abcdef",
"method": "GET",
"pathAfterStrip": "/oapi/user/byId",
"rawQuery": "id=7&name=zhang%20san",
"timestamp": "1758441600001",
"canonicalTarget": "/oapi/user/byId?id=7&name=zhang san",
"expectedSignature": "PfmaaIf+D+S4qcbcstvOzvhfDYOR8IQJlqTtX2/LfvM="
},
{
"secret": "sec-bravo-9876543210fedcba",
"method": "POST",
"pathAfterStrip": "/oapi/msg/sendUserMsg",
"rawQuery": "b=2&a=1&a=0",
"timestamp": "1758441600002",
"canonicalTarget": "/oapi/msg/sendUserMsg?a=0&a=1&b=2",
"expectedSignature": "Z8psqh0LER3OKQLzuJM2aqUpjHoZCcg/Ru5N5zT0rc8="
},
{
"secret": "sec-bravo-9876543210fedcba",
"method": "DELETE",
"pathAfterStrip": "/oapi/group/delete",
"rawQuery": "gid=3",
"timestamp": "1758441600003",
"canonicalTarget": "/oapi/group/delete?gid=3",
"expectedSignature": "oGBfhZrmge5w3RSiVOjEvkhcTyz27G8W8yCfcBqTYJ0="
},
{
"secret": "sec-alpha-0123456789abcdef",
"method": "GET",
"pathAfterStrip": "/oapi/OAStu/list",
"rawQuery": "page=1&size=20",
"timestamp": "1758441600004",
"canonicalTarget": "/oapi/OAStu/list?page=1&size=20",
"expectedSignature": "e4063mlXWxa3aB/yyXV7Umzvsn8o0hUIIzxdgNuQ5I0="
}
]
}