Compare commits
13
Commits
b9aa61ea11
...
4de8eda227
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4de8eda227 | ||
|
|
10374abd6d | ||
|
|
28b9bfca3c | ||
|
|
26f5787000 | ||
|
|
cdc4396ec2 | ||
|
|
58a1bb5358 | ||
|
|
949296eb67 | ||
|
|
a45a180008 | ||
|
|
4777d47499 | ||
|
|
6cb0fa1b5b | ||
|
|
d4bfe8a3b4 | ||
|
|
06a02750b0 | ||
|
|
f10343a4c1 |
@@ -0,0 +1,4 @@
|
||||
.git
|
||||
.idea
|
||||
*.md
|
||||
logs
|
||||
@@ -0,0 +1,4 @@
|
||||
target/
|
||||
.idea/
|
||||
*.iml
|
||||
logs/
|
||||
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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());
|
||||
@@ -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>
|
||||
* 条目量 <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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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="
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user