1. 新增外部系统编码部门编码关联管理

2. 新增统一的 api 对外门户管理
3. 修正各个模块的 api 命名
This commit is contained in:
chenbowen
2025-10-17 17:40:46 +08:00
parent ce8e06d2a3
commit 78bc88b7a6
106 changed files with 4200 additions and 1377 deletions

View File

@@ -22,7 +22,7 @@
<!-- <module>zt-module-ai</module>--> <!-- <module>zt-module-ai</module>-->
<module>zt-module-template</module> <module>zt-module-template</module>
<!-- <module>zt-module-iot</module>--> <!-- <module>zt-module-iot</module>-->
<!-- <module>zt-module-databus</module>--> <module>zt-module-databus</module>
<!-- <module>zt-module-rule</module>--> <!-- <module>zt-module-rule</module>-->
<!-- <module>zt-module-html2pdf</module>--> <!-- <module>zt-module-html2pdf</module>-->
</modules> </modules>

View File

@@ -1,31 +1,37 @@
-- 统一外部 API 网关菜单权限初始化DM8
-- 可重复执行的初始化脚本,统一外部网关所有页面改为权限菜单控制
DELETE FROM system_menu
WHERE id IN (6500,6501,6502,6503,
650101,650102,650103,650104,650105,650106,
650201,650202,650203,650204,
650301,650302,650303,650304);
-- 清理旧数据,确保脚本可重复执行 INSERT INTO system_menu (id, name, permission, type, sort, parent_id, path, icon, component, component_name,
DELETE FROM system_menu WHERE id IN (6500,6501,650101,650102,650103); status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted)
VALUES
(6500, '数据总线', '', 1, 20, 1, 'databus', 'ep:data-board', '', 'DatabusRoot',
0, '1', '1', '1', 'admin', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '0'),
(6501, 'API 定义', 'databus:gateway:query', 2, 10, 6500, 'gateway', 'ep:list', 'databus/gateway/index', 'DatabusGateway',
0, '1', '1', '1', 'admin', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '0'),
(6502, '客户端凭证', 'databus:credential:query', 2, 20, 6500, 'credential', 'ep:key', 'databus/credential/index', 'DatabusCredential',
0, '1', '1', '1', 'admin', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '0'),
(6503, '限流策略', 'databus:policy:query', 2, 30, 6500, 'policy/rate-limit', 'ep:stopwatch', 'databus/policy/RateLimitPolicy', 'DatabusRateLimitPolicy',
0, '1', '1', '1', 'admin', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '0');
-- 顶级目录(父级假定为 id=2 的系统管理目录) INSERT INTO system_menu (id, name, permission, type, sort, parent_id, path, icon, component, component_name,
INSERT INTO system_menu ( status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted)
id, name, permission, type, sort, parent_id, VALUES
path, icon, component, status, component_name (650101, 'API 查询', 'databus:gateway:query', 3, 1, 6501, '', '', '', '', 0, '1', '1', '1', 'admin', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '0'),
) VALUES ( (650102, 'API 新建', 'databus:gateway:create', 3, 2, 6501, '', '', '', '', 0, '1', '1', '1', 'admin', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '0'),
6500, '统一外部网关', '', 1, 20, 1, (650103, 'API 编辑', 'databus:gateway:update', 3, 3, 6501, '', '', '', '', 0, '1', '1', '1', 'admin', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '0'),
'databus', 'ep:data-line', '', 0, NULL (650104, 'API 删除', 'databus:gateway:delete', 3, 4, 6501, '', '', '', '', 0, '1', '1', '1', 'admin', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '0'),
); (650105, 'API 调试', 'databus:gateway:invoke', 3, 5, 6501, '', '', '', '', 0, '1', '1', '1', 'admin', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '0'),
(650106, 'API 刷新', 'databus:gateway:refresh', 3, 6, 6501, '', '', '', '', 0, '1', '1', '1', 'admin', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '0'),
-- API 门户页面 (650201, '凭证查询', 'databus:credential:query', 3, 1, 6502, '', '', '', '', 0, '1', '1', '1', 'admin', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '0'),
INSERT INTO system_menu ( (650202, '凭证新增', 'databus:credential:create', 3, 2, 6502, '', '', '', '', 0, '1', '1', '1', 'admin', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '0'),
id, name, permission, type, sort, parent_id, (650203, '凭证修改', 'databus:credential:update', 3, 3, 6502, '', '', '', '', 0, '1', '1', '1', 'admin', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '0'),
path, icon, component, status, component_name (650204, '凭证删除', 'databus:credential:delete', 3, 4, 6502, '', '', '', '', 0, '1', '1', '1', 'admin', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '0'),
) VALUES ( (650301, '策略查询', 'databus:policy:query', 3, 1, 6503, '', '', '', '', 0, '1', '1', '1', 'admin', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '0'),
6501, 'API 门户', 'databus:gateway:query', 2, 1, 6500, (650302, '策略新增', 'databus:policy:create', 3, 2, 6503, '', '', '', '', 0, '1', '1', '1', 'admin', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '0'),
'databus-gateway', 'ep:cpu', 'databus/gateway/index', 0, 'DatabusGateway' (650303, '策略修改', 'databus:policy:update', 3, 3, 6503, '', '', '', '', 0, '1', '1', '1', 'admin', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '0'),
); (650304, '策略删除', 'databus:policy:delete', 3, 4, 6503, '', '', '', '', 0, '1', '1', '1', 'admin', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '0');
-- 页面内操作按钮权限
INSERT INTO system_menu (
id, name, permission, type, sort, parent_id,
path, icon, component, status
) VALUES
(650101, 'API 列表', 'databus:gateway:query', 3, 1, 6501, '', '', '', 0),
(650102, 'API 调试', 'databus:gateway:invoke', 3, 2, 6501, '', '', '', 0),
(650103, '刷新定义', 'databus:gateway:refresh', 3, 3, 6501, '', '', '', 0);
d

View File

@@ -10,17 +10,13 @@ CREATE TABLE databus_api_definition (
id BIGINT NOT NULL PRIMARY KEY, id BIGINT NOT NULL PRIMARY KEY,
tenant_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL,
api_code VARCHAR(128) NOT NULL, api_code VARCHAR(128) NOT NULL,
uri_pattern VARCHAR(256) NOT NULL,
http_method VARCHAR(16) NOT NULL, http_method VARCHAR(16) NOT NULL,
version VARCHAR(32) NOT NULL, version VARCHAR(32) NOT NULL,
status SMALLINT DEFAULT 0 NOT NULL, status SMALLINT DEFAULT 0 NOT NULL,
description VARCHAR(512), description VARCHAR(512),
auth_policy_id BIGINT,
rate_limit_id BIGINT, rate_limit_id BIGINT,
response_template CLOB, response_template CLOB,
cache_strategy VARCHAR(128),
updated_at DATETIME, updated_at DATETIME,
grey_released BIT DEFAULT '0' NOT NULL,
creator VARCHAR(64) DEFAULT '' NOT NULL, creator VARCHAR(64) DEFAULT '' NOT NULL,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updater VARCHAR(64) DEFAULT '' NOT NULL, updater VARCHAR(64) DEFAULT '' NOT NULL,
@@ -30,23 +26,18 @@ CREATE TABLE databus_api_definition (
CREATE UNIQUE INDEX uk_databus_api_definition_code_ver ON databus_api_definition (tenant_id, api_code, version); CREATE UNIQUE INDEX uk_databus_api_definition_code_ver ON databus_api_definition (tenant_id, api_code, version);
CREATE INDEX idx_databus_api_definition_status ON databus_api_definition (tenant_id, status); CREATE INDEX idx_databus_api_definition_status ON databus_api_definition (tenant_id, status);
CREATE INDEX idx_databus_api_definition_policy ON databus_api_definition (tenant_id, auth_policy_id, rate_limit_id);
COMMENT ON TABLE databus_api_definition IS '统一外部 API 门户 - API 定义表'; COMMENT ON TABLE databus_api_definition IS '统一外部 API 门户 - API 定义表';
COMMENT ON COLUMN databus_api_definition.id IS '主键 ID'; COMMENT ON COLUMN databus_api_definition.id IS '主键 ID';
COMMENT ON COLUMN databus_api_definition.tenant_id IS '租户编号'; COMMENT ON COLUMN databus_api_definition.tenant_id IS '租户编号';
COMMENT ON COLUMN databus_api_definition.api_code IS 'API 编码'; COMMENT ON COLUMN databus_api_definition.api_code IS 'API 编码';
COMMENT ON COLUMN databus_api_definition.uri_pattern IS '匹配路径模板';
COMMENT ON COLUMN databus_api_definition.http_method IS 'HTTP 方法'; COMMENT ON COLUMN databus_api_definition.http_method IS 'HTTP 方法';
COMMENT ON COLUMN databus_api_definition.version IS '版本号'; COMMENT ON COLUMN databus_api_definition.version IS '版本号';
COMMENT ON COLUMN databus_api_definition.status IS '发布状态'; COMMENT ON COLUMN databus_api_definition.status IS '发布状态';
COMMENT ON COLUMN databus_api_definition.description IS '描述信息'; COMMENT ON COLUMN databus_api_definition.description IS '描述信息';
COMMENT ON COLUMN databus_api_definition.auth_policy_id IS '认证策略 ID';
COMMENT ON COLUMN databus_api_definition.rate_limit_id IS '限流策略 ID'; COMMENT ON COLUMN databus_api_definition.rate_limit_id IS '限流策略 ID';
COMMENT ON COLUMN databus_api_definition.response_template IS '响应模板 JSON'; COMMENT ON COLUMN databus_api_definition.response_template IS '响应模板 JSON';
COMMENT ON COLUMN databus_api_definition.cache_strategy IS '缓存策略配置';
COMMENT ON COLUMN databus_api_definition.updated_at IS '业务更新时间'; COMMENT ON COLUMN databus_api_definition.updated_at IS '业务更新时间';
COMMENT ON COLUMN databus_api_definition.grey_released IS '灰度发布标记';
COMMENT ON COLUMN databus_api_definition.creator IS '创建者'; COMMENT ON COLUMN databus_api_definition.creator IS '创建者';
COMMENT ON COLUMN databus_api_definition.create_time IS '创建时间'; COMMENT ON COLUMN databus_api_definition.create_time IS '创建时间';
COMMENT ON COLUMN databus_api_definition.updater IS '更新者'; COMMENT ON COLUMN databus_api_definition.updater IS '更新者';
@@ -89,38 +80,6 @@ COMMENT ON COLUMN databus_api_flow_publish.updater IS '更新者';
COMMENT ON COLUMN databus_api_flow_publish.update_time IS '更新时间'; COMMENT ON COLUMN databus_api_flow_publish.update_time IS '更新时间';
COMMENT ON COLUMN databus_api_flow_publish.deleted IS '逻辑删除标记'; COMMENT ON COLUMN databus_api_flow_publish.deleted IS '逻辑删除标记';
-- ----------------------------
-- Table structure for databus_policy_auth
-- ----------------------------
CREATE TABLE databus_policy_auth (
id BIGINT NOT NULL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
name VARCHAR(128) NOT NULL,
type VARCHAR(64) NOT NULL,
config CLOB,
description VARCHAR(512),
creator VARCHAR(64) DEFAULT '' NOT NULL,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updater VARCHAR(64) DEFAULT '' NOT NULL,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
deleted BIT DEFAULT '0' NOT NULL
);
CREATE UNIQUE INDEX uk_databus_policy_auth_name ON databus_policy_auth (tenant_id, name);
COMMENT ON TABLE databus_policy_auth IS '统一外部 API 门户 - 认证策略表';
COMMENT ON COLUMN databus_policy_auth.id IS '主键 ID';
COMMENT ON COLUMN databus_policy_auth.tenant_id IS '租户编号';
COMMENT ON COLUMN databus_policy_auth.name IS '策略名称';
COMMENT ON COLUMN databus_policy_auth.type IS '策略类型';
COMMENT ON COLUMN databus_policy_auth.config IS '策略配置 JSON';
COMMENT ON COLUMN databus_policy_auth.description IS '描述信息';
COMMENT ON COLUMN databus_policy_auth.creator IS '创建者';
COMMENT ON COLUMN databus_policy_auth.create_time IS '创建时间';
COMMENT ON COLUMN databus_policy_auth.updater IS '更新者';
COMMENT ON COLUMN databus_policy_auth.update_time IS '更新时间';
COMMENT ON COLUMN databus_policy_auth.deleted IS '逻辑删除标记';
-- ---------------------------- -- ----------------------------
-- Table structure for databus_policy_rate_limit -- Table structure for databus_policy_rate_limit
-- ---------------------------- -- ----------------------------
@@ -168,7 +127,6 @@ CREATE TABLE databus_api_step (
response_mapping_expr CLOB, response_mapping_expr CLOB,
transform_id BIGINT, transform_id BIGINT,
timeout BIGINT, timeout BIGINT,
retry_strategy CLOB,
fallback_strategy CLOB, fallback_strategy CLOB,
condition_expr CLOB, condition_expr CLOB,
stop_on_error BIT DEFAULT '0' NOT NULL, stop_on_error BIT DEFAULT '0' NOT NULL,
@@ -193,7 +151,6 @@ COMMENT ON COLUMN databus_api_step.request_mapping_expr IS '请求映射表达
COMMENT ON COLUMN databus_api_step.response_mapping_expr IS '响应映射表达式'; COMMENT ON COLUMN databus_api_step.response_mapping_expr IS '响应映射表达式';
COMMENT ON COLUMN databus_api_step.transform_id IS '默认变换 ID'; COMMENT ON COLUMN databus_api_step.transform_id IS '默认变换 ID';
COMMENT ON COLUMN databus_api_step.timeout IS '超时时间(毫秒)'; COMMENT ON COLUMN databus_api_step.timeout IS '超时时间(毫秒)';
COMMENT ON COLUMN databus_api_step.retry_strategy IS '重试策略 JSON';
COMMENT ON COLUMN databus_api_step.fallback_strategy IS '降级策略 JSON'; COMMENT ON COLUMN databus_api_step.fallback_strategy IS '降级策略 JSON';
COMMENT ON COLUMN databus_api_step.condition_expr IS '执行条件表达式'; COMMENT ON COLUMN databus_api_step.condition_expr IS '执行条件表达式';
COMMENT ON COLUMN databus_api_step.stop_on_error IS '出错是否终止'; COMMENT ON COLUMN databus_api_step.stop_on_error IS '出错是否终止';
@@ -239,3 +196,43 @@ COMMENT ON COLUMN databus_api_transform.create_time IS '创建时间';
COMMENT ON COLUMN databus_api_transform.updater IS '更新者'; COMMENT ON COLUMN databus_api_transform.updater IS '更新者';
COMMENT ON COLUMN databus_api_transform.update_time IS '更新时间'; COMMENT ON COLUMN databus_api_transform.update_time IS '更新时间';
COMMENT ON COLUMN databus_api_transform.deleted IS '逻辑删除标记'; COMMENT ON COLUMN databus_api_transform.deleted IS '逻辑删除标记';
-- 统一外部网关 - 客户端凭证表DM8
-- 可重复执行的建表脚本,执行前请备份历史数据
DROP TABLE IF EXISTS databus_api_client_credential;
CREATE TABLE databus_api_client_credential (
id BIGINT NOT NULL PRIMARY KEY,
app_id VARCHAR(64) NOT NULL,
app_name VARCHAR(128),
encryption_key VARCHAR(512) NOT NULL,
encryption_type VARCHAR(32) NOT NULL,
signature_type VARCHAR(32) NOT NULL,
enabled BIT DEFAULT '1' NOT NULL,
remark VARCHAR(255),
creator VARCHAR(64) DEFAULT '' NOT NULL,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updater VARCHAR(64) DEFAULT '' NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
deleted BIT DEFAULT '0' NOT NULL
);
CREATE UNIQUE INDEX uk_databus_api_client_credential_app ON databus_api_client_credential (app_id);
CREATE INDEX idx_databus_api_client_credential_enabled ON databus_api_client_credential (enabled);
COMMENT ON TABLE databus_api_client_credential IS '统一外部 API 门户 - 客户端凭证表';
COMMENT ON COLUMN databus_api_client_credential.id IS '主键 ID';
COMMENT ON COLUMN databus_api_client_credential.app_id IS '客户端标识';
COMMENT ON COLUMN databus_api_client_credential.app_name IS '客户端名称';
COMMENT ON COLUMN databus_api_client_credential.encryption_key IS '加密密钥 Base64';
COMMENT ON COLUMN databus_api_client_credential.encryption_type IS '加密算法';
COMMENT ON COLUMN databus_api_client_credential.signature_type IS '签名算法';
COMMENT ON COLUMN databus_api_client_credential.enabled IS '是否启用';
COMMENT ON COLUMN databus_api_client_credential.remark IS '备注';
COMMENT ON COLUMN databus_api_client_credential.creator IS '创建者';
COMMENT ON COLUMN databus_api_client_credential.create_time IS '创建时间';
COMMENT ON COLUMN databus_api_client_credential.updater IS '更新者';
COMMENT ON COLUMN databus_api_client_credential.update_time IS '更新时间';
COMMENT ON COLUMN databus_api_client_credential.deleted IS '逻辑删除标记';

View File

@@ -0,0 +1,217 @@
package com.zt.plat.framework.common.util.security;
import cn.hutool.crypto.SecureUtil;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
/**
* 通用的签名、加解密工具类
*/
public final class CryptoSignatureUtils {
public static final String ENCRYPT_TYPE_AES = "AES";
public static final String ENCRYPT_TYPE_DES = "DES";
public static final String SIGNATURE_TYPE_MD5 = "MD5";
public static final String SIGNATURE_TYPE_SHA256 = "SHA256";
private static final String AES_TRANSFORMATION = "AES/ECB/PKCS5Padding";
private static final String SIGNATURE_FIELD = "signature";
private CryptoSignatureUtils() {
}
/**
* 生成 AES 密钥SecretKeySpec
*
* @param password 密钥字符串
* @return SecretKeySpec
*/
public static SecretKeySpec getSecretKey(String password) {
try {
KeyGenerator kg = KeyGenerator.getInstance("AES");
SecureRandom random = SecureRandom.getInstance("SHA1PRNG");
random.setSeed(password.getBytes(StandardCharsets.UTF_8));
kg.init(128, random);
SecretKey secretKey = kg.generateKey();
return new SecretKeySpec(secretKey.getEncoded(), "AES");
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("Failed to generate AES secret key", ex);
}
}
/**
* 对称加密Base64 格式输出)
*
* @param plaintext 明文内容
* @param key 密钥
* @param type 加密类型,支持 AES、DES
* @return 密文Base64 格式)
*/
public static String encrypt(String plaintext, String key, String type) {
if (ENCRYPT_TYPE_AES.equalsIgnoreCase(type)) {
try {
Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, getSecretKey(key));
byte[] result = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(result);
} catch (Exception ex) {
throw new IllegalStateException("Failed to encrypt using AES", ex);
}
} else if (ENCRYPT_TYPE_DES.equalsIgnoreCase(type)) {
byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
byte[] desKey = new byte[8];
System.arraycopy(keyBytes, 0, desKey, 0, Math.min(keyBytes.length, desKey.length));
byte[] encrypted = SecureUtil.des(desKey).encrypt(plaintext.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(encrypted);
} else {
throw new IllegalArgumentException("Unsupported encryption type: " + type);
}
}
/**
* 对称解密(输入为 Base64 格式密文)
*
* @param ciphertext 密文内容Base64 格式)
* @param key 密钥
* @param type 加密类型,支持 AES、DES
* @return 明文内容
*/
public static String decrypt(String ciphertext, String key, String type) {
if (ciphertext == null) {
return null;
}
if (ENCRYPT_TYPE_AES.equalsIgnoreCase(type)) {
try {
Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(key));
byte[] decoded = decodeBase64Ciphertext(ciphertext);
byte[] result = cipher.doFinal(decoded);
return new String(result, StandardCharsets.UTF_8);
} catch (Exception ex) {
throw new IllegalStateException("Failed to decrypt using AES", ex);
}
} else if (ENCRYPT_TYPE_DES.equalsIgnoreCase(type)) {
byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
byte[] desKey = new byte[8];
System.arraycopy(keyBytes, 0, desKey, 0, Math.min(keyBytes.length, desKey.length));
byte[] decoded = decodeBase64Ciphertext(ciphertext);
byte[] decrypted = SecureUtil.des(desKey).decrypt(decoded);
return new String(decrypted, StandardCharsets.UTF_8);
} else {
throw new IllegalArgumentException("Unsupported encryption type: " + type);
}
}
/**
* 验证请求签名
*
* @param reqMap 请求参数 Map
* @param type 签名算法类型,支持 MD5、SHA256
* @return 签名是否有效
*/
public static boolean verifySignature(Map<String, Object> reqMap, String type) {
Map<String, Object> sortedMap = new TreeMap<>(reqMap);
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, Object> entry : sortedMap.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
if (SIGNATURE_FIELD.equals(key) || value == null) {
continue;
}
sb.append(key).append('=');
sb.append(value);
sb.append('&');
}
if (sb.length() > 0) {
sb.deleteCharAt(sb.length() - 1);
}
String provided = (String) reqMap.get(SIGNATURE_FIELD);
if (provided == null) {
return false;
}
String computed;
if (SIGNATURE_TYPE_MD5.equalsIgnoreCase(type)) {
computed = SecureUtil.md5(sb.toString());
} else if (SIGNATURE_TYPE_SHA256.equalsIgnoreCase(type)) {
computed = SecureUtil.sha256(sb.toString());
} else {
throw new IllegalArgumentException("Unsupported signature type: " + type);
}
return provided.equalsIgnoreCase(computed);
}
private static byte[] decodeBase64Ciphertext(String ciphertext) {
IllegalArgumentException last = null;
for (String candidate : buildBase64Candidates(ciphertext)) {
if (candidate == null || candidate.isEmpty()) {
continue;
}
try {
return Base64.getDecoder().decode(candidate);
} catch (IllegalArgumentException ex) {
last = ex;
}
}
throw last != null ? last : new IllegalArgumentException("Invalid Base64 content");
}
private static Set<String> buildBase64Candidates(String ciphertext) {
Set<String> candidates = new LinkedHashSet<>();
if (ciphertext == null) {
return candidates;
}
String trimmed = ciphertext.trim();
candidates.add(trimmed);
String withoutWhitespace = stripWhitespace(trimmed);
candidates.add(withoutWhitespace);
if (trimmed.indexOf(' ') >= 0) {
String restoredPlus = trimmed.replace(' ', '+');
candidates.add(restoredPlus);
candidates.add(stripWhitespace(restoredPlus));
}
String urlNormalised = withoutWhitespace
.replace('-', '+')
.replace('_', '/');
candidates.add(urlNormalised);
return candidates;
}
private static String stripWhitespace(String value) {
if (value == null) {
return null;
}
boolean hasWhitespace = false;
for (int i = 0; i < value.length(); i++) {
if (Character.isWhitespace(value.charAt(i))) {
hasWhitespace = true;
break;
}
}
if (!hasWhitespace) {
return value;
}
StringBuilder sb = new StringBuilder(value.length());
for (int i = 0; i < value.length(); i++) {
char ch = value.charAt(i);
if (!Character.isWhitespace(ch)) {
sb.append(ch);
}
}
return sb.toString();
}
}

View File

@@ -0,0 +1,50 @@
package com.zt.plat.framework.common.util.security;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
class CryptoSignatureUtilsTest {
@Test
void decryptShouldIgnoreWhitespaceInCiphertext() {
String key = "test-key";
String plaintext = "{\"sample\":123}";
String encrypted = CryptoSignatureUtils.encrypt(plaintext, key, CryptoSignatureUtils.ENCRYPT_TYPE_AES);
int splitIndex = Math.max(1, encrypted.length() / 2);
String cipherWithWhitespace = " " + encrypted.substring(0, splitIndex)
+ " \n\t "
+ encrypted.substring(splitIndex);
String decrypted = CryptoSignatureUtils.decrypt(cipherWithWhitespace, key, CryptoSignatureUtils.ENCRYPT_TYPE_AES);
assertEquals(plaintext, decrypted);
}
@Test
void decryptShouldRestorePlusCharactersConvertedToSpaces() {
String key = "test-key";
String basePlaintext = "payload-";
String encryptedWithPlus = null;
String chosenPlaintext = null;
for (int i = 0; i < 100; i++) {
String candidatePlaintext = basePlaintext + i;
String candidateEncrypted = CryptoSignatureUtils.encrypt(candidatePlaintext, key, CryptoSignatureUtils.ENCRYPT_TYPE_AES);
if (candidateEncrypted.indexOf('+') >= 0) {
encryptedWithPlus = candidateEncrypted;
chosenPlaintext = candidatePlaintext;
break;
}
}
assertNotNull(encryptedWithPlus, "Expected to generate ciphertext containing '+' character");
String mutatedCipher = encryptedWithPlus.replace('+', ' ');
String decrypted = CryptoSignatureUtils.decrypt(mutatedCipher, key, CryptoSignatureUtils.ENCRYPT_TYPE_AES);
assertEquals(chosenPlaintext, decrypted);
}
}

View File

@@ -1,11 +1,20 @@
package com.zt.plat.framework.signature.config; package com.zt.plat.framework.signature.config;
import com.zt.plat.framework.redis.config.ZtRedisAutoConfiguration; import com.zt.plat.framework.redis.config.ZtRedisAutoConfiguration;
import com.zt.plat.framework.signature.core.aop.ApiSignatureAspect; import com.zt.plat.framework.signature.core.ApiSignatureVerifier;
import com.zt.plat.framework.signature.core.config.ApiSignatureProperties;
import com.zt.plat.framework.signature.core.redis.ApiSignatureRedisDAO; import com.zt.plat.framework.signature.core.redis.ApiSignatureRedisDAO;
import com.zt.plat.framework.signature.core.web.ApiSignatureHandlerInterceptor;
import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.util.CollectionUtils;
import org.springframework.web.servlet.config.annotation.InterceptorRegistration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;
/** /**
* HTTP API 签名的自动配置类 * HTTP API 签名的自动配置类
@@ -13,16 +22,47 @@ import org.springframework.data.redis.core.StringRedisTemplate;
* @author Zhougang * @author Zhougang
*/ */
@AutoConfiguration(after = ZtRedisAutoConfiguration.class) @AutoConfiguration(after = ZtRedisAutoConfiguration.class)
@EnableConfigurationProperties(ApiSignatureProperties.class)
public class ZtApiSignatureAutoConfiguration { public class ZtApiSignatureAutoConfiguration {
@Bean
public ApiSignatureAspect signatureAspect(ApiSignatureRedisDAO signatureRedisDAO) {
return new ApiSignatureAspect(signatureRedisDAO);
}
@Bean @Bean
public ApiSignatureRedisDAO signatureRedisDAO(StringRedisTemplate stringRedisTemplate) { public ApiSignatureRedisDAO signatureRedisDAO(StringRedisTemplate stringRedisTemplate) {
return new ApiSignatureRedisDAO(stringRedisTemplate); return new ApiSignatureRedisDAO(stringRedisTemplate);
} }
@Bean
public ApiSignatureVerifier apiSignatureVerifier(ApiSignatureRedisDAO signatureRedisDAO) {
return new ApiSignatureVerifier(signatureRedisDAO);
}
@Bean
public ApiSignatureHandlerInterceptor apiSignatureHandlerInterceptor(ApiSignatureVerifier verifier,
ApiSignatureProperties properties) {
return new ApiSignatureHandlerInterceptor(verifier, properties);
}
@Bean
public WebMvcConfigurer apiSignatureWebMvcConfigurer(ApiSignatureHandlerInterceptor interceptor,
ApiSignatureProperties properties) {
return new WebMvcConfigurer() {
@Override
public void addInterceptors(InterceptorRegistry registry) {
if (!properties.isEnabled()) {
return;
}
InterceptorRegistration registration = registry.addInterceptor(interceptor);
List<String> includePaths = properties.getIncludePaths();
if (CollectionUtils.isEmpty(includePaths)) {
registration.addPathPatterns("/**");
} else {
registration.addPathPatterns(includePaths.toArray(new String[0]));
}
List<String> excludePaths = properties.getExcludePaths();
if (!CollectionUtils.isEmpty(excludePaths)) {
registration.excludePathPatterns(excludePaths.toArray(new String[0]));
}
}
};
}
} }

View File

@@ -0,0 +1,111 @@
package com.zt.plat.framework.signature.core;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
import com.zt.plat.framework.common.exception.ServiceException;
import com.zt.plat.framework.common.exception.enums.GlobalErrorCodeConstants;
import com.zt.plat.framework.common.util.servlet.ServletUtils;
import com.zt.plat.framework.signature.core.model.ApiSignatureRule;
import com.zt.plat.framework.signature.core.redis.ApiSignatureRedisDAO;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
import java.util.SortedMap;
import java.util.TreeMap;
import static com.zt.plat.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST;
/**
* API 签名校验器
*/
@Slf4j
@RequiredArgsConstructor
public class ApiSignatureVerifier {
private final ApiSignatureRedisDAO signatureRedisDAO;
public boolean verify(ApiSignatureRule rule, HttpServletRequest request) {
// 1. 校验请求头
verifyHeaders(rule, request);
// 2. 校验 appId 对应的 appSecret 是否存在
String appId = request.getHeader(rule.getAppId());
String appSecret = signatureRedisDAO.getAppSecret(appId);
Assert.notNull(appSecret, "[appId({})] 找不到对应的 appSecret", appId);
// 3. 校验签名
String clientSignature = request.getHeader(rule.getSign());
String serverSignatureString = buildSignatureString(rule, request, appSecret);
String serverSignature = DigestUtil.sha256Hex(serverSignatureString);
if (ObjUtil.notEqual(clientSignature, serverSignature)) {
throw new ServiceException(BAD_REQUEST.getCode(), rule.getMessage());
}
// 4. 缓存 nonce防止重复请求
String nonce = request.getHeader(rule.getNonce());
if (BooleanUtil.isFalse(signatureRedisDAO.setNonce(appId, nonce, rule.getTimeout() * 2, rule.getTimeUnit()))) {
String timestamp = request.getHeader(rule.getTimestamp());
log.info("[verifySignature][appId({}) timestamp({}) nonce({}) sign({}) 存在重复请求]", appId, timestamp, nonce, clientSignature);
throw new ServiceException(GlobalErrorCodeConstants.REPEATED_REQUESTS.getCode(), "存在重复请求");
}
return true;
}
private void verifyHeaders(ApiSignatureRule rule, HttpServletRequest request) {
String appId = request.getHeader(rule.getAppId());
String timestamp = request.getHeader(rule.getTimestamp());
String nonce = request.getHeader(rule.getNonce());
String sign = request.getHeader(rule.getSign());
if (StrUtil.isBlank(appId) || StrUtil.isBlank(timestamp) || StrUtil.isBlank(sign) || StrUtil.length(nonce) < 10) {
throw new ServiceException(BAD_REQUEST.getCode(), rule.getMessage());
}
long expireTime = rule.getTimeUnit().toMillis(rule.getTimeout());
long requestTimestamp;
try {
requestTimestamp = Long.parseLong(timestamp);
} catch (NumberFormatException ex) {
throw new ServiceException(BAD_REQUEST.getCode(), rule.getMessage());
}
long timestampDisparity = Math.abs(System.currentTimeMillis() - requestTimestamp);
if (timestampDisparity > expireTime) {
throw new ServiceException(BAD_REQUEST.getCode(), rule.getMessage());
}
if (signatureRedisDAO.getNonce(appId, nonce) != null) {
throw new ServiceException(BAD_REQUEST.getCode(), rule.getMessage());
}
}
private String buildSignatureString(ApiSignatureRule rule, HttpServletRequest request, String appSecret) {
SortedMap<String, String> parameterMap = getRequestParameterMap(request);
SortedMap<String, String> headerMap = getRequestHeaderMap(rule, request);
String requestBody = StrUtil.nullToDefault(ServletUtils.getBody(request), "");
return MapUtil.join(parameterMap, "&", "=")
+ requestBody
+ MapUtil.join(headerMap, "&", "=")
+ appSecret;
}
private SortedMap<String, String> getRequestHeaderMap(ApiSignatureRule rule, HttpServletRequest request) {
SortedMap<String, String> sortedMap = new TreeMap<>();
sortedMap.put(rule.getAppId(), request.getHeader(rule.getAppId()));
sortedMap.put(rule.getTimestamp(), request.getHeader(rule.getTimestamp()));
sortedMap.put(rule.getNonce(), request.getHeader(rule.getNonce()));
return sortedMap;
}
private SortedMap<String, String> getRequestParameterMap(HttpServletRequest request) {
SortedMap<String, String> sortedMap = new TreeMap<>();
for (Map.Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
sortedMap.put(entry.getKey(), entry.getValue()[0]);
}
return sortedMap;
}
}

View File

@@ -1,29 +1,18 @@
package com.zt.plat.framework.signature.core.aop; package com.zt.plat.framework.signature.core.aop;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
import com.zt.plat.framework.common.exception.ServiceException;
import com.zt.plat.framework.common.exception.enums.GlobalErrorCodeConstants;
import com.zt.plat.framework.common.util.servlet.ServletUtils; import com.zt.plat.framework.common.util.servlet.ServletUtils;
import com.zt.plat.framework.signature.core.ApiSignatureVerifier;
import com.zt.plat.framework.signature.core.annotation.ApiSignature; import com.zt.plat.framework.signature.core.annotation.ApiSignature;
import com.zt.plat.framework.signature.core.config.ApiSignatureProperties;
import com.zt.plat.framework.signature.core.model.ApiSignatureRule;
import com.zt.plat.framework.signature.core.redis.ApiSignatureRedisDAO; import com.zt.plat.framework.signature.core.redis.ApiSignatureRedisDAO;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint; import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before; import org.aspectj.lang.annotation.Before;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.SortedMap;
import java.util.TreeMap;
import static com.zt.plat.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST;
/** /**
* 拦截声明了 {@link ApiSignature} 注解的方法,实现签名 * 拦截声明了 {@link ApiSignature} 注解的方法,实现签名
@@ -32,143 +21,33 @@ import static com.zt.plat.framework.common.exception.enums.GlobalErrorCodeConsta
*/ */
@Aspect @Aspect
@Slf4j @Slf4j
@AllArgsConstructor @Deprecated
public class ApiSignatureAspect { public class ApiSignatureAspect {
private final ApiSignatureRedisDAO signatureRedisDAO; private final ApiSignatureVerifier verifier;
private final ApiSignatureProperties properties;
public ApiSignatureAspect(ApiSignatureRedisDAO signatureRedisDAO) {
this(new ApiSignatureVerifier(signatureRedisDAO), new ApiSignatureProperties());
}
public ApiSignatureAspect(ApiSignatureVerifier verifier, ApiSignatureProperties properties) {
this.verifier = verifier;
this.properties = properties;
}
@Before("@annotation(signature)") @Before("@annotation(signature)")
public void beforePointCut(JoinPoint joinPoint, ApiSignature signature) { public void beforePointCut(JoinPoint joinPoint, ApiSignature signature) {
// 1. 验证通过,直接结束 HttpServletRequest request = Objects.requireNonNull(ServletUtils.getRequest());
if (verifySignature(signature, Objects.requireNonNull(ServletUtils.getRequest()))) { ApiSignatureRule rule = ApiSignatureRule.from(signature, properties);
return; verifier.verify(rule, request);
} log.debug("[beforePointCut][方法{} 参数({}) 签名校验通过]", joinPoint.getSignature(), joinPoint.getArgs());
// 2. 验证不通过,抛出异常
log.error("[beforePointCut][方法{} 参数({}) 签名失败]", joinPoint.getSignature().toString(),
joinPoint.getArgs());
throw new ServiceException(BAD_REQUEST.getCode(),
StrUtil.blankToDefault(signature.message(), BAD_REQUEST.getMsg()));
} }
public boolean verifySignature(ApiSignature signature, HttpServletRequest request) { public boolean verifySignature(ApiSignature signature, HttpServletRequest request) {
// 1.1 校验 Header ApiSignatureRule rule = ApiSignatureRule.from(signature, properties);
if (!verifyHeaders(signature, request)) { verifier.verify(rule, request);
return false;
}
// 1.2 校验 appId 是否能获取到对应的 appSecret
String appId = request.getHeader(signature.appId());
String appSecret = signatureRedisDAO.getAppSecret(appId);
Assert.notNull(appSecret, "[appId({})] 找不到对应的 appSecret", appId);
// 2. 校验签名【重要!】
String clientSignature = request.getHeader(signature.sign()); // 客户端签名
String serverSignatureString = buildSignatureString(signature, request, appSecret); // 服务端签名字符串
String serverSignature = DigestUtil.sha256Hex(serverSignatureString); // 服务端签名
if (ObjUtil.notEqual(clientSignature, serverSignature)) {
return false;
}
// 3. 将 nonce 记入缓存,防止重复使用(重点二:此处需要将 ttl 设定为允许 timestamp 时间差的值 x 2
String nonce = request.getHeader(signature.nonce());
if (BooleanUtil.isFalse(signatureRedisDAO.setNonce(appId, nonce, signature.timeout() * 2, signature.timeUnit()))) {
String timestamp = request.getHeader(signature.timestamp());
log.info("[verifySignature][appId({}) timestamp({}) nonce({}) sign({}) 存在重复请求]", appId, timestamp, nonce, clientSignature);
throw new ServiceException(GlobalErrorCodeConstants.REPEATED_REQUESTS.getCode(), "存在重复请求");
}
return true; return true;
} }
/**
* 校验请求头加签参数
* <p>
* 1. appId 是否为空
* 2. timestamp 是否为空,请求是否已经超时,默认 10 分钟
* 3. nonce 是否为空,随机数是否 10 位以上,是否在规定时间内已经访问过了
* 4. sign 是否为空
*
* @param signature signature
* @param request request
* @return 是否校验 Header 通过
*/
private boolean verifyHeaders(ApiSignature signature, HttpServletRequest request) {
// 1. 非空校验
String appId = request.getHeader(signature.appId());
if (StrUtil.isBlank(appId)) {
return false;
}
String timestamp = request.getHeader(signature.timestamp());
if (StrUtil.isBlank(timestamp)) {
return false;
}
String nonce = request.getHeader(signature.nonce());
if (StrUtil.length(nonce) < 10) {
return false;
}
String sign = request.getHeader(signature.sign());
if (StrUtil.isBlank(sign)) {
return false;
}
// 2. 检查 timestamp 是否超出允许的范围 (重点一:此处需要取绝对值)
long expireTime = signature.timeUnit().toMillis(signature.timeout());
long requestTimestamp = Long.parseLong(timestamp);
long timestampDisparity = Math.abs(System.currentTimeMillis() - requestTimestamp);
if (timestampDisparity > expireTime) {
return false;
}
// 3. 检查 nonce 是否存在,有且仅能使用一次
return signatureRedisDAO.getNonce(appId, nonce) == null;
}
/**
* 构建签名字符串
* <p>
* 格式为 = 请求参数 + 请求体 + 请求头 + 密钥
*
* @param signature signature
* @param request request
* @param appSecret appSecret
* @return 签名字符串
*/
private String buildSignatureString(ApiSignature signature, HttpServletRequest request, String appSecret) {
SortedMap<String, String> parameterMap = getRequestParameterMap(request); // 请求头
SortedMap<String, String> headerMap = getRequestHeaderMap(signature, request); // 请求参数
String requestBody = StrUtil.nullToDefault(ServletUtils.getBody(request), ""); // 请求体
return MapUtil.join(parameterMap, "&", "=")
+ requestBody
+ MapUtil.join(headerMap, "&", "=")
+ appSecret;
}
/**
* 获取请求头加签参数 Map
*
* @param request 请求
* @param signature 签名注解
* @return signature params
*/
private static SortedMap<String, String> getRequestHeaderMap(ApiSignature signature, HttpServletRequest request) {
SortedMap<String, String> sortedMap = new TreeMap<>();
sortedMap.put(signature.appId(), request.getHeader(signature.appId()));
sortedMap.put(signature.timestamp(), request.getHeader(signature.timestamp()));
sortedMap.put(signature.nonce(), request.getHeader(signature.nonce()));
return sortedMap;
}
/**
* 获取请求参数 Map
*
* @param request 请求
* @return queryParams
*/
private static SortedMap<String, String> getRequestParameterMap(HttpServletRequest request) {
SortedMap<String, String> sortedMap = new TreeMap<>();
for (Map.Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
sortedMap.put(entry.getKey(), entry.getValue()[0]);
}
return sortedMap;
}
} }

View File

@@ -0,0 +1,68 @@
package com.zt.plat.framework.signature.core.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* API 签名配置
*/
@Data
@ConfigurationProperties(prefix = "zt.api-signature")
public class ApiSignatureProperties {
/**
* 是否开启全局签名校验
*/
private boolean enabled = true;
/**
* 签名有效期
*/
private int timeout = 60;
/**
* 时间单位
*/
private TimeUnit timeUnit = TimeUnit.SECONDS;
/**
* 校验失败时的提示信息
*/
private String message = "签名不正确";
/**
* 请求头appId
*/
private String appId = "appId";
/**
* 请求头timestamp
*/
private String timestamp = "timestamp";
/**
* 请求头nonce
*/
private String nonce = "nonce";
/**
* 请求头sign
*/
private String sign = "sign";
/**
* 需要进行签名校验的路径,默认全量
*/
private List<String> includePaths = new ArrayList<>(Arrays.asList("/**"));
/**
* 无需签名校验的路径
*/
private List<String> excludePaths = new ArrayList<>(Arrays.asList("/error", "/swagger-ui/**", "/v3/api-docs/**"));
}

View File

@@ -0,0 +1,50 @@
package com.zt.plat.framework.signature.core.model;
import cn.hutool.core.util.StrUtil;
import com.zt.plat.framework.signature.core.annotation.ApiSignature;
import com.zt.plat.framework.signature.core.config.ApiSignatureProperties;
import lombok.Builder;
import lombok.Getter;
import java.util.concurrent.TimeUnit;
/**
* 签名校验规则
*/
@Getter
@Builder
public class ApiSignatureRule {
private final int timeout;
private final TimeUnit timeUnit;
private final String message;
private final String appId;
private final String timestamp;
private final String nonce;
private final String sign;
public static ApiSignatureRule from(ApiSignatureProperties properties) {
return ApiSignatureRule.builder()
.timeout(properties.getTimeout())
.timeUnit(properties.getTimeUnit())
.message(properties.getMessage())
.appId(properties.getAppId())
.timestamp(properties.getTimestamp())
.nonce(properties.getNonce())
.sign(properties.getSign())
.build();
}
public static ApiSignatureRule from(ApiSignature signature, ApiSignatureProperties defaults) {
return ApiSignatureRule.builder()
.timeout(signature.timeout())
.timeUnit(signature.timeUnit())
.message(StrUtil.blankToDefault(signature.message(), defaults.getMessage()))
.appId(signature.appId())
.timestamp(signature.timestamp())
.nonce(signature.nonce())
.sign(signature.sign())
.build();
}
}

View File

@@ -0,0 +1,80 @@
package com.zt.plat.framework.signature.core.web;
import com.zt.plat.framework.signature.core.ApiSignatureVerifier;
import com.zt.plat.framework.signature.core.annotation.ApiSignature;
import com.zt.plat.framework.signature.core.config.ApiSignatureProperties;
import com.zt.plat.framework.signature.core.model.ApiSignatureRule;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.CollectionUtils;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.util.UrlPathHelper;
import java.util.List;
/**
* 全局 API 签名拦截器
*/
@RequiredArgsConstructor
public class ApiSignatureHandlerInterceptor implements HandlerInterceptor {
private final ApiSignatureVerifier verifier;
private final ApiSignatureProperties properties;
private final AntPathMatcher pathMatcher = new AntPathMatcher();
private final UrlPathHelper urlPathHelper = new UrlPathHelper();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (!properties.isEnabled()) {
return true;
}
if (!(handler instanceof HandlerMethod handlerMethod)) {
return true;
}
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
String lookupPath = urlPathHelper.getLookupPathForRequest(request);
if (shouldSkip(lookupPath)) {
return true;
}
ApiSignatureRule rule = createRule(handlerMethod);
verifier.verify(rule, request);
return true;
}
private boolean shouldSkip(String path) {
List<String> includePaths = properties.getIncludePaths();
if (!CollectionUtils.isEmpty(includePaths)) {
boolean matched = includePaths.stream().anyMatch(pattern -> pathMatcher.match(pattern, path));
if (!matched) {
return true;
}
}
List<String> excludePaths = properties.getExcludePaths();
if (!CollectionUtils.isEmpty(excludePaths)) {
for (String pattern : excludePaths) {
if (pathMatcher.match(pattern, path)) {
return true;
}
}
}
return false;
}
private ApiSignatureRule createRule(HandlerMethod handlerMethod) {
ApiSignature signature = handlerMethod.getMethodAnnotation(ApiSignature.class);
if (signature != null) {
return ApiSignatureRule.from(signature, properties);
}
signature = handlerMethod.getBeanType().getAnnotation(ApiSignature.class);
if (signature != null) {
return ApiSignatureRule.from(signature, properties);
}
return ApiSignatureRule.from(properties);
}
}

View File

@@ -1,4 +1,3 @@
com.zt.plat.framework.idempotent.config.ZtIdempotentConfiguration com.zt.plat.framework.idempotent.config.ZtIdempotentConfiguration
com.zt.plat.framework.lock4j.config.ZtLock4jConfiguration com.zt.plat.framework.lock4j.config.ZtLock4jConfiguration
com.zt.plat.framework.ratelimiter.config.ZtRateLimiterConfiguration com.zt.plat.framework.ratelimiter.config.ZtRateLimiterConfiguration
com.zt.plat.framework.signature.config.ZtApiSignatureAutoConfiguration

View File

@@ -3,13 +3,13 @@ package com.zt.plat.framework.signature.core;
import cn.hutool.core.map.MapUtil; import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.IdUtil;
import cn.hutool.crypto.digest.DigestUtil; import cn.hutool.crypto.digest.DigestUtil;
import com.zt.plat.framework.signature.core.annotation.ApiSignature; import com.zt.plat.framework.signature.core.ApiSignatureVerifier;
import com.zt.plat.framework.signature.core.aop.ApiSignatureAspect; import com.zt.plat.framework.signature.core.config.ApiSignatureProperties;
import com.zt.plat.framework.signature.core.model.ApiSignatureRule;
import com.zt.plat.framework.signature.core.redis.ApiSignatureRedisDAO; import com.zt.plat.framework.signature.core.redis.ApiSignatureRedisDAO;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
@@ -28,9 +28,6 @@ import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
public class ApiSignatureTest { public class ApiSignatureTest {
@InjectMocks
private ApiSignatureAspect apiSignatureAspect;
@Mock @Mock
private ApiSignatureRedisDAO signatureRedisDAO; private ApiSignatureRedisDAO signatureRedisDAO;
@@ -45,13 +42,16 @@ public class ApiSignatureTest {
String sign = DigestUtil.sha256Hex(signString); String sign = DigestUtil.sha256Hex(signString);
// 准备参数 // 准备参数
ApiSignature apiSignature = mock(ApiSignature.class); ApiSignatureProperties properties = new ApiSignatureProperties();
when(apiSignature.appId()).thenReturn("appId"); ApiSignatureRule apiSignature = ApiSignatureRule.builder()
when(apiSignature.timestamp()).thenReturn("timestamp"); .appId("appId")
when(apiSignature.nonce()).thenReturn("nonce"); .timestamp("timestamp")
when(apiSignature.sign()).thenReturn("sign"); .nonce("nonce")
when(apiSignature.timeout()).thenReturn(60); .sign("sign")
when(apiSignature.timeUnit()).thenReturn(TimeUnit.SECONDS); .timeout(60)
.timeUnit(TimeUnit.SECONDS)
.message(properties.getMessage())
.build();
HttpServletRequest request = mock(HttpServletRequest.class); HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getHeader(eq("appId"))).thenReturn(appId); when(request.getHeader(eq("appId"))).thenReturn(appId);
when(request.getHeader(eq("timestamp"))).thenReturn(String.valueOf(timestamp)); when(request.getHeader(eq("timestamp"))).thenReturn(String.valueOf(timestamp));
@@ -62,11 +62,13 @@ public class ApiSignatureTest {
when(request.getContentType()).thenReturn("application/json"); when(request.getContentType()).thenReturn("application/json");
when(request.getReader()).thenReturn(new BufferedReader(new StringReader("test"))); when(request.getReader()).thenReturn(new BufferedReader(new StringReader("test")));
// mock 方法 // mock 方法
when(signatureRedisDAO.getNonce(eq(appId), eq(nonce))).thenReturn(null);
when(signatureRedisDAO.getAppSecret(eq(appId))).thenReturn(appSecret); when(signatureRedisDAO.getAppSecret(eq(appId))).thenReturn(appSecret);
when(signatureRedisDAO.setNonce(eq(appId), eq(nonce), eq(120), eq(TimeUnit.SECONDS))).thenReturn(true); when(signatureRedisDAO.setNonce(eq(appId), eq(nonce), eq(120), eq(TimeUnit.SECONDS))).thenReturn(true);
// 调用 // 调用
boolean result = apiSignatureAspect.verifySignature(apiSignature, request); ApiSignatureVerifier verifier = new ApiSignatureVerifier(signatureRedisDAO);
boolean result = verifier.verify(apiSignature, request);
// 断言结果 // 断言结果
assertTrue(result); assertTrue(result);
} }

View File

@@ -44,6 +44,8 @@ public class TokenAuthenticationFilter implements GlobalFilter, Ordered {
private static final TypeReference<CommonResult<OAuth2AccessTokenCheckRespDTO>> CHECK_RESULT_TYPE_REFERENCE private static final TypeReference<CommonResult<OAuth2AccessTokenCheckRespDTO>> CHECK_RESULT_TYPE_REFERENCE
= new TypeReference<CommonResult<OAuth2AccessTokenCheckRespDTO>>() {}; = new TypeReference<CommonResult<OAuth2AccessTokenCheckRespDTO>>() {};
private static final String ADMIN_DATABUS_PORTAL_PREFIX = "/admin-api/databus/api/portal";
/** /**
* 空的 LoginUser 的结果 * 空的 LoginUser 的结果
* *
@@ -85,6 +87,13 @@ public class TokenAuthenticationFilter implements GlobalFilter, Ordered {
// 移除 login-user 的请求头,避免伪造模拟 // 移除 login-user 的请求头,避免伪造模拟
exchange = SecurityFrameworkUtils.removeLoginUser(exchange); exchange = SecurityFrameworkUtils.removeLoginUser(exchange);
// API Portal 通过网关访问时无需认证,直接放行
String rawPath = exchange.getRequest().getURI().getRawPath();
if (rawPath != null && (rawPath.equals(ADMIN_DATABUS_PORTAL_PREFIX)
|| rawPath.startsWith(ADMIN_DATABUS_PORTAL_PREFIX + "/"))) {
return chain.filter(exchange);
}
// 情况一,如果没有 Token 令牌,则直接继续 filter // 情况一,如果没有 Token 令牌,则直接继续 filter
String token = SecurityFrameworkUtils.obtainAuthorization(exchange); String token = SecurityFrameworkUtils.obtainAuthorization(exchange);
if (StrUtil.isEmpty(token)) { if (StrUtil.isEmpty(token)) {

View File

@@ -212,8 +212,8 @@ zt:
exclude-urls: # 如下 url仅仅是为了演示去掉配置也没关系 exclude-urls: # 如下 url仅仅是为了演示去掉配置也没关系
- ${management.endpoints.web.base-path}/** # 不处理 Actuator 的请求 - ${management.endpoints.web.base-path}/** # 不处理 Actuator 的请求
swagger: swagger:
title: 管理后台 title: ai 模块
description: 提供管理员管理的所有功能 description: 提供ai功能
version: ${zt.info.version} version: ${zt.info.version}
tenant: # 多租户相关配置项 tenant: # 多租户相关配置项
enable: true enable: true

View File

@@ -141,8 +141,8 @@ zt:
exclude-urls: # 如下 url仅仅是为了演示去掉配置也没关系 exclude-urls: # 如下 url仅仅是为了演示去掉配置也没关系
- ${management.endpoints.web.base-path}/** # 不处理 Actuator 的请求 - ${management.endpoints.web.base-path}/** # 不处理 Actuator 的请求
swagger: swagger:
title: 管理后台 title: 流程模块
description: 提供管理员管理的所有功能 description: 提供流程模块功能
version: ${zt.info.version} version: ${zt.info.version}
tenant: # 多租户相关配置项 tenant: # 多租户相关配置项
enable: true enable: true

View File

@@ -140,11 +140,6 @@
<groupId>org.springframework.integration</groupId> <groupId>org.springframework.integration</groupId>
<artifactId>spring-integration-scripting</artifactId> <artifactId>spring-integration-scripting</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
<!-- Reactive HTTP client for internal REST orchestration --> <!-- Reactive HTTP client for internal REST orchestration -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>

View File

@@ -0,0 +1,79 @@
package com.zt.plat.module.databus.controller.admin.gateway;
import com.zt.plat.framework.common.pojo.CommonResult;
import com.zt.plat.framework.common.pojo.PageResult;
import com.zt.plat.module.databus.controller.admin.gateway.convert.ApiClientCredentialConvert;
import com.zt.plat.module.databus.controller.admin.gateway.vo.credential.ApiClientCredentialPageReqVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.credential.ApiClientCredentialRespVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.credential.ApiClientCredentialSaveReqVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.credential.ApiClientCredentialSimpleRespVO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiClientCredentialDO;
import com.zt.plat.module.databus.service.gateway.ApiClientCredentialService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import static com.zt.plat.framework.common.pojo.CommonResult.success;
@Tag(name = "管理后台 - API 客户端凭证")
@RestController
@RequestMapping("/databus/gateway/credential")
@RequiredArgsConstructor
@Validated
public class ApiClientCredentialController {
private final ApiClientCredentialService credentialService;
@GetMapping("/page")
@Operation(summary = "分页查询客户端凭证")
public CommonResult<PageResult<ApiClientCredentialRespVO>> page(ApiClientCredentialPageReqVO reqVO) {
PageResult<ApiClientCredentialDO> page = credentialService.getPage(reqVO);
return success(ApiClientCredentialConvert.INSTANCE.convertPage(page));
}
@GetMapping("/get")
@Operation(summary = "查询凭证详情")
public CommonResult<ApiClientCredentialRespVO> get(@RequestParam("id") Long id) {
ApiClientCredentialDO credential = credentialService.get(id);
return success(ApiClientCredentialConvert.INSTANCE.convert(credential));
}
@PostMapping("/create")
@Operation(summary = "新增客户端凭证")
public CommonResult<Long> create(@Valid @RequestBody ApiClientCredentialSaveReqVO reqVO) {
return success(credentialService.create(reqVO));
}
@PutMapping("/update")
@Operation(summary = "更新客户端凭证")
public CommonResult<Boolean> update(@Valid @RequestBody ApiClientCredentialSaveReqVO reqVO) {
credentialService.update(reqVO);
return success(Boolean.TRUE);
}
@DeleteMapping("/delete")
@Operation(summary = "删除客户端凭证")
public CommonResult<Boolean> delete(@RequestParam("id") Long id) {
credentialService.delete(id);
return success(Boolean.TRUE);
}
@GetMapping("/list-simple")
@Operation(summary = "获取启用的凭证列表(精简)")
public CommonResult<List<ApiClientCredentialSimpleRespVO>> listSimple() {
List<ApiClientCredentialDO> list = credentialService.listEnabled();
return success(ApiClientCredentialConvert.INSTANCE.convertSimpleList(list));
}
}

View File

@@ -1,25 +1,20 @@
package com.zt.plat.module.databus.controller.admin.gateway; package com.zt.plat.module.databus.controller.admin.gateway;
import com.zt.plat.framework.common.exception.ServiceException;
import com.zt.plat.framework.common.pojo.CommonResult; import com.zt.plat.framework.common.pojo.CommonResult;
import com.zt.plat.module.databus.controller.admin.gateway.convert.ApiDefinitionConvert; import com.zt.plat.module.databus.controller.admin.gateway.convert.ApiDefinitionConvert;
import com.zt.plat.module.databus.controller.admin.gateway.vo.ApiGatewayInvokeReqVO; import com.zt.plat.module.databus.controller.admin.gateway.vo.ApiGatewayInvokeReqVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.definition.ApiDefinitionDetailRespVO; import com.zt.plat.module.databus.controller.admin.gateway.vo.definition.ApiDefinitionDetailRespVO;
import com.zt.plat.module.databus.framework.integration.gateway.core.ApiFlowDispatcher; import com.zt.plat.module.databus.framework.integration.gateway.core.ApiGatewayExecutionService;
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiGatewayResponse; import com.zt.plat.module.databus.framework.integration.gateway.model.ApiGatewayResponse;
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiInvocationContext;
import com.zt.plat.module.databus.service.gateway.ApiDefinitionService; import com.zt.plat.module.databus.service.gateway.ApiDefinitionService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.util.StringUtils; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static com.zt.plat.framework.common.pojo.CommonResult.success; import static com.zt.plat.framework.common.pojo.CommonResult.success;
@@ -30,44 +25,13 @@ import static com.zt.plat.framework.common.pojo.CommonResult.success;
@RequiredArgsConstructor @RequiredArgsConstructor
public class ApiGatewayController { public class ApiGatewayController {
private final ApiFlowDispatcher apiFlowDispatcher; private final ApiGatewayExecutionService executionService;
private final ApiDefinitionService apiDefinitionService; private final ApiDefinitionService apiDefinitionService;
@PostMapping(value = "/invoke", consumes = MediaType.APPLICATION_JSON_VALUE) @PostMapping(value = "/invoke", consumes = MediaType.APPLICATION_JSON_VALUE)
@Operation(summary = "测试调用 API 编排") @Operation(summary = "测试调用 API 编排")
public CommonResult<ApiGatewayResponse> invoke(@RequestBody ApiGatewayInvokeReqVO reqVO) { public ResponseEntity<ApiGatewayResponse> invoke(@RequestBody ApiGatewayInvokeReqVO reqVO) {
ApiInvocationContext context = ApiInvocationContext.create(); return executionService.invokeForDebug(reqVO);
context.setApiCode(reqVO.getApiCode());
context.setApiVersion(reqVO.getVersion());
context.setRequestBody(reqVO.getPayload());
if (reqVO.getHeaders() != null) {
context.getRequestHeaders().putAll(reqVO.getHeaders());
}
if (reqVO.getQueryParams() != null) {
context.getRequestQueryParams().putAll(reqVO.getQueryParams());
}
ApiInvocationContext responseContext = context;
try {
responseContext = apiFlowDispatcher.dispatch(reqVO.getApiCode(), reqVO.getVersion(), context);
} catch (ServiceException ex) {
handleServiceException(responseContext, ex);
} catch (Exception ex) {
handleUnexpectedException(responseContext, ex);
}
int status = responseContext.getResponseStatus() != null ? responseContext.getResponseStatus() : HttpStatus.OK.value();
String message = StringUtils.hasText(responseContext.getResponseMessage())
? responseContext.getResponseMessage()
: HttpStatus.valueOf(status).getReasonPhrase();
ApiGatewayResponse envelope = ApiGatewayResponse.builder()
.code(status >= 200 && status < 400 ? "SUCCESS" : "ERROR")
.message(message)
.data(responseContext.getResponseBody())
.traceId(responseContext.getRequestId())
.build();
return success(envelope);
} }
@GetMapping("/definitions") @GetMapping("/definitions")
@@ -79,29 +43,4 @@ public class ApiGatewayController {
return success(definitions); return success(definitions);
} }
private void handleServiceException(ApiInvocationContext context, ServiceException ex) {
String message = StringUtils.hasText(ex.getMessage()) ? ex.getMessage() : "API 调用失败";
context.setResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
context.setResponseMessage(message);
Map<String, Object> body = new HashMap<>();
if (ex.getCode() != null) {
body.put("errorCode", ex.getCode());
}
body.put("errorMessage", message);
context.setResponseBody(body);
}
private void handleUnexpectedException(ApiInvocationContext context, Exception ex) {
String message = StringUtils.hasText(ex.getMessage())
? ex.getMessage()
: ex.getCause() != null && StringUtils.hasText(ex.getCause().getMessage())
? ex.getCause().getMessage()
: "API invocation encountered an unexpected error";
context.setResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
context.setResponseMessage(message);
Map<String, Object> body = new HashMap<>();
body.put("errorMessage", message);
body.put("exception", ex.getClass().getSimpleName());
context.setResponseBody(body);
}
} }

View File

@@ -1,84 +0,0 @@
package com.zt.plat.module.databus.controller.admin.gateway;
import com.zt.plat.framework.common.exception.util.ServiceExceptionUtil;
import com.zt.plat.framework.common.pojo.CommonResult;
import com.zt.plat.framework.common.pojo.PageResult;
import com.zt.plat.module.databus.controller.admin.gateway.convert.ApiPolicyAuthConvert;
import com.zt.plat.module.databus.controller.admin.gateway.vo.policy.ApiPolicyPageReqVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.policy.ApiPolicyRespVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.policy.ApiPolicySaveReqVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.policy.ApiPolicySimpleRespVO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiPolicyAuthDO;
import com.zt.plat.module.databus.service.gateway.ApiPolicyAuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import static com.zt.plat.framework.common.pojo.CommonResult.success;
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_POLICY_NOT_FOUND;
@Tag(name = "管理后台 - 网关认证策略")
@RestController
@RequestMapping("/databus/gateway/policy/auth")
@RequiredArgsConstructor
@Validated
public class ApiPolicyAuthController {
private final ApiPolicyAuthService authService;
@GetMapping("/page")
@Operation(summary = "分页查询认证策略")
public CommonResult<PageResult<ApiPolicyRespVO>> getAuthPolicyPage(@Valid ApiPolicyPageReqVO reqVO) {
PageResult<ApiPolicyAuthDO> pageResult = authService.getPage(reqVO);
return success(ApiPolicyAuthConvert.INSTANCE.convertPage(pageResult));
}
@GetMapping("/{id}")
@Operation(summary = "查询认证策略详情")
public CommonResult<ApiPolicyRespVO> getAuthPolicy(@PathVariable("id") Long id) {
ApiPolicyAuthDO policy = authService.get(id)
.orElseThrow(() -> ServiceExceptionUtil.exception(API_POLICY_NOT_FOUND));
return success(ApiPolicyAuthConvert.INSTANCE.convert(policy));
}
@GetMapping("/simple-list")
@Operation(summary = "获取认证策略精简列表")
public CommonResult<List<ApiPolicySimpleRespVO>> getAuthPolicySimpleList() {
List<ApiPolicyAuthDO> list = authService.getSimpleList();
return success(ApiPolicyAuthConvert.INSTANCE.convertSimpleList(list));
}
@PostMapping
@Operation(summary = "创建认证策略")
public CommonResult<Long> createAuthPolicy(@Valid @RequestBody ApiPolicySaveReqVO reqVO) {
Long id = authService.create(reqVO);
return success(id);
}
@PutMapping
@Operation(summary = "更新认证策略")
public CommonResult<Boolean> updateAuthPolicy(@Valid @RequestBody ApiPolicySaveReqVO reqVO) {
authService.update(reqVO);
return success(Boolean.TRUE);
}
@DeleteMapping("/{id}")
@Operation(summary = "删除认证策略")
public CommonResult<Boolean> deleteAuthPolicy(@PathVariable("id") Long id) {
authService.delete(id);
return success(Boolean.TRUE);
}
}

View File

@@ -0,0 +1,41 @@
package com.zt.plat.module.databus.controller.admin.gateway.convert;
import com.zt.plat.framework.common.pojo.PageResult;
import com.zt.plat.module.databus.controller.admin.gateway.vo.credential.ApiClientCredentialRespVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.credential.ApiClientCredentialSimpleRespVO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiClientCredentialDO;
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;
import java.util.List;
import java.util.stream.Collectors;
@Mapper
public interface ApiClientCredentialConvert {
ApiClientCredentialConvert INSTANCE = Mappers.getMapper(ApiClientCredentialConvert.class);
ApiClientCredentialRespVO convert(ApiClientCredentialDO bean);
List<ApiClientCredentialRespVO> convertList(List<ApiClientCredentialDO> list);
default PageResult<ApiClientCredentialRespVO> convertPage(PageResult<ApiClientCredentialDO> page) {
if (page == null) {
return PageResult.empty();
}
PageResult<ApiClientCredentialRespVO> result = new PageResult<>();
result.setList(convertList(page.getList()));
result.setTotal(page.getTotal());
return result;
}
default List<ApiClientCredentialSimpleRespVO> convertSimpleList(List<ApiClientCredentialDO> list) {
return list == null ? List.of() : list.stream().map(item -> {
ApiClientCredentialSimpleRespVO vo = new ApiClientCredentialSimpleRespVO();
vo.setId(item.getId());
vo.setAppId(item.getAppId());
vo.setAppName(item.getAppName());
return vo;
}).collect(Collectors.toList());
}
}

View File

@@ -1,25 +0,0 @@
package com.zt.plat.module.databus.controller.admin.gateway.convert;
import com.zt.plat.framework.common.pojo.PageResult;
import com.zt.plat.module.databus.controller.admin.gateway.vo.policy.ApiPolicyRespVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.policy.ApiPolicySimpleRespVO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiPolicyAuthDO;
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;
import java.util.List;
@Mapper
public interface ApiPolicyAuthConvert {
ApiPolicyAuthConvert INSTANCE = Mappers.getMapper(ApiPolicyAuthConvert.class);
ApiPolicyRespVO convert(ApiPolicyAuthDO bean);
List<ApiPolicyRespVO> convertList(List<ApiPolicyAuthDO> list);
PageResult<ApiPolicyRespVO> convertPage(PageResult<ApiPolicyAuthDO> page);
List<ApiPolicySimpleRespVO> convertSimpleList(List<ApiPolicyAuthDO> list);
}

View File

@@ -0,0 +1,19 @@
package com.zt.plat.module.databus.controller.admin.gateway.vo.credential;
import com.zt.plat.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Schema(description = "管理后台 - API 客户端凭证分页查询 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class ApiClientCredentialPageReqVO extends PageParam {
@Schema(description = "关键字,匹配 appId 或名称", example = "databus-app")
private String keyword;
@Schema(description = "是否启用")
private Boolean enabled;
}

View File

@@ -0,0 +1,42 @@
package com.zt.plat.module.databus.controller.admin.gateway.vo.credential;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - API 客户端凭证 Response VO")
@Data
public class ApiClientCredentialRespVO {
@Schema(description = "记录编号", example = "1024")
private Long id;
@Schema(description = "应用标识", example = "databus-app")
private String appId;
@Schema(description = "应用名称", example = "数据总线默认应用")
private String appName;
@Schema(description = "加密密钥", example = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=")
private String encryptionKey;
@Schema(description = "加密算法", example = "AES")
private String encryptionType;
@Schema(description = "签名算法", example = "MD5")
private String signatureType;
@Schema(description = "是否启用", example = "true")
private Boolean enabled;
@Schema(description = "备注", example = "默认应用凭证")
private String remark;
@Schema(description = "创建时间")
private LocalDateTime createTime;
@Schema(description = "最后更新时间")
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,41 @@
package com.zt.plat.module.databus.controller.admin.gateway.vo.credential;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Schema(description = "管理后台 - API 客户端凭证保存 Request VO")
@Data
public class ApiClientCredentialSaveReqVO {
@Schema(description = "记录编号,仅更新时必填", example = "1024")
private Long id;
@Schema(description = "应用标识", example = "databus-app")
@NotBlank(message = "应用标识不能为空")
private String appId;
@Schema(description = "应用名称", example = "数据总线默认应用")
private String appName;
@Schema(description = "加密密钥", example = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=")
@NotBlank(message = "加密密钥不能为空")
private String encryptionKey;
@Schema(description = "加密算法", example = "AES")
@NotBlank(message = "加密算法不能为空")
private String encryptionType;
@Schema(description = "签名算法", example = "MD5")
@NotBlank(message = "签名算法不能为空")
private String signatureType;
@Schema(description = "是否启用", example = "true")
@NotNull(message = "启用状态不能为空")
private Boolean enabled;
@Schema(description = "备注", example = "默认应用凭证")
private String remark;
}

View File

@@ -0,0 +1,19 @@
package com.zt.plat.module.databus.controller.admin.gateway.vo.credential;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - API 客户端凭证精简 Response VO")
@Data
public class ApiClientCredentialSimpleRespVO {
@Schema(description = "记录编号", example = "1024")
private Long id;
@Schema(description = "应用标识", example = "databus-app")
private String appId;
@Schema(description = "应用名称", example = "数据总线默认应用")
private String appName;
}

View File

@@ -26,30 +26,18 @@ public class ApiDefinitionDetailRespVO {
@Schema(description = "HTTP 方法", example = "POST") @Schema(description = "HTTP 方法", example = "POST")
private String httpMethod; private String httpMethod;
@Schema(description = "URI 模板", example = "/external/order/create")
private String uriPattern;
@Schema(description = "状态", example = "1") @Schema(description = "状态", example = "1")
private Integer status; private Integer status;
@Schema(description = "是否灰度")
private Boolean greyReleased;
@Schema(description = "描述") @Schema(description = "描述")
private String description; private String description;
@Schema(description = "认证策略编号")
private Long authPolicyId;
@Schema(description = "限流策略编号") @Schema(description = "限流策略编号")
private Long rateLimitId; private Long rateLimitId;
@Schema(description = "响应模板(JSON)") @Schema(description = "响应模板(JSON)")
private String responseTemplate; private String responseTemplate;
@Schema(description = "缓存策略(JSON)")
private String cacheStrategy;
@Schema(description = "创建时间") @Schema(description = "创建时间")
private LocalDateTime createTime; private LocalDateTime createTime;

View File

@@ -10,7 +10,7 @@ import lombok.EqualsAndHashCode;
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class ApiDefinitionPageReqVO extends PageParam { public class ApiDefinitionPageReqVO extends PageParam {
@Schema(description = "关键字,匹配编码/描述/URI", example = "order") @Schema(description = "关键字,匹配编码描述", example = "order")
private String keyword; private String keyword;
@Schema(description = "API 状态", example = "1") @Schema(description = "API 状态", example = "1")
@@ -19,7 +19,4 @@ public class ApiDefinitionPageReqVO extends PageParam {
@Schema(description = "HTTP 方法", example = "POST") @Schema(description = "HTTP 方法", example = "POST")
private String httpMethod; private String httpMethod;
@Schema(description = "是否灰度", example = "true")
private Boolean greyReleased;
} }

View File

@@ -29,10 +29,6 @@ public class ApiDefinitionSaveReqVO {
@NotBlank(message = "HTTP 方法不能为空") @NotBlank(message = "HTTP 方法不能为空")
private String httpMethod; private String httpMethod;
@Schema(description = "URI 模板", example = "/external/order/create")
@NotBlank(message = "URI 模板不能为空")
private String uriPattern;
@Schema(description = "API 状态", example = "1") @Schema(description = "API 状态", example = "1")
@NotNull(message = "API 状态不能为空") @NotNull(message = "API 状态不能为空")
private Integer status; private Integer status;
@@ -40,21 +36,12 @@ public class ApiDefinitionSaveReqVO {
@Schema(description = "描述") @Schema(description = "描述")
private String description; private String description;
@Schema(description = "认证策略编号")
private Long authPolicyId;
@Schema(description = "限流策略编号") @Schema(description = "限流策略编号")
private Long rateLimitId; private Long rateLimitId;
@Schema(description = "响应模板(JSON)") @Schema(description = "响应模板(JSON)")
private String responseTemplate; private String responseTemplate;
@Schema(description = "缓存策略(JSON)")
private String cacheStrategy;
@Schema(description = "是否开启灰度发布")
private Boolean greyReleased;
@Schema(description = "API 级别变换列表") @Schema(description = "API 级别变换列表")
@Valid @Valid
private List<ApiDefinitionTransformSaveReqVO> apiLevelTransforms = new ArrayList<>(); private List<ApiDefinitionTransformSaveReqVO> apiLevelTransforms = new ArrayList<>();

View File

@@ -37,9 +37,6 @@ public class ApiDefinitionStepRespVO {
@Schema(description = "超时时间(毫秒)") @Schema(description = "超时时间(毫秒)")
private Long timeout; private Long timeout;
@Schema(description = "重试策略(JSON)")
private String retryStrategy;
@Schema(description = "降级策略(JSON)") @Schema(description = "降级策略(JSON)")
private String fallbackStrategy; private String fallbackStrategy;

View File

@@ -39,9 +39,6 @@ public class ApiDefinitionStepSaveReqVO {
@Schema(description = "超时时间(毫秒)", example = "5000") @Schema(description = "超时时间(毫秒)", example = "5000")
private Long timeout; private Long timeout;
@Schema(description = "重试策略(JSON)")
private String retryStrategy;
@Schema(description = "降级策略(JSON)") @Schema(description = "降级策略(JSON)")
private String fallbackStrategy; private String fallbackStrategy;

View File

@@ -21,15 +21,9 @@ public class ApiDefinitionSummaryRespVO {
@Schema(description = "HTTP 方法", example = "POST") @Schema(description = "HTTP 方法", example = "POST")
private String httpMethod; private String httpMethod;
@Schema(description = "URI 模板", example = "/external/order/create")
private String uriPattern;
@Schema(description = "状态", example = "1") @Schema(description = "状态", example = "1")
private Integer status; private Integer status;
@Schema(description = "是否灰度", example = "true")
private Boolean greyReleased;
@Schema(description = "描述") @Schema(description = "描述")
private String description; private String description;

View File

@@ -0,0 +1,37 @@
package com.zt.plat.module.databus.dal.dataobject.gateway;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.zt.plat.framework.mybatis.core.dataobject.BaseDO;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* API 客户端凭证,用于维护 appId 与加解密配置的关联关系。
*/
@Data
@TableName("databus_api_client_credential")
@KeySequence("databus_api_client_credential_seq")
@EqualsAndHashCode(callSuper = true)
public class ApiClientCredentialDO extends BaseDO {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String appId;
private String appName;
private String encryptionKey;
private String encryptionType;
private String signatureType;
private Boolean enabled;
private String remark;
}

View File

@@ -24,8 +24,6 @@ public class ApiDefinitionDO extends TenantBaseDO {
private String apiCode; private String apiCode;
private String uriPattern;
private String httpMethod; private String httpMethod;
private String version; private String version;
@@ -37,16 +35,10 @@ public class ApiDefinitionDO extends TenantBaseDO {
private String description; private String description;
private Long authPolicyId;
private Long rateLimitId; private Long rateLimitId;
private String responseTemplate; private String responseTemplate;
private String cacheStrategy;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
private Boolean greyReleased;
} }

View File

@@ -1,31 +0,0 @@
package com.zt.plat.module.databus.dal.dataobject.gateway;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.zt.plat.framework.tenant.core.db.TenantBaseDO;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* Authentication policy definition.
*/
@TableName("databus_policy_auth")
@KeySequence("databus_policy_auth_seq")
@Data
@EqualsAndHashCode(callSuper = true)
public class ApiPolicyAuthDO extends TenantBaseDO {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String name;
private String type;
private String config;
private String description;
}

View File

@@ -38,8 +38,6 @@ public class ApiStepDO extends TenantBaseDO {
private Long timeout; private Long timeout;
private String retryStrategy;
private String fallbackStrategy; private String fallbackStrategy;
private String conditionExpr; private String conditionExpr;

View File

@@ -0,0 +1,47 @@
package com.zt.plat.module.databus.dal.mysql.gateway;
import cn.hutool.core.util.StrUtil;
import com.zt.plat.framework.common.pojo.PageResult;
import com.zt.plat.framework.mybatis.core.mapper.BaseMapperX;
import com.zt.plat.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.zt.plat.module.databus.controller.admin.gateway.vo.credential.ApiClientCredentialPageReqVO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiClientCredentialDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
import java.util.Optional;
@Mapper
public interface ApiClientCredentialMapper extends BaseMapperX<ApiClientCredentialDO> {
default Optional<ApiClientCredentialDO> selectByAppId(String appId) {
if (StrUtil.isBlank(appId)) {
return Optional.empty();
}
LambdaQueryWrapperX<ApiClientCredentialDO> query = new LambdaQueryWrapperX<>();
query.eq(ApiClientCredentialDO::getAppId, appId)
.eq(ApiClientCredentialDO::getDeleted, false);
return Optional.ofNullable(selectOne(query));
}
default PageResult<ApiClientCredentialDO> selectPage(ApiClientCredentialPageReqVO reqVO) {
LambdaQueryWrapperX<ApiClientCredentialDO> query = new LambdaQueryWrapperX<>();
if (StrUtil.isNotBlank(reqVO.getKeyword())) {
String keyword = reqVO.getKeyword();
query.and(wrapper -> wrapper.like(ApiClientCredentialDO::getAppId, keyword)
.or().like(ApiClientCredentialDO::getAppName, keyword));
}
query.eqIfPresent(ApiClientCredentialDO::getEnabled, reqVO.getEnabled())
.eq(ApiClientCredentialDO::getDeleted, false)
.orderByDesc(ApiClientCredentialDO::getUpdateTime)
.orderByDesc(ApiClientCredentialDO::getId);
return selectPage(reqVO, query);
}
default List<ApiClientCredentialDO> selectEnabledList() {
return selectList(new LambdaQueryWrapperX<ApiClientCredentialDO>()
.eq(ApiClientCredentialDO::getEnabled, true)
.eq(ApiClientCredentialDO::getDeleted, false)
.orderByAsc(ApiClientCredentialDO::getAppId));
}
}

View File

@@ -31,26 +31,15 @@ public interface ApiDefinitionMapper extends BaseMapperX<ApiDefinitionDO> {
if (StrUtil.isNotBlank(reqVO.getKeyword())) { if (StrUtil.isNotBlank(reqVO.getKeyword())) {
String keyword = reqVO.getKeyword(); String keyword = reqVO.getKeyword();
query.and(wrapper -> wrapper.like(ApiDefinitionDO::getApiCode, keyword) query.and(wrapper -> wrapper.like(ApiDefinitionDO::getApiCode, keyword)
.or().like(ApiDefinitionDO::getDescription, keyword) .or().like(ApiDefinitionDO::getDescription, keyword));
.or().like(ApiDefinitionDO::getUriPattern, keyword));
} }
query.eqIfPresent(ApiDefinitionDO::getStatus, reqVO.getStatus()) query.eqIfPresent(ApiDefinitionDO::getStatus, reqVO.getStatus())
.eqIfPresent(ApiDefinitionDO::getHttpMethod, reqVO.getHttpMethod()) .eqIfPresent(ApiDefinitionDO::getHttpMethod, reqVO.getHttpMethod())
// .eqIfPresent(ApiDefinitionDO::getGreyReleased, reqVO.getGreyReleased()) .orderByDesc(ApiDefinitionDO::getUpdateTime)
.orderByDesc(ApiDefinitionDO::getUpdateTime)
.orderByDesc(ApiDefinitionDO::getId); .orderByDesc(ApiDefinitionDO::getId);
return selectPage(reqVO, query); return selectPage(reqVO, query);
} }
default Long selectCountByAuthPolicyId(Long policyId) {
if (policyId == null) {
return 0L;
}
return selectCount(new LambdaQueryWrapperX<ApiDefinitionDO>()
.eq(ApiDefinitionDO::getAuthPolicyId, policyId)
.eq(ApiDefinitionDO::getDeleted, false));
}
default Long selectCountByRateLimitPolicyId(Long policyId) { default Long selectCountByRateLimitPolicyId(Long policyId) {
if (policyId == null) { if (policyId == null) {
return 0L; return 0L;

View File

@@ -1,36 +0,0 @@
package com.zt.plat.module.databus.dal.mysql.gateway;
import cn.hutool.core.util.StrUtil;
import com.zt.plat.framework.common.pojo.PageResult;
import com.zt.plat.framework.mybatis.core.mapper.BaseMapperX;
import com.zt.plat.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.zt.plat.module.databus.controller.admin.gateway.vo.policy.ApiPolicyPageReqVO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiPolicyAuthDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface ApiPolicyAuthMapper extends BaseMapperX<ApiPolicyAuthDO> {
default PageResult<ApiPolicyAuthDO> selectPage(ApiPolicyPageReqVO reqVO) {
LambdaQueryWrapperX<ApiPolicyAuthDO> query = new LambdaQueryWrapperX<>();
if (StrUtil.isNotBlank(reqVO.getKeyword())) {
String keyword = reqVO.getKeyword();
query.and(wrapper -> wrapper.like(ApiPolicyAuthDO::getName, keyword)
.or().like(ApiPolicyAuthDO::getDescription, keyword));
}
query.eqIfPresent(ApiPolicyAuthDO::getType, reqVO.getType())
.eq(ApiPolicyAuthDO::getDeleted, false)
.orderByDesc(ApiPolicyAuthDO::getUpdateTime)
.orderByDesc(ApiPolicyAuthDO::getId);
return selectPage(reqVO, query);
}
default List<ApiPolicyAuthDO> selectSimpleList() {
return selectList(new LambdaQueryWrapperX<ApiPolicyAuthDO>()
.eq(ApiPolicyAuthDO::getDeleted, false)
.orderByDesc(ApiPolicyAuthDO::getUpdateTime)
.orderByDesc(ApiPolicyAuthDO::getId));
}
}

View File

@@ -10,9 +10,10 @@ import lombok.Getter;
@Getter @Getter
public enum ApiStepTypeEnum { public enum ApiStepTypeEnum {
START,
HTTP, HTTP,
RPC, RPC,
SCRIPT, SCRIPT,
FLOW; END;
} }

View File

@@ -1,10 +1,14 @@
package com.zt.plat.module.databus.framework.integration.config; package com.zt.plat.module.databus.framework.integration.config;
import com.zt.plat.framework.common.util.security.CryptoSignatureUtils;
import lombok.Data; import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.StringUtils;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Set;
/** /**
* Configuration properties for the unified API portal. * Configuration properties for the unified API portal.
@@ -13,24 +17,72 @@ import java.util.List;
@ConfigurationProperties(prefix = "databus.api-portal") @ConfigurationProperties(prefix = "databus.api-portal")
public class ApiGatewayProperties { public class ApiGatewayProperties {
private String basePath = "/api/portal"; public static final String DEFAULT_BASE_PATH = "/admin-api/databus/api/portal";
public static final String LEGACY_BASE_PATH = "/databus/api/portal";
private String basePath = DEFAULT_BASE_PATH;
public void setBasePath(String basePath) {
if (!StringUtils.hasText(basePath)) {
this.basePath = DEFAULT_BASE_PATH;
return;
}
String normalized = basePath.startsWith("/") ? basePath : "/" + basePath;
if (LEGACY_BASE_PATH.equals(normalized)) {
this.basePath = DEFAULT_BASE_PATH;
return;
}
this.basePath = normalized;
}
public List<String> getAllBasePaths() {
Set<String> candidates = new LinkedHashSet<>();
candidates.add(basePath);
candidates.add(DEFAULT_BASE_PATH);
candidates.add(LEGACY_BASE_PATH);
return new ArrayList<>(candidates);
}
private List<String> allowedIps = new ArrayList<>(); private List<String> allowedIps = new ArrayList<>();
private List<String> deniedIps = new ArrayList<>(); private List<String> deniedIps = new ArrayList<>();
private boolean enableSignature = false; private Security security = new Security();
private String signatureHeader = "X-Signature"; private boolean enableTenantHeader = false;
private String signatureSecret; private String tenantHeader = "ZT-Tenant-Id";
private boolean enableTenantHeader = true;
private String tenantHeader = "X-Tenant-Id";
private boolean enableAudit = true; private boolean enableAudit = true;
private boolean enableRateLimit = true; private boolean enableRateLimit = true;
@Data
public static class Security {
private boolean enabled = true;
private String appIdHeader = "ZT-App-Id";
private String timestampHeader = "ZT-Timestamp";
private String nonceHeader = "ZT-Nonce";
private String signatureHeader = "ZT-Signature";
private String signatureType = CryptoSignatureUtils.SIGNATURE_TYPE_MD5;
private String encryptionType = CryptoSignatureUtils.ENCRYPT_TYPE_AES;
private long allowedClockSkewSeconds = 300;
private long nonceTtlSeconds = 600;
private String nonceRedisKeyPrefix = "databus:gateway:nonce:";
private boolean requireBodyEncryption = true;
private boolean encryptResponse = true;
}
} }

View File

@@ -1,47 +1,30 @@
package com.zt.plat.module.databus.framework.integration.config; package com.zt.plat.module.databus.framework.integration.config;
import com.zt.plat.framework.common.exception.ServiceException; import com.zt.plat.module.databus.framework.integration.gateway.core.ApiGatewayExecutionService;
import com.zt.plat.module.databus.framework.integration.gateway.core.ApiFlowDispatcher;
import com.zt.plat.module.databus.framework.integration.gateway.core.ApiGatewayRequestMapper;
import com.zt.plat.module.databus.framework.integration.gateway.core.ErrorHandlingStrategy; import com.zt.plat.module.databus.framework.integration.gateway.core.ErrorHandlingStrategy;
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiGatewayResponse;
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiInvocationContext;
import com.zt.plat.module.databus.framework.integration.gateway.security.GatewaySecurityFilter; import com.zt.plat.module.databus.framework.integration.gateway.security.GatewaySecurityFilter;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered; import org.springframework.core.Ordered;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.integration.core.MessagingTemplate; import org.springframework.integration.core.MessagingTemplate;
import org.springframework.integration.dsl.IntegrationFlow; import org.springframework.integration.dsl.IntegrationFlow;
import org.springframework.integration.http.dsl.Http; import org.springframework.integration.http.dsl.Http;
import org.springframework.integration.support.MessageBuilder;
import org.springframework.messaging.Message;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.util.StringUtils;
import java.util.HashMap;
import java.util.Map;
/** /**
* Configures the unified API portal inbound gateway and supporting beans. * Configures the unified API portal inbound gateway and supporting beans.
*/ */
@Slf4j
@Configuration @Configuration
@EnableConfigurationProperties(ApiGatewayProperties.class) @EnableConfigurationProperties(ApiGatewayProperties.class)
@RequiredArgsConstructor @RequiredArgsConstructor
public class GatewayIntegrationConfiguration { public class GatewayIntegrationConfiguration {
private final ApiGatewayProperties properties; private final ApiGatewayProperties properties;
private final ApiGatewayRequestMapper requestMapper;
private final ObjectProvider<ApiFlowDispatcher> apiFlowDispatcherProvider;
private final ErrorHandlingStrategy errorHandlingStrategy; private final ErrorHandlingStrategy errorHandlingStrategy;
@Bean(name = "apiPortalTaskExecutor") @Bean(name = "apiPortalTaskExecutor")
@@ -68,7 +51,7 @@ public class GatewayIntegrationConfiguration {
} }
@Bean @Bean
public IntegrationFlow apiGatewayInboundFlow() { public IntegrationFlow apiGatewayInboundFlow(ApiGatewayExecutionService executionService) {
String pattern = properties.getBasePath() + "/{apiCode}/{version}"; String pattern = properties.getBasePath() + "/{apiCode}/{version}";
return IntegrationFlow.from(Http.inboundGateway(pattern) return IntegrationFlow.from(Http.inboundGateway(pattern)
.requestMapping(spec -> spec .requestMapping(spec -> spec
@@ -77,71 +60,9 @@ public class GatewayIntegrationConfiguration {
.requestPayloadType(String.class) .requestPayloadType(String.class)
.mappedRequestHeaders("*") .mappedRequestHeaders("*")
.mappedResponseHeaders("*")) .mappedResponseHeaders("*"))
.handle(this, "mapRequest", endpoint -> endpoint.advice(errorHandlingStrategy.errorForwardingAdvice())) .handle(executionService, "mapRequest", endpoint -> endpoint.advice(errorHandlingStrategy.errorForwardingAdvice()))
.handle(this, "dispatch", endpoint -> endpoint.advice(errorHandlingStrategy.errorForwardingAdvice())) .handle(executionService, "dispatch", endpoint -> endpoint.advice(errorHandlingStrategy.errorForwardingAdvice()))
.handle(this, "buildResponse", endpoint -> endpoint.advice(errorHandlingStrategy.errorForwardingAdvice())) .handle(executionService, "buildResponseEntity", endpoint -> endpoint.advice(errorHandlingStrategy.errorForwardingAdvice()))
.get(); .get();
} }
public Message<ApiInvocationContext> mapRequest(Message<?> message) {
ApiInvocationContext context = requestMapper.map(message.getPayload(), message.getHeaders());
return MessageBuilder.withPayload(context)
.copyHeaders(message.getHeaders())
.setHeaderIfAbsent("apiCode", context.getApiCode())
.setHeaderIfAbsent("version", context.getApiVersion())
.build();
}
public ApiInvocationContext dispatch(Message<ApiInvocationContext> message) {
ApiInvocationContext context = message.getPayload();
try {
return apiFlowDispatcherProvider.getObject()
.dispatch(context.getApiCode(), context.getApiVersion(), context);
} catch (ServiceException ex) {
handleServiceException(context, ex);
log.warn("[API-PORTAL] ServiceException while dispatching apiCode={} version={}: {}", context.getApiCode(), context.getApiVersion(), ex.getMessage());
return context;
} catch (Exception ex) {
handleUnexpectedException(context, ex);
log.error("[API-PORTAL] Unexpected exception while dispatching apiCode={} version={}", context.getApiCode(), context.getApiVersion(), ex);
return context;
}
}
public ResponseEntity<ApiGatewayResponse> buildResponse(ApiInvocationContext context) {
int status = context.getResponseStatus() != null ? context.getResponseStatus() : HttpStatus.OK.value();
ApiGatewayResponse envelope = ApiGatewayResponse.builder()
.code(status >= 200 && status < 400 ? "SUCCESS" : "ERROR")
.message(StringUtils.hasText(context.getResponseMessage()) ? context.getResponseMessage() : HttpStatus.valueOf(status).getReasonPhrase())
.data(context.getResponseBody())
.traceId(context.getRequestId())
.build();
return ResponseEntity.status(status).body(envelope);
}
private void handleServiceException(ApiInvocationContext context, ServiceException ex) {
String message = StringUtils.hasText(ex.getMessage()) ? ex.getMessage() : "API invocation failed";
context.setResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
context.setResponseMessage(message);
if (context.getResponseBody() == null) {
Map<String, Object> body = new HashMap<>();
if (ex.getCode() != null) {
body.put("errorCode", ex.getCode());
}
body.put("errorMessage", message);
context.setResponseBody(body);
}
}
private void handleUnexpectedException(ApiInvocationContext context, Exception ex) {
String message = StringUtils.hasText(ex.getMessage()) ? ex.getMessage() : "API invocation encountered an unexpected error";
context.setResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
context.setResponseMessage(message);
if (context.getResponseBody() == null) {
Map<String, Object> body = new HashMap<>();
body.put("errorMessage", message);
body.put("exception", ex.getClass().getSimpleName());
context.setResponseBody(body);
}
}
} }

View File

@@ -9,8 +9,10 @@ import com.zt.plat.module.databus.framework.integration.gateway.domain.ApiStepDe
import com.zt.plat.module.databus.framework.integration.gateway.expression.ExpressionExecutor; import com.zt.plat.module.databus.framework.integration.gateway.expression.ExpressionExecutor;
import com.zt.plat.module.databus.framework.integration.gateway.expression.ExpressionSpec; import com.zt.plat.module.databus.framework.integration.gateway.expression.ExpressionSpec;
import com.zt.plat.module.databus.framework.integration.gateway.expression.ExpressionSpecParser; import com.zt.plat.module.databus.framework.integration.gateway.expression.ExpressionSpecParser;
import com.zt.plat.module.databus.framework.integration.gateway.expression.GatewayExpressionHelper;
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiInvocationContext; import com.zt.plat.module.databus.framework.integration.gateway.model.ApiInvocationContext;
import com.zt.plat.module.databus.framework.integration.gateway.step.StepHandlerFactory; import com.zt.plat.module.databus.framework.integration.gateway.step.StepHandlerFactory;
import lombok.Getter;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.aopalliance.aop.Advice; import org.aopalliance.aop.Advice;
@@ -27,17 +29,13 @@ import org.springframework.util.StringUtils;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_PARALLEL_FAILED; import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.*;
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_PARALLEL_INTERRUPTED;
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_TRANSFORM_EVALUATION_FAILED;
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_TRANSFORM_RESPONSE_STATUS_INVALID;
/** /**
* Assembles dynamic integration flows per API definition. * 按 API 定义装配动态集成流程。
*/ */
@Slf4j @Slf4j
@Component @Component
@@ -58,7 +56,7 @@ public class ApiFlowAssembler {
IntegrationFlowBuilder builder = IntegrationFlow.from(MessageChannels.direct(inputChannelName) IntegrationFlowBuilder builder = IntegrationFlow.from(MessageChannels.direct(inputChannelName)
.datatype(ApiInvocationContext.class) .datatype(ApiInvocationContext.class)
.interceptor(monitoringInterceptor)) .interceptor(monitoringInterceptor))
.log(message -> String.format("[API-PORTAL] entering flow %s", flowId)) .log(message -> String.format("[API-PORTAL] 进入流程 %s", flowId))
.handle(ApiInvocationContext.class, .handle(ApiInvocationContext.class,
applyTransforms(aggregate, TransformPhaseEnum.REQUEST_PRE), applyTransforms(aggregate, TransformPhaseEnum.REQUEST_PRE),
endpoint -> endpoint.advice(errorHandlingStrategy.errorForwardingAdvice())); endpoint -> endpoint.advice(errorHandlingStrategy.errorForwardingAdvice()));
@@ -88,6 +86,10 @@ public class ApiFlowAssembler {
} }
private GenericHandler<ApiInvocationContext> applyTransforms(ApiDefinitionAggregate aggregate, TransformPhaseEnum phase) { private GenericHandler<ApiInvocationContext> applyTransforms(ApiDefinitionAggregate aggregate, TransformPhaseEnum phase) {
boolean mutateRequest = phase == TransformPhaseEnum.REQUEST_PRE || phase == TransformPhaseEnum.REQUEST_POST;
boolean mutateResponse = phase == TransformPhaseEnum.RESPONSE_PRE
|| phase == TransformPhaseEnum.RESPONSE_POST
|| phase == TransformPhaseEnum.ERROR;
return (payload, headers) -> { return (payload, headers) -> {
var transformDefinition = aggregate.getApiLevelTransforms().get(phase.name()); var transformDefinition = aggregate.getApiLevelTransforms().get(phase.name());
if (transformDefinition != null && StringUtils.hasText(transformDefinition.getExpression())) { if (transformDefinition != null && StringUtils.hasText(transformDefinition.getExpression())) {
@@ -95,7 +97,7 @@ public class ApiFlowAssembler {
ExpressionSpec spec = ExpressionSpecParser.parse(rawExpression, ExpressionTypeEnum.JSON); ExpressionSpec spec = ExpressionSpecParser.parse(rawExpression, ExpressionTypeEnum.JSON);
try { try {
Object result = expressionExecutor.evaluate(spec, payload, payload.getRequestBody(), headers); Object result = expressionExecutor.evaluate(spec, payload, payload.getRequestBody(), headers);
applyTransformResult(payload, result); GatewayExpressionHelper.applyContextMutations(payload, result, mutateRequest, mutateResponse);
} catch (Exception ex) { } catch (Exception ex) {
if (ex instanceof ServiceException serviceException) { if (ex instanceof ServiceException serviceException) {
throw serviceException; throw serviceException;
@@ -107,48 +109,6 @@ public class ApiFlowAssembler {
}; };
} }
private void applyTransformResult(ApiInvocationContext context, Object result) {
if (!(result instanceof Map<?, ?> map)) {
return;
}
Object headerUpdates = map.get("requestHeaders");
if (headerUpdates instanceof Map<?, ?> headerMap) {
headerMap.forEach((key, value) -> context.getRequestHeaders().put(String.valueOf(key), value));
}
Object variableUpdates = map.get("variables");
if (variableUpdates instanceof Map<?, ?> variables) {
variables.forEach((key, value) -> context.getVariables().put(String.valueOf(key), value));
}
Object attributeUpdates = map.get("attributes");
if (attributeUpdates instanceof Map<?, ?> attributes) {
attributes.forEach((key, value) -> context.getAttributes().put(String.valueOf(key), value));
}
if (map.containsKey("responseBody")) {
context.setResponseBody(map.get("responseBody"));
}
if (map.containsKey("responseStatus")) {
context.setResponseStatus(asInteger(map.get("responseStatus")));
}
if (map.containsKey("responseMessage")) {
Object message = map.get("responseMessage");
context.setResponseMessage(message == null ? null : String.valueOf(message));
}
}
private Integer asInteger(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number number) {
return number.intValue();
}
try {
return Integer.parseInt(String.valueOf(value));
} catch (NumberFormatException ex) {
throw ServiceExceptionUtil.exception(API_TRANSFORM_RESPONSE_STATUS_INVALID, value);
}
}
private IntegrationFlowBuilder applySequential(IntegrationFlowBuilder builder, ApiDefinitionAggregate aggregate, ApiStepDefinition stepDefinition) { private IntegrationFlowBuilder applySequential(IntegrationFlowBuilder builder, ApiDefinitionAggregate aggregate, ApiStepDefinition stepDefinition) {
GenericHandler<ApiInvocationContext> handler = stepHandlerFactory.build(aggregate, stepDefinition); GenericHandler<ApiInvocationContext> handler = stepHandlerFactory.build(aggregate, stepDefinition);
return builder.handle(ApiInvocationContext.class, handler, endpoint -> { return builder.handle(ApiInvocationContext.class, handler, endpoint -> {
@@ -238,6 +198,7 @@ public class ApiFlowAssembler {
private interface FlowSegment { private interface FlowSegment {
} }
@Getter
private static final class SequentialSegment implements FlowSegment { private static final class SequentialSegment implements FlowSegment {
private final ApiStepDefinition step; private final ApiStepDefinition step;
@@ -245,11 +206,9 @@ public class ApiFlowAssembler {
this.step = step; this.step = step;
} }
public ApiStepDefinition getStep() {
return step;
}
} }
@Getter
private static final class ParallelSegment implements FlowSegment { private static final class ParallelSegment implements FlowSegment {
private final String group; private final String group;
private final List<ApiStepDefinition> steps; private final List<ApiStepDefinition> steps;
@@ -259,12 +218,5 @@ public class ApiFlowAssembler {
this.steps = steps; this.steps = steps;
} }
public String getGroup() {
return group;
}
public List<ApiStepDefinition> getSteps() {
return steps;
}
} }
} }

View File

@@ -13,7 +13,8 @@ import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErro
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_FLOW_NO_REPLY; import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_FLOW_NO_REPLY;
/** /**
* Dispatches API invocation contexts to the appropriate integration flow. * api 分发.
* @author chenbowen
*/ */
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor

View File

@@ -6,6 +6,7 @@ import org.springframework.integration.dsl.IntegrationFlow;
/** /**
* Metadata returned by the assembler for flow registration. * Metadata returned by the assembler for flow registration.
* @author chenbowen
*/ */
@Value @Value
@Builder @Builder

View File

@@ -0,0 +1,97 @@
package com.zt.plat.module.databus.framework.integration.gateway.core;
import com.zt.plat.framework.common.exception.ServiceException;
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiInvocationContext;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.HashMap;
import java.util.Map;
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_AUTH_UNAUTHORIZED;
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_RATE_LIMIT_EXCEEDED;
/**
* Shared error processor that maps exceptions into {@link ApiInvocationContext} responses.
*/
@Component
public class ApiGatewayErrorProcessor {
/**
* Applies the given {@link Throwable} to the context, unwrapping {@link ServiceException}
* instances when present and falling back to a generic error payload otherwise.
*/
public void apply(ApiInvocationContext context, Throwable throwable) {
ServiceException serviceException = resolveServiceException(throwable);
if (serviceException != null) {
applyServiceException(context, serviceException);
} else {
applyUnexpectedException(context, throwable);
}
}
public void applyServiceException(ApiInvocationContext context, ServiceException ex) {
String message = StringUtils.hasText(ex.getMessage()) ? ex.getMessage() : "API invocation failed";
context.setResponseStatus(resolveHttpStatus(ex, context));
context.setResponseMessage(message);
if (context.getResponseBody() == null) {
Map<String, Object> body = new HashMap<>();
if (ex.getCode() != null) {
body.put("errorCode", ex.getCode());
}
body.put("errorMessage", message);
context.setResponseBody(body);
}
}
public void applyUnexpectedException(ApiInvocationContext context, Throwable throwable) {
String message = determineUnexpectedMessage(throwable);
context.setResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
context.setResponseMessage(message);
if (context.getResponseBody() == null) {
Map<String, Object> body = new HashMap<>();
body.put("errorMessage", message);
body.put("exception", throwable.getClass().getSimpleName());
context.setResponseBody(body);
}
}
public ServiceException resolveServiceException(Throwable throwable) {
Throwable current = throwable;
while (current != null) {
if (current instanceof ServiceException serviceException) {
return serviceException;
}
current = current.getCause();
}
return null;
}
private int resolveHttpStatus(ServiceException ex, ApiInvocationContext context) {
if (context.getResponseStatus() != null) {
return context.getResponseStatus();
}
Integer code = ex.getCode();
if (code != null) {
if (API_AUTH_UNAUTHORIZED.getCode().equals(code)) {
return HttpStatus.UNAUTHORIZED.value();
}
if (API_RATE_LIMIT_EXCEEDED.getCode().equals(code)) {
return HttpStatus.TOO_MANY_REQUESTS.value();
}
}
return HttpStatus.INTERNAL_SERVER_ERROR.value();
}
private String determineUnexpectedMessage(Throwable throwable) {
Throwable current = throwable;
while (current != null) {
if (StringUtils.hasText(current.getMessage())) {
return current.getMessage();
}
current = current.getCause();
}
return "API invocation encountered an unexpected error";
}
}

View File

@@ -0,0 +1,286 @@
package com.zt.plat.module.databus.framework.integration.gateway.core;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zt.plat.framework.common.exception.ServiceException;
import com.zt.plat.module.databus.controller.admin.gateway.vo.ApiGatewayInvokeReqVO;
import com.zt.plat.module.databus.framework.integration.config.ApiGatewayProperties;
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiGatewayResponse;
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiInvocationContext;
import com.zt.plat.module.databus.framework.integration.gateway.security.GatewayJwtResolver;
import com.zt.plat.module.databus.framework.integration.gateway.security.GatewaySecurityFilter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.*;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerMapping;
import org.springframework.web.util.UriComponentsBuilder;
import java.lang.reflect.Array;
import java.net.URI;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Orchestrates API portal request mapping, dispatch and response building so that
* management-side debug invocations and external HTTP requests share identical
* behaviour (other than security concerns handled by {@link GatewaySecurityFilter}).
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ApiGatewayExecutionService {
private static final String HEADER_REQUEST_HEADERS = org.springframework.integration.http.HttpHeaders.PREFIX + "requestHeaders";
private static final String HEADER_REQUEST_URI = org.springframework.integration.http.HttpHeaders.PREFIX + "requestUri";
private static final String HEADER_REQUEST_PARAMS = org.springframework.integration.http.HttpHeaders.PREFIX + "requestParams";
private static final String HEADER_QUERY_STRING = org.springframework.integration.http.HttpHeaders.PREFIX + "queryString";
private final ApiGatewayRequestMapper requestMapper;
private final ApiFlowDispatcher apiFlowDispatcher;
private final ApiGatewayErrorProcessor errorProcessor;
private final ApiGatewayProperties properties;
private final ObjectMapper objectMapper;
/**
* Maps a raw HTTP message (as provided by Spring Integration) into a context message.
*/
public Message<ApiInvocationContext> mapRequest(Message<?> message) {
ApiInvocationContext context = requestMapper.map(message.getPayload(), message.getHeaders());
return MessageBuilder.withPayload(context)
.copyHeaders(message.getHeaders())
.setHeaderIfAbsent("apiCode", context.getApiCode())
.setHeaderIfAbsent("version", context.getApiVersion())
.build();
}
/**
* Dispatches the API invocation and applies gateway error processing rules on failure scenarios.
*/
public ApiInvocationContext dispatch(Message<ApiInvocationContext> message) {
ApiInvocationContext context = message.getPayload();
try {
return apiFlowDispatcher.dispatch(context.getApiCode(), context.getApiVersion(), context);
} catch (ServiceException ex) {
errorProcessor.applyServiceException(context, ex);
log.warn("[API-PORTAL] 分发 apiCode={} version={} 时出现 ServiceException: {}", context.getApiCode(), context.getApiVersion(), ex.getMessage());
return context;
} catch (Exception ex) {
ServiceException nestedServiceException = errorProcessor.resolveServiceException(ex);
if (nestedServiceException != null) {
errorProcessor.applyServiceException(context, nestedServiceException);
log.warn("[API-PORTAL] 分发 apiCode={} version={} 时出现 ServiceException(包装异常): {}", context.getApiCode(), context.getApiVersion(), nestedServiceException.getMessage());
if (log.isDebugEnabled()) {
log.debug("[API-PORTAL] 包装异常堆栈", ex);
}
} else {
errorProcessor.applyUnexpectedException(context, ex);
log.error("[API-PORTAL] 分发 apiCode={} version={} 时出现未预期异常", context.getApiCode(), context.getApiVersion(), ex);
}
return context;
}
}
/**
* Builds a HTTP response entity for the external gateway flow.
*/
public ResponseEntity<ApiGatewayResponse> buildResponseEntity(ApiInvocationContext context) {
int status = resolveStatus(context);
ApiGatewayResponse envelope = buildResponseEnvelope(context, status);
return ResponseEntity.status(status).body(envelope);
}
/**
* Executes a debug invocation by reusing the same mapping/dispatch pipeline as the public gateway.
*/
public ResponseEntity<ApiGatewayResponse> invokeForDebug(ApiGatewayInvokeReqVO reqVO) {
Message<?> rawMessage = buildDebugMessage(reqVO);
Message<ApiInvocationContext> mappedMessage = mapRequest(rawMessage);
ApiInvocationContext context = mappedMessage.getPayload();
// Ensure query parameters & headers from debug payload are reflected after mapping.
mergeDebugMetadata(context, reqVO);
ApiInvocationContext responseContext = dispatch(mappedMessage);
return buildResponseEntity(responseContext);
}
private Message<?> buildDebugMessage(ApiGatewayInvokeReqVO reqVO) {
Object payload = preparePayload(reqVO.getPayload());
MessageBuilder<Object> builder = MessageBuilder.withPayload(payload);
Map<String, Object> uriVariables = Map.of(
"apiCode", reqVO.getApiCode(),
"version", reqVO.getVersion()
);
builder.setHeader(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, uriVariables);
builder.setHeader(org.springframework.integration.http.HttpHeaders.REQUEST_METHOD, HttpMethod.POST.name());
String basePath = normalizeBasePath(properties.getBasePath());
String rawQuery = buildQueryString(reqVO.getQueryParams());
String requestUri = basePath + "/" + reqVO.getApiCode() + "/" + reqVO.getVersion();
if (StringUtils.hasText(rawQuery)) {
requestUri = requestUri + "?" + rawQuery;
}
builder.setHeader(HEADER_REQUEST_URI, requestUri);
builder.setHeader(org.springframework.integration.http.HttpHeaders.REQUEST_URL, requestUri);
Map<String, Object> requestHeaders = new LinkedHashMap<>();
if (reqVO.getHeaders() != null) {
reqVO.getHeaders().forEach(requestHeaders::put);
}
normalizeJwtHeaders(requestHeaders, reqVO.getQueryParams());
requestHeaders.putIfAbsent(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
builder.setHeader(HEADER_REQUEST_HEADERS, requestHeaders);
requestHeaders.forEach((key, value) -> {
if (value != null) {
builder.setHeaderIfAbsent(key, value);
}
});
if (reqVO.getQueryParams() != null && !reqVO.getQueryParams().isEmpty()) {
Map<String, Object> paramsCopy = new LinkedHashMap<>(reqVO.getQueryParams());
builder.setHeader(HEADER_REQUEST_PARAMS, paramsCopy);
if (StringUtils.hasText(rawQuery)) {
builder.setHeader(HEADER_QUERY_STRING, rawQuery);
}
}
if (properties.isEnableTenantHeader()) {
String tenantHeader = properties.getTenantHeader();
Object tenantValue = requestHeaders.get(tenantHeader);
if (tenantValue != null) {
builder.setHeaderIfAbsent(tenantHeader, tenantValue);
}
}
return builder.build();
}
private Object preparePayload(Object payload) {
if (payload == null) {
return "";
}
if (payload instanceof String) {
return payload;
}
try {
return objectMapper.writeValueAsString(payload);
} catch (JsonProcessingException ex) {
log.debug("[API-PORTAL] 将调试请求体序列化为 JSON 失败,使用 toString()", ex);
return payload.toString();
}
}
private void mergeDebugMetadata(ApiInvocationContext context, ApiGatewayInvokeReqVO reqVO) {
if (reqVO.getHeaders() != null && !reqVO.getHeaders().isEmpty()) {
reqVO.getHeaders().forEach((key, value) -> context.getRequestHeaders().putIfAbsent(key, value));
}
if (reqVO.getQueryParams() != null && !reqVO.getQueryParams().isEmpty()) {
reqVO.getQueryParams().forEach((key, value) -> context.getRequestQueryParams().putIfAbsent(key, value));
}
if (!StringUtils.hasText(context.getHttpMethod())) {
context.setHttpMethod(HttpMethod.POST.name());
}
if (!StringUtils.hasText(context.getRequestPath())) {
String basePath = normalizeBasePath(properties.getBasePath());
String path = basePath + "/" + reqVO.getApiCode() + "/" + reqVO.getVersion();
context.setRequestPath(path);
}
}
private int resolveStatus(ApiInvocationContext context) {
return context.getResponseStatus() != null ? context.getResponseStatus() : HttpStatus.OK.value();
}
private ApiGatewayResponse buildResponseEnvelope(ApiInvocationContext context, int status) {
String message = StringUtils.hasText(context.getResponseMessage())
? context.getResponseMessage()
: HttpStatus.valueOf(status).getReasonPhrase();
return ApiGatewayResponse.builder()
.code(status)
.message(message)
.response(context.getResponseBody())
.traceId(context.getRequestId())
.build();
}
private String normalizeBasePath(String basePath) {
if (!StringUtils.hasText(basePath)) {
return ApiGatewayProperties.DEFAULT_BASE_PATH;
}
String normalized = basePath.startsWith("/") ? basePath : "/" + basePath;
while (normalized.endsWith("/") && normalized.length() > 1) {
normalized = normalized.substring(0, normalized.length() - 1);
}
return normalized;
}
private String buildQueryString(Map<String, Object> queryParams) {
if (queryParams == null || queryParams.isEmpty()) {
return null;
}
UriComponentsBuilder builder = UriComponentsBuilder.newInstance();
queryParams.forEach((key, value) -> appendQueryParam(builder, key, value));
URI uri = builder.build(true).toUri();
return uri.getQuery();
}
private void appendQueryParam(UriComponentsBuilder builder, String key, Object value) {
if (!StringUtils.hasText(key)) {
return;
}
if (value == null) {
builder.queryParam(key, (Object) null);
return;
}
if (value instanceof Iterable<?> iterable) {
for (Object element : iterable) {
appendQueryParam(builder, key, element);
}
return;
}
if (value.getClass().isArray()) {
int length = Array.getLength(value);
for (int i = 0; i < length; i++) {
appendQueryParam(builder, key, Array.get(value, i));
}
return;
}
builder.queryParam(key, value);
}
private void normalizeJwtHeaders(Map<String, Object> headers, Map<String, Object> queryParams) {
String token = GatewayJwtResolver.resolveJwtToken(headers, queryParams, objectMapper);
if (!StringUtils.hasText(token)) {
return;
}
ensureHeaderValue(headers, GatewayJwtResolver.HEADER_ZT_AUTH_TOKEN, token);
ensureHeaderValue(headers, HttpHeaders.AUTHORIZATION, "Bearer " + token);
}
private void ensureHeaderValue(Map<String, Object> headers, String headerName, String value) {
if (!StringUtils.hasText(headerName) || value == null) {
return;
}
String existingKey = findHeaderKey(headers, headerName);
if (existingKey != null) {
headers.put(existingKey, value);
} else {
headers.put(headerName, value);
}
}
private String findHeaderKey(Map<String, Object> headers, String headerName) {
if (headers == null || !StringUtils.hasText(headerName)) {
return null;
}
for (String key : headers.keySet()) {
if (headerName.equalsIgnoreCase(key)) {
return key;
}
}
return null;
}
}

View File

@@ -8,10 +8,15 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerMapping; import org.springframework.web.servlet.HandlerMapping;
import org.springframework.web.util.UriComponentsBuilder;
import java.io.IOException; import java.io.IOException;
import java.lang.reflect.Array;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
@@ -34,8 +39,14 @@ public class ApiGatewayRequestMapper {
ApiInvocationContext context = ApiInvocationContext.create(); ApiInvocationContext context = ApiInvocationContext.create();
Map<String, Object> uriVariables = (Map<String, Object>) headers.get(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE); Map<String, Object> uriVariables = (Map<String, Object>) headers.get(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
if (uriVariables != null) { if (uriVariables != null) {
context.setApiCode(String.valueOf(uriVariables.get("apiCode"))); Object apiCode = uriVariables.get("apiCode");
context.setApiVersion(String.valueOf(uriVariables.get("version"))); Object version = uriVariables.get("version");
if (apiCode != null) {
context.setApiCode(String.valueOf(apiCode));
}
if (version != null) {
context.setApiVersion(String.valueOf(version));
}
} }
Object methodHeader = headers.get(org.springframework.integration.http.HttpHeaders.REQUEST_METHOD); Object methodHeader = headers.get(org.springframework.integration.http.HttpHeaders.REQUEST_METHOD);
if (methodHeader != null) { if (methodHeader != null) {
@@ -45,13 +56,38 @@ public class ApiGatewayRequestMapper {
if (requestPath == null) { if (requestPath == null) {
requestPath = headers.get(org.springframework.integration.http.HttpHeaders.REQUEST_URL); requestPath = headers.get(org.springframework.integration.http.HttpHeaders.REQUEST_URL);
} }
if (requestPath != null) { String originalRequestUri = requestPath != null ? String.valueOf(requestPath) : null;
context.setRequestPath(String.valueOf(requestPath)); if (originalRequestUri != null) {
context.setRequestPath(originalRequestUri);
} }
if (!StringUtils.hasText(context.getApiCode())) {
Object apiCodeHeader = headers.get("apiCode");
if (apiCodeHeader != null) {
context.setApiCode(String.valueOf(apiCodeHeader));
}
}
if (!StringUtils.hasText(context.getApiVersion())) {
Object versionHeader = headers.get("version");
if (versionHeader != null) {
context.setApiVersion(String.valueOf(versionHeader));
}
}
if (!StringUtils.hasText(context.getApiCode()) || !StringUtils.hasText(context.getApiVersion())) {
inferFromRequestPath(context);
}
Map<String, Object> requestHeaders = (Map<String, Object>) headers.get(HEADER_REQUEST_HEADERS); Map<String, Object> requestHeaders = (Map<String, Object>) headers.get(HEADER_REQUEST_HEADERS);
if (requestHeaders != null) { GatewayHeaderUtils.mergeNormalizedHeaders(requestHeaders, context.getRequestHeaders());
requestHeaders.forEach((key, value) -> context.getRequestHeaders().put(key, String.valueOf(value))); headers.forEach((key, value) -> {
} if (isInternalHeader(key)) {
return;
}
String normalized = GatewayHeaderUtils.normalizeHeaderValue(value);
if (normalized != null) {
context.getRequestHeaders().putIfAbsent(key, normalized);
}
});
populateQueryParams(headers, context, originalRequestUri);
if (properties.isEnableTenantHeader()) { if (properties.isEnableTenantHeader()) {
Object tenantHeaderValue = context.getRequestHeaders().get(properties.getTenantHeader()); Object tenantHeaderValue = context.getRequestHeaders().get(properties.getTenantHeader());
if (tenantHeaderValue != null) { if (tenantHeaderValue != null) {
@@ -63,7 +99,7 @@ public class ApiGatewayRequestMapper {
try { try {
context.setRequestBody(objectMapper.readValue(body, Object.class)); context.setRequestBody(objectMapper.readValue(body, Object.class));
} catch (IOException ex) { } catch (IOException ex) {
log.warn("Failed to parse request body as JSON", ex); log.warn("解析请求体为 JSON 失败", ex);
context.setRequestBody(body); context.setRequestBody(body);
} }
} else { } else {
@@ -75,8 +111,208 @@ public class ApiGatewayRequestMapper {
return context; return context;
} }
private boolean isInternalHeader(String headerName) {
if (!StringUtils.hasText(headerName)) {
return true;
}
if (headerName.startsWith(org.springframework.integration.http.HttpHeaders.PREFIX)) {
return true;
}
if (org.springframework.messaging.MessageHeaders.ID.equals(headerName)
|| org.springframework.messaging.MessageHeaders.TIMESTAMP.equals(headerName)) {
return true;
}
return "correlationId".equals(headerName)
|| "sequenceNumber".equals(headerName)
|| "sequenceSize".equals(headerName)
|| "errorChannel".equals(headerName)
|| "replyChannel".equals(headerName)
|| "replyChannelName".equals(headerName)
|| "errorChannelName".equals(headerName);
}
private boolean isJsonContent(ApiInvocationContext context) { private boolean isJsonContent(ApiInvocationContext context) {
String contentType = String.valueOf(context.getRequestHeaders().getOrDefault(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)).toLowerCase(Locale.ROOT); String contentType = String.valueOf(context.getRequestHeaders().getOrDefault(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)).toLowerCase(Locale.ROOT);
return contentType.contains(MediaType.APPLICATION_JSON_VALUE); return contentType.contains(MediaType.APPLICATION_JSON_VALUE);
} }
private void populateQueryParams(Map<String, Object> headers, ApiInvocationContext context, String originalRequestUri) {
Map<?, ?> headerParams = findRequestParamsHeader(headers);
if (headerParams != null) {
mergeParameterMap(headerParams, context.getRequestQueryParams(), true);
}
String queryString = findQueryStringHeader(headers);
if (!StringUtils.hasText(queryString) && StringUtils.hasText(originalRequestUri)) {
int idx = originalRequestUri.indexOf('?');
if (idx >= 0 && idx + 1 < originalRequestUri.length()) {
queryString = originalRequestUri.substring(idx + 1);
}
}
if (StringUtils.hasText(queryString)) {
mergeQueryString(queryString, context.getRequestQueryParams());
}
}
private Map<?, ?> findRequestParamsHeader(Map<String, Object> headers) {
if (headers == null) {
return null;
}
for (Map.Entry<String, Object> entry : headers.entrySet()) {
String key = entry.getKey();
if (!StringUtils.hasText(key)) {
continue;
}
if (entry.getValue() instanceof Map<?, ?> map && key.toLowerCase(Locale.ROOT).endsWith("requestparams")) {
return map;
}
}
return null;
}
private String findQueryStringHeader(Map<String, Object> headers) {
if (headers == null) {
return null;
}
for (Map.Entry<String, Object> entry : headers.entrySet()) {
String key = entry.getKey();
if (!StringUtils.hasText(key)) {
continue;
}
if (key.toLowerCase(Locale.ROOT).endsWith("querystring") && entry.getValue() != null) {
return entry.getValue().toString();
}
}
return null;
}
private void mergeQueryString(String queryString, Map<String, Object> target) {
try {
MultiValueMap<String, String> params = UriComponentsBuilder.newInstance()
.query(queryString)
.build()
.getQueryParams();
params.forEach((key, values) -> {
if (!StringUtils.hasText(key) || values == null || values.isEmpty()) {
return;
}
if (target.containsKey(key)) {
return;
}
if (values.size() == 1) {
target.put(key, values.get(0));
} else {
target.put(key, List.copyOf(values));
}
});
} catch (IllegalArgumentException ex) {
log.debug("解析查询串 '{}' 失败", queryString, ex);
}
}
private void mergeParameterMap(Map<?, ?> source, Map<String, Object> target, boolean override) {
source.forEach((rawKey, rawValue) -> {
if (!(rawKey instanceof String key) || !StringUtils.hasText(key)) {
return;
}
List<String> values = normalizeParamValues(rawValue);
if (values.isEmpty()) {
return;
}
if (!override && target.containsKey(key)) {
return;
}
if (values.size() == 1) {
target.put(key, values.get(0));
} else {
target.put(key, values);
}
});
}
private List<String> normalizeParamValues(Object value) {
if (value == null) {
return List.of();
}
if (value instanceof CharSequence) {
String candidate = value.toString();
return candidate.isEmpty() ? List.of("") : List.of(candidate);
}
if (value instanceof Iterable<?>) {
List<String> result = new ArrayList<>();
for (Object element : (Iterable<?>) value) {
result.addAll(normalizeParamValues(element));
}
return result;
}
if (value.getClass().isArray()) {
int length = Array.getLength(value);
List<String> result = new ArrayList<>(length);
for (int i = 0; i < length; i++) {
result.addAll(normalizeParamValues(Array.get(value, i)));
}
return result;
}
return List.of(value.toString());
}
private void inferFromRequestPath(ApiInvocationContext context) {
String requestPath = normalizeRequestPath(context.getRequestPath());
if (!StringUtils.hasText(requestPath)) {
return;
}
if (!requestPath.equals(context.getRequestPath())) {
context.setRequestPath(requestPath);
}
for (String basePath : properties.getAllBasePaths()) {
if (!requestPath.startsWith(basePath)) {
continue;
}
String remainder = requestPath.substring(basePath.length());
if (remainder.startsWith("/")) {
remainder = remainder.substring(1);
}
if (!StringUtils.hasText(remainder)) {
continue;
}
String[] segments = remainder.split("/");
if (segments.length < 2) {
continue;
}
if (!StringUtils.hasText(context.getApiCode())) {
context.setApiCode(segments[0]);
}
if (!StringUtils.hasText(context.getApiVersion())) {
context.setApiVersion(segments[1]);
}
if (StringUtils.hasText(context.getApiCode()) && StringUtils.hasText(context.getApiVersion())) {
return;
}
}
}
private String normalizeRequestPath(String requestPath) {
if (!StringUtils.hasText(requestPath)) {
return requestPath;
}
String candidate = requestPath;
int queryIndex = candidate.indexOf('?');
if (queryIndex >= 0) {
candidate = candidate.substring(0, queryIndex);
}
if (candidate.contains("://")) {
try {
java.net.URI uri = java.net.URI.create(candidate);
if (uri.getPath() != null) {
candidate = uri.getPath();
}
} catch (IllegalArgumentException ex) {
log.debug("将请求路径 '{}' 解析为 URI 失败", candidate, ex);
}
}
if (!candidate.startsWith("/")) {
candidate = "/" + candidate;
}
return candidate;
}
} }

View File

@@ -14,17 +14,19 @@ import org.springframework.messaging.support.ErrorMessage;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
/** /**
* Centralized error channel and handler for the API portal. * 为 API 门户集中管理错误通道与处理器。
*/ */
@Slf4j @Slf4j
@Component @Component
public class ErrorHandlingStrategy { public class ErrorHandlingStrategy {
private final ApiGatewayErrorProcessor errorProcessor;
@Getter @Getter
private final MessageChannel errorChannel; private final MessageChannel errorChannel;
private final Advice errorForwardingAdvice; private final Advice errorForwardingAdvice;
public ErrorHandlingStrategy() { public ErrorHandlingStrategy(ApiGatewayErrorProcessor errorProcessor) {
this.errorProcessor = errorProcessor;
DirectChannel channel = MessageChannels.direct("apiPortalErrorChannel").getObject(); DirectChannel channel = MessageChannels.direct("apiPortalErrorChannel").getObject();
this.errorChannel = channel; this.errorChannel = channel;
channel.subscribe(this::handleErrorMessage); channel.subscribe(this::handleErrorMessage);
@@ -45,10 +47,9 @@ public class ErrorHandlingStrategy {
Throwable throwable = errorMessage.getPayload(); Throwable throwable = errorMessage.getPayload();
Message<?> failedMessage = errorMessage.getOriginalMessage(); Message<?> failedMessage = errorMessage.getOriginalMessage();
if (failedMessage != null && failedMessage.getPayload() instanceof ApiInvocationContext context) { if (failedMessage != null && failedMessage.getPayload() instanceof ApiInvocationContext context) {
context.setResponseStatus(500); errorProcessor.apply(context, throwable);
context.setResponseMessage(throwable.getMessage());
} }
log.error("[API-PORTAL] Integration flow error", throwable); log.error("[API-PORTAL] 集成流程发生错误", throwable);
} }
private class ErrorForwardingAdvice extends AbstractHandleMessageAdvice { private class ErrorForwardingAdvice extends AbstractHandleMessageAdvice {
@@ -61,10 +62,10 @@ public class ErrorHandlingStrategy {
ErrorMessage errorMessage = new ErrorMessage(ex, message); ErrorMessage errorMessage = new ErrorMessage(ex, message);
try { try {
if (!errorChannel.send(errorMessage)) { if (!errorChannel.send(errorMessage)) {
log.warn("[API-PORTAL] Failed to forward error message to channel {}", errorChannel); log.warn("[API-PORTAL] 无法将错误消息转发到通道 {}", errorChannel);
} }
} catch (Exception sendEx) { } catch (Exception sendEx) {
log.error("[API-PORTAL] Error while submitting message to error channel", sendEx); log.error("[API-PORTAL] 向错误通道投递消息时出错", sendEx);
} }
throw ex; throw ex;
} }

View File

@@ -0,0 +1,81 @@
package com.zt.plat.module.databus.framework.integration.gateway.core;
import org.springframework.util.StringUtils;
import java.lang.reflect.Array;
import java.util.Map;
/**
* Utility helpers for working with request headers inside the gateway module.
*/
public final class GatewayHeaderUtils {
private GatewayHeaderUtils() {
}
public static void mergeNormalizedHeaders(Map<String, Object> source, Map<String, Object> target) {
if (source == null || target == null) {
return;
}
source.forEach((key, value) -> {
String normalized = normalizeHeaderValue(value);
if (normalized != null) {
target.put(key, normalized);
}
});
}
public static String findFirstHeaderValue(Map<String, Object> headers, String headerName) {
if (headers == null || !StringUtils.hasText(headerName)) {
return null;
}
for (Map.Entry<String, Object> entry : headers.entrySet()) {
if (headerName.equalsIgnoreCase(entry.getKey())) {
return normalizeHeaderValue(entry.getValue());
}
}
return null;
}
public static String stripBearerPrefix(String token) {
if (!StringUtils.hasText(token)) {
return token;
}
String candidate = token.trim();
if (candidate.regionMatches(true, 0, "Bearer ", 0, 7)) {
candidate = candidate.substring(7).trim();
}
return candidate;
}
static String normalizeHeaderValue(Object value) {
if (value == null) {
return null;
}
if (value instanceof CharSequence sequence) {
String candidate = sequence.toString().trim();
return StringUtils.hasText(candidate) ? candidate : null;
}
if (value instanceof Iterable<?> iterable) {
for (Object element : iterable) {
String candidate = normalizeHeaderValue(element);
if (candidate != null) {
return candidate;
}
}
return null;
}
if (value.getClass().isArray()) {
int length = Array.getLength(value);
for (int i = 0; i < length; i++) {
String candidate = normalizeHeaderValue(Array.get(value, i));
if (candidate != null) {
return candidate;
}
}
return null;
}
String candidate = value.toString().trim();
return StringUtils.hasText(candidate) ? candidate : null;
}
}

View File

@@ -15,7 +15,7 @@ import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
/** /**
* Manages dynamic registration of API integration flows. * 管理 API 集成流程的动态注册。
*/ */
@Slf4j @Slf4j
@Component @Component
@@ -71,7 +71,7 @@ public class IntegrationFlowManager {
.id(apiFlowRegistration.getFlowId()) .id(apiFlowRegistration.getFlowId())
.register(); .register();
activeRegistrations.put(key, registration); activeRegistrations.put(key, registration);
log.info("[API-PORTAL] registered flow {} for apiCode={} version={}", apiFlowRegistration.getFlowId(), aggregate.getDefinition().getApiCode(), aggregate.getDefinition().getVersion()); log.info("[API-PORTAL] 已注册流程 {} 对应 apiCode={} version={}", apiFlowRegistration.getFlowId(), aggregate.getDefinition().getApiCode(), aggregate.getDefinition().getVersion());
} }
private void deregister(String apiCode, String version) { private void deregister(String apiCode, String version) {
@@ -83,9 +83,9 @@ public class IntegrationFlowManager {
if (existing != null) { if (existing != null) {
try { try {
integrationFlowContext.remove(existing.getId()); integrationFlowContext.remove(existing.getId());
log.info("[API-PORTAL] deregistered flow {} for key {}", existing.getId(), key); log.info("[API-PORTAL] 已注销流程 {} 对应 key {}", existing.getId(), key);
} catch (Exception ex) { } catch (Exception ex) {
log.warn("Failed to remove integration flow {}", existing.getId(), ex); log.warn("移除集成流程 {} 失败", existing.getId(), ex);
} }
} }
} }

View File

@@ -15,7 +15,7 @@ import java.time.Duration;
import java.time.Instant; import java.time.Instant;
/** /**
* Channel interceptor capturing timing metrics and enriched logging. * 通道拦截器,用于捕获耗时指标并增强日志记录。
*/ */
@Slf4j @Slf4j
@Component @Component
@@ -48,7 +48,7 @@ public class MonitoringInterceptor implements ChannelInterceptor {
} }
} }
if (ex != null) { if (ex != null) {
log.error("[API-PORTAL] Channel send failed", ex); log.error("[API-PORTAL] 通道发送失败", ex);
} }
} }
} }

View File

@@ -5,19 +5,14 @@ import com.zt.plat.framework.common.exception.util.ServiceExceptionUtil;
import com.zt.plat.module.databus.framework.integration.gateway.domain.ApiDefinitionAggregate; import com.zt.plat.module.databus.framework.integration.gateway.domain.ApiDefinitionAggregate;
import com.zt.plat.module.databus.framework.integration.gateway.domain.ApiStepDefinition; import com.zt.plat.module.databus.framework.integration.gateway.domain.ApiStepDefinition;
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiInvocationContext; import com.zt.plat.module.databus.framework.integration.gateway.model.ApiInvocationContext;
import com.zt.plat.module.databus.framework.integration.gateway.policy.AuthPolicyEvaluator;
import com.zt.plat.module.databus.framework.integration.gateway.policy.RateLimitPolicyEvaluator; import com.zt.plat.module.databus.framework.integration.gateway.policy.RateLimitPolicyEvaluator;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.aopalliance.aop.Advice;
import org.springframework.integration.handler.advice.AbstractRequestHandlerAdvice; import org.springframework.integration.handler.advice.AbstractRequestHandlerAdvice;
import org.springframework.integration.handler.advice.RequestHandlerRetryAdvice;
import org.springframework.retry.backoff.ExponentialBackOffPolicy;
import org.springframework.retry.policy.SimpleRetryPolicy;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_STEP_EXECUTION_ERROR; import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_STEP_EXECUTION_ERROR;
@@ -28,74 +23,21 @@ import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErro
@RequiredArgsConstructor @RequiredArgsConstructor
public class PolicyAdvisorFactory { public class PolicyAdvisorFactory {
private final AuthPolicyEvaluator authPolicyEvaluator;
private final RateLimitPolicyEvaluator rateLimitPolicyEvaluator; private final RateLimitPolicyEvaluator rateLimitPolicyEvaluator;
public org.aopalliance.aop.Advice[] buildAdvices(ApiDefinitionAggregate aggregate, ApiStepDefinition stepDefinition) { public Advice[] buildAdvices(ApiDefinitionAggregate aggregate, ApiStepDefinition stepDefinition) {
List<org.aopalliance.aop.Advice> advices = new ArrayList<>(); List<Advice> advices = new ArrayList<>();
advices.add(new AuthPolicyAdvice(aggregate)); if (aggregate.getRateLimitPolicy() != null) {
advices.add(new RateLimitPolicyAdvice(aggregate)); advices.add(new RateLimitPolicyAdvice(aggregate));
advices.add(createRetryAdvice(stepDefinition)); }
return advices.stream().filter(advice -> advice != null).toArray(org.aopalliance.aop.Advice[]::new); return advices.toArray(Advice[]::new);
} }
public org.aopalliance.aop.Advice[] buildParallelAdvices(ApiDefinitionAggregate aggregate, Object segment) { public Advice[] buildParallelAdvices(ApiDefinitionAggregate aggregate, Object segment) {
// For parallel segments we reuse the same advice chain (auth + rateLimit once at entry) // For parallel segments we reuse the same rate-limit advice chain
return buildAdvices(aggregate, null); return buildAdvices(aggregate, null);
} }
private RequestHandlerRetryAdvice createRetryAdvice(ApiStepDefinition stepDefinition) {
if (stepDefinition == null) {
return null;
}
Object strategyConfig = stepDefinition.getMetadata().get("retryStrategy");
if (!(strategyConfig instanceof Map<?, ?> configMap)) {
return null;
}
RetryTemplate template = new RetryTemplate();
SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy();
int maxAttempts = asInt(configMap.get("maxAttempts"), 3);
retryPolicy.setMaxAttempts(maxAttempts);
ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy();
long initialInterval = asLong(configMap.get("initialInterval"), 200L);
double multiplier = asDouble(configMap.get("multiplier"), 2.0d);
long maxInterval = asLong(configMap.get("maxInterval"), 2000L);
backOffPolicy.setInitialInterval(initialInterval);
backOffPolicy.setMultiplier(multiplier);
backOffPolicy.setMaxInterval(maxInterval);
template.setBackOffPolicy(backOffPolicy);
template.setRetryPolicy(retryPolicy);
RequestHandlerRetryAdvice advice = new RequestHandlerRetryAdvice();
advice.setRetryTemplate(template);
return advice;
}
private final class AuthPolicyAdvice extends AbstractRequestHandlerAdvice {
private final ApiDefinitionAggregate aggregate;
private AuthPolicyAdvice(ApiDefinitionAggregate aggregate) {
this.aggregate = aggregate;
}
@Override
protected Object doInvoke(ExecutionCallback callback, Object target, org.springframework.messaging.Message<?> message) {
if (aggregate.getAuthPolicy() != null) {
authPolicyEvaluator.evaluate(aggregate, (ApiInvocationContext) message.getPayload());
}
try {
return callback.execute();
} catch (Exception ex) {
if (ex instanceof ServiceException serviceException) {
throw serviceException;
}
if (ex instanceof RuntimeException runtimeException) {
throw runtimeException;
}
throw ServiceExceptionUtil.exception(API_STEP_EXECUTION_ERROR, ex.getMessage());
}
}
}
private final class RateLimitPolicyAdvice extends AbstractRequestHandlerAdvice { private final class RateLimitPolicyAdvice extends AbstractRequestHandlerAdvice {
private final ApiDefinitionAggregate aggregate; private final ApiDefinitionAggregate aggregate;
@@ -121,46 +63,4 @@ public class PolicyAdvisorFactory {
} }
} }
} }
private int asInt(Object value, int defaultValue) {
if (value instanceof Number number) {
return number.intValue();
}
if (value instanceof String text) {
try {
return Integer.parseInt(text);
} catch (NumberFormatException ignored) {
// ignore and fall back to default
}
}
return defaultValue;
}
private long asLong(Object value, long defaultValue) {
if (value instanceof Number number) {
return number.longValue();
}
if (value instanceof String text) {
try {
return Long.parseLong(text);
} catch (NumberFormatException ignored) {
// ignore and fall back to default
}
}
return defaultValue;
}
private double asDouble(Object value, double defaultValue) {
if (value instanceof Number number) {
return number.doubleValue();
}
if (value instanceof String text) {
try {
return Double.parseDouble(text);
} catch (NumberFormatException ignored) {
// ignore and fall back to default
}
}
return defaultValue;
}
} }

View File

@@ -1,7 +1,6 @@
package com.zt.plat.module.databus.framework.integration.gateway.domain; package com.zt.plat.module.databus.framework.integration.gateway.domain;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiDefinitionDO; import com.zt.plat.module.databus.dal.dataobject.gateway.ApiDefinitionDO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiPolicyAuthDO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiPolicyRateLimitDO; import com.zt.plat.module.databus.dal.dataobject.gateway.ApiPolicyRateLimitDO;
import lombok.Builder; import lombok.Builder;
import lombok.Value; import lombok.Value;
@@ -23,8 +22,6 @@ public class ApiDefinitionAggregate {
Map<String, ApiTransformDefinition> apiLevelTransforms; Map<String, ApiTransformDefinition> apiLevelTransforms;
ApiPolicyAuthDO authPolicy;
ApiPolicyRateLimitDO rateLimitPolicy; ApiPolicyRateLimitDO rateLimitPolicy;
ApiFlowPublication publication; ApiFlowPublication publication;

View File

@@ -0,0 +1,168 @@
package com.zt.plat.module.databus.framework.integration.gateway.expression;
import com.zt.plat.framework.common.exception.util.ServiceExceptionUtil;
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiInvocationContext;
import java.util.LinkedHashMap;
import java.util.Map;
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_TRANSFORM_RESPONSE_STATUS_INVALID;
/**
* Utility helpers for mutating {@link ApiInvocationContext} based on expression evaluation results.
*/
public final class GatewayExpressionHelper {
private GatewayExpressionHelper() {
}
public static void applyContextMutations(ApiInvocationContext context, Object result,
boolean allowRequestMutations, boolean allowResponseMutations) {
if (result == null) {
return;
}
if (result instanceof Map<?, ?> map) {
applyCommonMutations(context, map);
if (allowRequestMutations) {
applyRequestMutations(context, map);
}
if (allowResponseMutations) {
applyResponseMutations(context, map);
}
return;
}
if (allowRequestMutations) {
context.setRequestBody(result);
} else if (allowResponseMutations) {
context.setResponseBody(result);
}
}
public static Map<String, Object> snapshotRequest(ApiInvocationContext context) {
Map<String, Object> snapshot = new LinkedHashMap<>();
if (context.getRequestBody() != null) {
snapshot.put("body", context.getRequestBody());
}
if (context.getRequestQueryParams() != null && !context.getRequestQueryParams().isEmpty()) {
snapshot.put("query", new LinkedHashMap<>(context.getRequestQueryParams()));
}
if (context.getRequestHeaders() != null && !context.getRequestHeaders().isEmpty()) {
snapshot.put("headers", new LinkedHashMap<>(context.getRequestHeaders()));
}
if (snapshot.isEmpty()) {
return null;
}
return snapshot;
}
public static Map<String, Object> snapshotResponse(ApiInvocationContext context) {
Map<String, Object> snapshot = new LinkedHashMap<>();
if (context.getResponseBody() != null) {
snapshot.put("body", context.getResponseBody());
}
if (context.getResponseStatus() != null) {
snapshot.put("status", context.getResponseStatus());
}
if (context.getResponseMessage() != null) {
snapshot.put("message", context.getResponseMessage());
}
if (snapshot.isEmpty()) {
return null;
}
return snapshot;
}
private static void applyCommonMutations(ApiInvocationContext context, Map<?, ?> map) {
Object variables = map.get("variables");
if (variables instanceof Map<?, ?> variableMap) {
context.getVariables().putAll(toObjectMap(variableMap));
}
Object attributes = map.get("attributes");
if (attributes instanceof Map<?, ?> attributeMap) {
context.getAttributes().putAll(toObjectMap(attributeMap));
}
Object tenantId = map.get("tenantId");
if (tenantId != null) {
context.setTenantId(String.valueOf(tenantId));
}
Object httpMethod = map.get("httpMethod");
if (httpMethod != null) {
context.setHttpMethod(String.valueOf(httpMethod));
}
Object requestPath = map.get("requestPath");
if (requestPath != null) {
context.setRequestPath(String.valueOf(requestPath));
}
}
private static void applyRequestMutations(ApiInvocationContext context, Map<?, ?> map) {
Object body = firstNonNull(map.get("requestBody"), map.get("body"));
if (body != null) {
context.setRequestBody(body);
}
Object headers = map.get("requestHeaders");
if (headers instanceof Map<?, ?> headerMap) {
context.getRequestHeaders().putAll(toStringMap(headerMap));
}
Object query = firstNonNull(map.get("requestQuery"), map.get("requestQueryParams"), map.get("query"));
if (query instanceof Map<?, ?> queryMap) {
context.getRequestQueryParams().putAll(toObjectMap(queryMap));
}
}
private static void applyResponseMutations(ApiInvocationContext context, Map<?, ?> map) {
Object body = firstNonNull(map.get("responseBody"), map.get("body"));
if (body != null) {
context.setResponseBody(body);
}
Object status = map.get("responseStatus");
if (status != null) {
context.setResponseStatus(asInteger(status));
}
Object message = map.get("responseMessage");
if (message != null) {
context.setResponseMessage(String.valueOf(message));
}
}
private static Map<String, Object> toObjectMap(Map<?, ?> origin) {
Map<String, Object> result = new LinkedHashMap<>();
origin.forEach((key, value) -> result.put(String.valueOf(key), value));
return result;
}
private static Map<String, String> toStringMap(Map<?, ?> origin) {
Map<String, String> result = new LinkedHashMap<>();
origin.forEach((key, value) -> {
if (value != null) {
result.put(String.valueOf(key), String.valueOf(value));
}
});
return result;
}
private static Integer asInteger(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number number) {
return number.intValue();
}
String raw = String.valueOf(value);
try {
return Integer.parseInt(raw);
} catch (NumberFormatException ex) {
throw ServiceExceptionUtil.exception(API_TRANSFORM_RESPONSE_STATUS_INVALID, raw);
}
}
@SafeVarargs
private static <T> T firstNonNull(T... values) {
for (T value : values) {
if (value != null) {
return value;
}
}
return null;
}
}

View File

@@ -1,13 +0,0 @@
package com.zt.plat.module.databus.framework.integration.gateway.expression;
/**
* Legacy placeholder kept for binary compatibility. JSR-223 scripts are no longer supported.
*/
@Deprecated(forRemoval = true)
public class JsScriptExpressionEvaluator implements ExpressionEvaluator {
@Override
public Object evaluate(String expression, ExpressionEvaluationContext context) {
throw new UnsupportedOperationException("JSR-223 script expressions are no longer supported. Use JSON expressions instead.");
}
}

View File

@@ -1,13 +0,0 @@
package com.zt.plat.module.databus.framework.integration.gateway.expression;
/**
* Legacy placeholder kept for binary compatibility. MVEL evaluation is no longer supported.
*/
@Deprecated(forRemoval = true)
public class MvelExpressionEvaluator implements ExpressionEvaluator {
@Override
public Object evaluate(String expression, ExpressionEvaluationContext context) {
throw new UnsupportedOperationException("MVEL expressions are no longer supported. Use JSON expressions instead.");
}
}

View File

@@ -1,13 +0,0 @@
package com.zt.plat.module.databus.framework.integration.gateway.expression;
/**
* Legacy placeholder kept for binary compatibility. SpEL evaluation is no longer supported.
*/
@Deprecated(forRemoval = true)
public class SpelExpressionEvaluator implements ExpressionEvaluator {
@Override
public Object evaluate(String expression, ExpressionEvaluationContext context) {
throw new UnsupportedOperationException("SpEL expressions are no longer supported. Use JSON expressions instead.");
}
}

View File

@@ -5,8 +5,7 @@ import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
/** /**
* Applies idempotent data adjustments required for gateway orchestration features * 在集成流程启动前执行网关编排所需的幂等数据调整。
* before integration flows bootstrap.
*/ */
@Slf4j @Slf4j
@Component("gatewayPolicyMigration") @Component("gatewayPolicyMigration")
@@ -14,6 +13,6 @@ public class GatewayPolicyMigration {
@PostConstruct @PostConstruct
public void migrate() { public void migrate() {
log.info("[API-PORTAL] gateway policy migration skipped; standard header token auth in use"); log.info("[API-PORTAL] 跳过网关策略迁移,继续使用标准头部令牌认证");
} }
} }

View File

@@ -10,11 +10,14 @@ import lombok.Value;
@Builder @Builder
public class ApiGatewayResponse { public class ApiGatewayResponse {
String code; /**
* HTTP status code returned by the gateway. Always aligns with the response status line.
*/
int code;
String message; String message;
Object data; Object response;
String traceId; String traceId;

View File

@@ -5,11 +5,7 @@ import lombok.Setter;
import lombok.ToString; import lombok.ToString;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/** /**
* Runtime context for an API invocation flowing through the integration pipeline. * Runtime context for an API invocation flowing through the integration pipeline.
@@ -57,7 +53,7 @@ public class ApiInvocationContext {
this.variables = new HashMap<>(); this.variables = new HashMap<>();
this.attributes = new HashMap<>(); this.attributes = new HashMap<>();
this.stepResults = new ArrayList<>(); this.stepResults = new ArrayList<>();
this.requestHeaders = new HashMap<>(); this.requestHeaders = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
this.requestQueryParams = new HashMap<>(); this.requestQueryParams = new HashMap<>();
} }
@@ -73,7 +69,7 @@ public class ApiInvocationContext {
copy.httpMethod = this.httpMethod; copy.httpMethod = this.httpMethod;
copy.requestPath = this.requestPath; copy.requestPath = this.requestPath;
copy.requestBody = this.requestBody; copy.requestBody = this.requestBody;
copy.requestQueryParams.putAll(this.requestQueryParams); copy.requestQueryParams.putAll(this.requestQueryParams);
copy.responseBody = this.responseBody; copy.responseBody = this.responseBody;
copy.responseStatus = this.responseStatus; copy.responseStatus = this.responseStatus;
copy.responseMessage = this.responseMessage; copy.responseMessage = this.responseMessage;

View File

@@ -1,13 +0,0 @@
package com.zt.plat.module.databus.framework.integration.gateway.policy;
import com.zt.plat.module.databus.framework.integration.gateway.domain.ApiDefinitionAggregate;
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiInvocationContext;
/**
* Performs authentication / authorization policy evaluation for a request.
*/
public interface AuthPolicyEvaluator {
void evaluate(ApiDefinitionAggregate aggregate, ApiInvocationContext context);
}

View File

@@ -1,56 +0,0 @@
package com.zt.plat.module.databus.framework.integration.gateway.policy;
import com.zt.plat.framework.common.biz.system.oauth2.OAuth2TokenCommonApi;
import com.zt.plat.framework.common.exception.ServiceException;
import com.zt.plat.framework.common.exception.util.ServiceExceptionUtil;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiPolicyAuthDO;
import com.zt.plat.module.databus.framework.integration.gateway.domain.ApiDefinitionAggregate;
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiInvocationContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_AUTH_UNAUTHORIZED;
/**
* Basic authentication evaluator delegating token validation to system module.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DefaultAuthPolicyEvaluator implements AuthPolicyEvaluator {
private static final String TOKEN_HEADER = "ZT-Auth-Token";
private final OAuth2TokenCommonApi oauth2TokenCommonApi;
@Override
public void evaluate(ApiDefinitionAggregate aggregate, ApiInvocationContext context) {
ApiPolicyAuthDO authPolicy = aggregate.getAuthPolicy();
if (authPolicy == null) {
return;
}
validateHeaderToken(context);
}
private void validateHeaderToken(ApiInvocationContext context) {
Object rawHeader = context.getRequestHeaders().get(TOKEN_HEADER);
String token = rawHeader == null ? null : String.valueOf(rawHeader).trim();
if (!StringUtils.hasText(token)) {
throw ServiceExceptionUtil.exception(API_AUTH_UNAUTHORIZED);
}
try {
oauth2TokenCommonApi.checkAccessToken(token).getCheckedData();
context.getAttributes().putIfAbsent("accessToken", token);
String bearerToken = token.startsWith("Bearer ") ? token : "Bearer " + token;
context.getRequestHeaders().putIfAbsent("Authorization", bearerToken);
} catch (ServiceException ex) {
log.warn("Access token validation failed: {}", ex.getMessage());
throw ServiceExceptionUtil.exception(API_AUTH_UNAUTHORIZED);
} catch (RuntimeException ex) {
log.error("Access token validation error", ex);
throw ServiceExceptionUtil.exception(API_AUTH_UNAUTHORIZED);
}
}
}

View File

@@ -22,7 +22,7 @@ import java.util.Map;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
/** /**
* Simple Redis-backed rate limit evaluator supporting fixed window counters. * Redis 支撑的基础限流评估器,支持固定窗口计数。
*/ */
@Slf4j @Slf4j
@Component @Component
@@ -54,7 +54,7 @@ public class DefaultRateLimitPolicyEvaluator implements RateLimitPolicyEvaluator
throw ServiceExceptionUtil.exception(API_RATE_LIMIT_EXCEEDED); throw ServiceExceptionUtil.exception(API_RATE_LIMIT_EXCEEDED);
} }
} catch (JsonProcessingException | DataAccessException ex) { } catch (JsonProcessingException | DataAccessException ex) {
log.error("Rate limit evaluation failed for api {}", aggregate.getDefinition().getApiCode(), ex); log.error("API {} 的限流评估失败", aggregate.getDefinition().getApiCode(), ex);
throw ServiceExceptionUtil.exception(API_RATE_LIMIT_EVALUATION_FAILED); throw ServiceExceptionUtil.exception(API_RATE_LIMIT_EVALUATION_FAILED);
} }
} }

View File

@@ -0,0 +1,179 @@
package com.zt.plat.module.databus.framework.integration.gateway.security;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
/**
* 简单的报文缓存包装,便于后续处理链重复读取解密后的内容。
*/
public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {
private final byte[] cachedBody;
private String characterEncoding;
private final Map<String, List<String>> additionalHeaders;
public CachedBodyHttpServletRequest(HttpServletRequest request, byte[] cachedBody) {
super(request);
this.cachedBody = cachedBody != null ? cachedBody : new byte[0];
this.characterEncoding = request.getCharacterEncoding();
this.additionalHeaders = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
}
@Override
public void setCharacterEncoding(String encoding) throws UnsupportedEncodingException {
if (encoding != null) {
super.setCharacterEncoding(encoding);
}
this.characterEncoding = encoding;
}
@Override
public String getCharacterEncoding() {
return characterEncoding != null ? characterEncoding : StandardCharsets.UTF_8.name();
}
@Override
public ServletInputStream getInputStream() {
ByteArrayInputStream delegate = new ByteArrayInputStream(cachedBody);
return new ServletInputStream() {
@Override
public boolean isFinished() {
return delegate.available() == 0;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setReadListener(ReadListener readListener) {
// 保持空实现
}
@Override
public int read() {
return delegate.read();
}
};
}
@Override
public BufferedReader getReader() throws IOException {
Charset charset = Charset.forName(getCharacterEncoding());
return new BufferedReader(new InputStreamReader(getInputStream(), charset));
}
@Override
public int getContentLength() {
return cachedBody.length;
}
@Override
public long getContentLengthLong() {
return cachedBody.length;
}
public byte[] getCachedBody() {
return cachedBody.clone();
}
public void setHeader(String name, String value) {
if (!StringUtils.hasText(name)) {
return;
}
if (!StringUtils.hasText(value)) {
additionalHeaders.remove(name);
return;
}
additionalHeaders.put(name, new ArrayList<>(Collections.singletonList(value)));
}
public void setHeaderValues(String name, List<String> values) {
if (!StringUtils.hasText(name)) {
return;
}
if (CollectionUtils.isEmpty(values)) {
additionalHeaders.remove(name);
return;
}
additionalHeaders.put(name, new ArrayList<>(values));
}
public void addHeader(String name, String value) {
if (!StringUtils.hasText(name) || !StringUtils.hasText(value)) {
return;
}
additionalHeaders.compute(name, (key, existing) -> {
List<String> list = existing == null ? new ArrayList<>() : new ArrayList<>(existing);
list.add(value);
return list;
});
}
public void removeHeader(String name) {
if (!StringUtils.hasText(name)) {
return;
}
additionalHeaders.remove(name);
}
@Override
public String getHeader(String name) {
if (StringUtils.hasText(name)) {
List<String> values = additionalHeaders.get(name);
if (!CollectionUtils.isEmpty(values)) {
return values.get(0);
}
}
return super.getHeader(name);
}
@Override
public Enumeration<String> getHeaders(String name) {
List<String> combined = new ArrayList<>();
if (StringUtils.hasText(name)) {
List<String> custom = additionalHeaders.get(name);
if (!CollectionUtils.isEmpty(custom)) {
combined.addAll(custom);
}
}
Enumeration<String> parent = super.getHeaders(name);
while (parent.hasMoreElements()) {
combined.add(parent.nextElement());
}
return Collections.enumeration(combined);
}
@Override
public Enumeration<String> getHeaderNames() {
Set<String> names = new LinkedHashSet<>();
Enumeration<String> parent = super.getHeaderNames();
while (parent.hasMoreElements()) {
names.add(parent.nextElement());
}
names.addAll(additionalHeaders.keySet());
return Collections.enumeration(names);
}
}

View File

@@ -0,0 +1,167 @@
package com.zt.plat.module.databus.framework.integration.gateway.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zt.plat.module.databus.framework.integration.gateway.core.GatewayHeaderUtils;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpHeaders;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.lang.reflect.Array;
import java.util.Enumeration;
import java.util.Iterator;
import java.util.Map;
/**
* Shared helpers for resolving JWT tokens from incoming payloads so debug flows
* and external HTTP requests stay consistent.
*/
public final class GatewayJwtResolver {
public static final String HEADER_ZT_AUTH_TOKEN = "ZT-Auth-Token";
private GatewayJwtResolver() {
}
public static String resolveJwtToken(HttpServletRequest request, ObjectMapper objectMapper) {
if (request == null) {
return null;
}
String token = extractTokenFromHeader(request.getHeaders(HEADER_ZT_AUTH_TOKEN), objectMapper);
if (!StringUtils.hasText(token)) {
token = extractTokenFromHeader(request.getHeaders(HttpHeaders.AUTHORIZATION), objectMapper);
}
if (!StringUtils.hasText(token)) {
token = normalizeTokenValue(request.getParameter("token"), objectMapper);
}
return token;
}
public static String resolveJwtToken(Map<String, Object> headers,
Map<String, Object> queryParams,
ObjectMapper objectMapper) {
String token = extractTokenFromMap(headers, HEADER_ZT_AUTH_TOKEN, objectMapper);
if (!StringUtils.hasText(token)) {
token = extractTokenFromMap(headers, HttpHeaders.AUTHORIZATION, objectMapper);
}
if (!StringUtils.hasText(token) && queryParams != null) {
token = normalizeTokenValue(queryParams.get("token"), objectMapper);
}
return token;
}
private static String extractTokenFromHeader(Enumeration<String> values, ObjectMapper objectMapper) {
if (values == null) {
return null;
}
while (values.hasMoreElements()) {
String candidate = normalizeTokenString(values.nextElement(), objectMapper);
if (StringUtils.hasText(candidate)) {
return candidate;
}
}
return null;
}
private static String extractTokenFromMap(Map<String, Object> headers, String headerName, ObjectMapper objectMapper) {
if (headers == null || !StringUtils.hasText(headerName)) {
return null;
}
for (Map.Entry<String, Object> entry : headers.entrySet()) {
if (!headerName.equalsIgnoreCase(entry.getKey())) {
continue;
}
String candidate = normalizeTokenValue(entry.getValue(), objectMapper);
if (StringUtils.hasText(candidate)) {
return candidate;
}
}
return null;
}
private static String normalizeTokenValue(Object value, ObjectMapper objectMapper) {
if (value == null) {
return null;
}
if (value instanceof String str) {
return normalizeTokenString(str, objectMapper);
}
if (value instanceof Map<?, ?> map) {
Object direct = map.get("token");
if (direct == null) {
direct = map.get("accessToken");
}
if (direct == null) {
direct = map.get("authToken");
}
if (direct == null) {
direct = map.get("jwt");
}
if (direct != null) {
String resolved = normalizeTokenValue(direct, objectMapper);
if (StringUtils.hasText(resolved)) {
return resolved;
}
}
for (Object entryValue : map.values()) {
String resolved = normalizeTokenValue(entryValue, objectMapper);
if (StringUtils.hasText(resolved)) {
return resolved;
}
}
return null;
}
if (value instanceof Iterable<?> iterable) {
Iterator<?> iterator = iterable.iterator();
while (iterator.hasNext()) {
String resolved = normalizeTokenValue(iterator.next(), objectMapper);
if (StringUtils.hasText(resolved)) {
return resolved;
}
}
return null;
}
if (value.getClass().isArray()) {
int length = Array.getLength(value);
for (int i = 0; i < length; i++) {
String resolved = normalizeTokenValue(Array.get(value, i), objectMapper);
if (StringUtils.hasText(resolved)) {
return resolved;
}
}
return null;
}
return normalizeTokenString(String.valueOf(value), objectMapper);
}
private static String normalizeTokenString(String rawValue, ObjectMapper objectMapper) {
if (!StringUtils.hasText(rawValue)) {
return null;
}
String candidate = rawValue.trim();
if (candidate.startsWith("\"") && candidate.endsWith("\"") && candidate.length() > 1) {
candidate = candidate.substring(1, candidate.length() - 1).trim();
}
if ((candidate.startsWith("{") && candidate.endsWith("}"))
|| (candidate.startsWith("[") && candidate.endsWith("]"))) {
candidate = parseStructuredToken(candidate, objectMapper);
}
if (!StringUtils.hasText(candidate)) {
return null;
}
return GatewayHeaderUtils.stripBearerPrefix(candidate);
}
private static String parseStructuredToken(String candidate, ObjectMapper objectMapper) {
if (objectMapper == null) {
return candidate;
}
try {
Object parsed = objectMapper.readValue(candidate, Object.class);
String resolved = normalizeTokenValue(parsed, objectMapper);
return StringUtils.hasText(resolved) ? resolved : null;
} catch (IOException ex) {
return candidate;
}
}
}

View File

@@ -1,25 +1,39 @@
package com.zt.plat.module.databus.framework.integration.gateway.security; package com.zt.plat.module.databus.framework.integration.gateway.security;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zt.plat.framework.common.util.security.CryptoSignatureUtils;
import com.zt.plat.framework.common.util.servlet.ServletUtils;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiClientCredentialDO;
import com.zt.plat.module.databus.framework.integration.config.ApiGatewayProperties; import com.zt.plat.module.databus.framework.integration.config.ApiGatewayProperties;
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiGatewayResponse;
import com.zt.plat.module.databus.service.gateway.ApiClientCredentialService;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.HmacUtils; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher; import org.springframework.util.*;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingResponseWrapper;
import org.springframework.web.util.UriComponentsBuilder;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* Security filter performing IP allow/deny, signature validation, and tenant extraction for the unified portal. * 对进入网关的请求统一执行 IP 校验、报文签名、加解密与防重复校验。
*/ */
@Slf4j @Slf4j
@Component @Component
@@ -27,25 +41,73 @@ import java.util.List;
public class GatewaySecurityFilter extends OncePerRequestFilter { public class GatewaySecurityFilter extends OncePerRequestFilter {
private final ApiGatewayProperties properties; private final ApiGatewayProperties properties;
private final StringRedisTemplate stringRedisTemplate;
private final ApiClientCredentialService credentialService;
private final ObjectMapper objectMapper;
private final AntPathMatcher pathMatcher = new AntPathMatcher(); private final AntPathMatcher pathMatcher = new AntPathMatcher();
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
@Override @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException { throws ServletException, IOException {
String requestPath = request.getRequestURI(); String pathWithinApplication = pathWithinApplication(request);
if (!pathMatcher.match(properties.getBasePath() + "/**", requestPath)) { boolean matchesPortalPath = properties.getAllBasePaths()
.stream()
.map(this::normalizeBasePath)
.anyMatch(basePath -> pathMatcher.match(basePath + "/**", pathWithinApplication));
if (!matchesPortalPath) {
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
return; return;
} }
if (!isIpAllowed(request)) { if (!isIpAllowed(request)) {
response.sendError(HttpStatus.FORBIDDEN.value(), "IP not allowed"); log.warn("[API-PORTAL] 拦截来自 IP {} 访问 {} 的请求", request.getRemoteAddr(), pathWithinApplication);
response.sendError(HttpStatus.FORBIDDEN.value(), "IP 禁止访问");
return; return;
} }
if (properties.isEnableSignature() && !validateSignature(request)) { ApiGatewayProperties.Security security = properties.getSecurity();
response.sendError(HttpStatus.UNAUTHORIZED.value(), "Invalid signature"); ApiClientCredentialDO credential = null;
if (!security.isEnabled()) {
filterChain.doFilter(request, response);
return; return;
} }
filterChain.doFilter(request, response); try {
Long tenantId = resolveTenantId(request);
String appId = requireHeader(request, security.getAppIdHeader(), "缺少应用标识");
credential = credentialService.findActiveCredential(appId)
.orElseThrow(() -> new SecurityValidationException(HttpStatus.UNAUTHORIZED, "应用凭证不存在或已禁用"));
String timestampHeader = requireHeader(request, security.getTimestampHeader(), "缺少时间戳");
validateTimestamp(timestampHeader, security);
String nonce = requireHeader(request, security.getNonceHeader(), "缺少随机数");
if (nonce.length() < 8) {
throw new SecurityValidationException(HttpStatus.BAD_REQUEST, "随机数长度不足");
}
String signature = requireHeader(request, security.getSignatureHeader(), "缺少签名");
byte[] originalBody = StreamUtils.copyToByteArray(request.getInputStream());
byte[] decryptedBody = decryptRequestBody(originalBody, credential, security);
verifySignature(request, decryptedBody, signature, credential, security);
ensureNonce(tenantId, appId, nonce, security);
CachedBodyHttpServletRequest securedRequest = new CachedBodyHttpServletRequest(request, decryptedBody);
if (StringUtils.hasText(request.getCharacterEncoding())) {
securedRequest.setCharacterEncoding(request.getCharacterEncoding());
}
propagateJwtToken(request, securedRequest);
ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);
try {
filterChain.doFilter(securedRequest, responseWrapper);
encryptResponse(responseWrapper, credential, security);
} finally {
responseWrapper.copyBodyToResponse();
}
} catch (SecurityValidationException ex) {
log.warn("[API-PORTAL] 安全校验失败: {}", ex.getMessage());
writeErrorResponse(response, security, credential, ex.status(), ex.getMessage());
} catch (Exception ex) {
log.error("[API-PORTAL] 处理安全校验时出现异常", ex);
writeErrorResponse(response, security, credential, HttpStatus.INTERNAL_SERVER_ERROR, "网关安全校验失败");
}
} }
private boolean isIpAllowed(HttpServletRequest request) { private boolean isIpAllowed(HttpServletRequest request) {
@@ -58,18 +120,321 @@ public class GatewaySecurityFilter extends OncePerRequestFilter {
return CollectionUtils.isEmpty(allowed) || allowed.contains(remoteIp); return CollectionUtils.isEmpty(allowed) || allowed.contains(remoteIp);
} }
private boolean validateSignature(HttpServletRequest request) { private String pathWithinApplication(HttpServletRequest request) {
String headerSignature = request.getHeader(properties.getSignatureHeader()); String requestUri = request.getRequestURI();
if (!StringUtils.hasText(headerSignature)) { String contextPath = request.getContextPath();
if (StringUtils.hasText(contextPath) && requestUri.startsWith(contextPath)) {
return requestUri.substring(contextPath.length());
}
return requestUri;
}
private String normalizeBasePath(String basePath) {
String candidate = StringUtils.hasText(basePath) ? basePath : ApiGatewayProperties.DEFAULT_BASE_PATH;
candidate = candidate.startsWith("/") ? candidate : "/" + candidate;
if (candidate.endsWith("/")) {
candidate = candidate.substring(0, candidate.length() - 1);
}
return candidate;
}
private Long resolveTenantId(HttpServletRequest request) {
if (!properties.isEnableTenantHeader()) {
return null;
}
String tenantHeader = request.getHeader(properties.getTenantHeader());
if (!StringUtils.hasText(tenantHeader)) {
return null;
}
try {
return Long.valueOf(tenantHeader.trim());
} catch (NumberFormatException ex) {
throw new SecurityValidationException(HttpStatus.BAD_REQUEST, "租户标识格式不正确");
}
}
private String requireHeader(HttpServletRequest request, String headerName, String message) {
String value = request.getHeader(headerName);
if (!StringUtils.hasText(value)) {
throw new SecurityValidationException(HttpStatus.BAD_REQUEST, message);
}
return value.trim();
}
private void validateTimestamp(String rawTimestamp, ApiGatewayProperties.Security security) {
long parsed;
try {
parsed = Long.parseLong(rawTimestamp.trim());
} catch (NumberFormatException ex) {
throw new SecurityValidationException(HttpStatus.BAD_REQUEST, "时间戳格式错误");
}
long now = System.currentTimeMillis();
long skew = Math.abs(now - parsed);
if (skew > security.getAllowedClockSkewSeconds() * 1000) {
throw new SecurityValidationException(HttpStatus.UNAUTHORIZED, "请求到达时间超出 300s");
}
}
private byte[] decryptRequestBody(byte[] originalBody,
ApiClientCredentialDO credential,
ApiGatewayProperties.Security security) {
if (originalBody == null || originalBody.length == 0) {
return new byte[0];
}
String payload = new String(originalBody, StandardCharsets.UTF_8).trim();
if (!StringUtils.hasText(payload)) {
return new byte[0];
}
String encryptionKey = credential.getEncryptionKey();
String encryptionType = resolveEncryptionType(credential, security);
boolean canDecrypt = StringUtils.hasText(encryptionKey) && StringUtils.hasText(encryptionType);
if (!canDecrypt) {
if (security.isRequireBodyEncryption()) {
throw new SecurityValidationException(HttpStatus.INTERNAL_SERVER_ERROR, "应用未配置加密密钥");
}
return originalBody;
}
try {
String decrypted = CryptoSignatureUtils.decrypt(payload, encryptionKey, encryptionType);
if (!StringUtils.hasText(decrypted)) {
return new byte[0];
}
return decrypted.getBytes(StandardCharsets.UTF_8);
} catch (IllegalArgumentException | IllegalStateException ex) {
log.debug("[API-PORTAL] 解密请求报文失败", ex);
if (security.isRequireBodyEncryption()) {
throw new SecurityValidationException(HttpStatus.UNAUTHORIZED, "报文解密失败");
}
return originalBody;
}
}
private void verifySignature(HttpServletRequest request,
byte[] decryptedBody,
String signature,
ApiClientCredentialDO credential,
ApiGatewayProperties.Security security) {
Map<String, Object> signaturePayload = new LinkedHashMap<>();
mergeQueryParameters(signaturePayload, request);
mergeBodyParameters(signaturePayload, decryptedBody);
signaturePayload.put("signature", signature);
String signatureType = resolveSignatureType(credential, security);
try {
boolean valid = CryptoSignatureUtils.verifySignature(signaturePayload, signatureType);
if (!valid) {
throw new SecurityValidationException(HttpStatus.UNAUTHORIZED, "签名校验失败");
}
} catch (IllegalArgumentException ex) {
throw new SecurityValidationException(HttpStatus.INTERNAL_SERVER_ERROR, "签名算法配置异常");
}
}
private void mergeQueryParameters(Map<String, Object> target, HttpServletRequest request) {
String queryString = request.getQueryString();
if (!StringUtils.hasText(queryString)) {
return;
}
try {
MultiValueMap<String, String> params = UriComponentsBuilder.newInstance()
.query(queryString)
.build()
.getQueryParams();
params.forEach((key, values) -> {
if (!StringUtils.hasText(key) || "signature".equalsIgnoreCase(key)) {
return;
}
if (CollectionUtils.isEmpty(values)) {
target.put(key, "");
} else if (values.size() == 1) {
target.put(key, values.get(0));
} else {
target.put(key, String.join(",", values));
}
});
} catch (IllegalArgumentException ex) {
log.debug("[API-PORTAL] 解析查询串 {} 失败", queryString, ex);
target.put("query", queryString);
}
}
private void mergeBodyParameters(Map<String, Object> target, byte[] body) {
if (body == null || body.length == 0) {
return;
}
String bodyText = new String(body, StandardCharsets.UTF_8).trim();
if (!StringUtils.hasText(bodyText)) {
return;
}
if (bodyText.startsWith("{")) {
try {
Map<String, Object> bodyMap = objectMapper.readValue(bodyText, MAP_TYPE);
bodyMap.forEach((key, value) -> target.put(key, normalizeValue(value)));
return;
} catch (JsonProcessingException ex) {
log.debug("[API-PORTAL] 解析请求体 JSON 失败", ex);
}
}
target.put("body", bodyText);
}
private Object normalizeValue(Object value) {
if (value == null) {
return null;
}
if (value instanceof Map || value instanceof List) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException ex) {
return value.toString();
}
}
return value;
}
private String resolveEncryptionType(ApiClientCredentialDO credential, ApiGatewayProperties.Security security) {
if (credential != null && StringUtils.hasText(credential.getEncryptionType())) {
return credential.getEncryptionType();
}
return security.getEncryptionType();
}
private String resolveSignatureType(ApiClientCredentialDO credential, ApiGatewayProperties.Security security) {
if (credential != null && StringUtils.hasText(credential.getSignatureType())) {
return credential.getSignatureType();
}
return security.getSignatureType();
}
private void ensureNonce(Long tenantId, String appId, String nonce, ApiGatewayProperties.Security security) {
long ttl = security.getNonceTtlSeconds();
if (ttl <= 0) {
return;
}
String tenantSegment = tenantId != null ? tenantId.toString() : "global";
String key = security.getNonceRedisKeyPrefix() + tenantSegment + ":" + appId + ":" + nonce;
try {
Boolean stored = stringRedisTemplate.opsForValue()
.setIfAbsent(key, "1", Duration.ofSeconds(ttl));
if (Boolean.FALSE.equals(stored)) {
throw new SecurityValidationException(HttpStatus.UNAUTHORIZED, "重复请求");
}
} catch (RuntimeException ex) {
log.error("[API-PORTAL] 校验随机数时出现异常", ex);
throw new SecurityValidationException(HttpStatus.INTERNAL_SERVER_ERROR, "重复请求校验失败");
}
}
private void encryptResponse(ContentCachingResponseWrapper responseWrapper,
ApiClientCredentialDO credential,
ApiGatewayProperties.Security security) throws IOException {
if (!security.isEncryptResponse()) {
return;
}
String encryptionKey = credential.getEncryptionKey();
String encryptionType = resolveEncryptionType(credential, security);
if (!StringUtils.hasText(encryptionKey) || !StringUtils.hasText(encryptionType)) {
throw new SecurityValidationException(HttpStatus.INTERNAL_SERVER_ERROR, "应用未配置加密密钥");
}
byte[] plainBody = responseWrapper.getContentAsByteArray();
String charsetName = responseWrapper.getCharacterEncoding();
if (!StringUtils.hasText(charsetName)) {
charsetName = StandardCharsets.UTF_8.name();
}
Charset charset;
try {
charset = Charset.forName(charsetName);
} catch (IllegalArgumentException ex) {
charset = StandardCharsets.UTF_8;
}
String plainText = new String(plainBody, charset);
try {
String encrypted = CryptoSignatureUtils.encrypt(plainText, encryptionKey, encryptionType);
responseWrapper.resetBuffer();
if (!StringUtils.hasText(responseWrapper.getContentType())) {
responseWrapper.setContentType("text/plain;charset=UTF-8");
}
byte[] encryptedBytes = encrypted.getBytes(StandardCharsets.UTF_8);
responseWrapper.setContentLength(encryptedBytes.length);
responseWrapper.getOutputStream().write(encryptedBytes);
} catch (IllegalArgumentException | IllegalStateException ex) {
log.error("[API-PORTAL] 响应加密失败", ex);
throw new SecurityValidationException(HttpStatus.INTERNAL_SERVER_ERROR, "响应加密失败");
}
}
private void propagateJwtToken(HttpServletRequest originalRequest, CachedBodyHttpServletRequest securedRequest) {
String token = GatewayJwtResolver.resolveJwtToken(originalRequest, objectMapper);
if (!StringUtils.hasText(token)) {
return;
}
securedRequest.setHeader(GatewayJwtResolver.HEADER_ZT_AUTH_TOKEN, token);
securedRequest.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
}
private static final class SecurityValidationException extends RuntimeException {
private final HttpStatus status;
private SecurityValidationException(HttpStatus status, String message) {
super(message);
this.status = status;
}
private HttpStatus status() {
return status;
}
}
private void writeErrorResponse(HttpServletResponse response,
ApiGatewayProperties.Security security,
ApiClientCredentialDO credential,
HttpStatus status,
String message) {
if (response.isCommitted()) {
log.warn("[API-PORTAL] 响应已提交,无法写入安全校验错误: {}", message);
return;
}
response.resetBuffer();
response.setStatus(status.value());
String resolvedMessage = StringUtils.hasText(message) ? message : status.getReasonPhrase();
ApiGatewayResponse envelope = ApiGatewayResponse.builder()
.code(status.value())
.message(resolvedMessage)
.response(null)
.traceId(null)
.build();
if (shouldEncryptErrorResponse(security, credential)) {
String encryptionKey = credential.getEncryptionKey();
String encryptionType = resolveEncryptionType(credential, security);
try {
String json = objectMapper.writeValueAsString(envelope);
String encrypted = CryptoSignatureUtils.encrypt(json, encryptionKey, encryptionType);
response.setContentType("text/plain;charset=UTF-8");
byte[] encryptedBytes = encrypted.getBytes(StandardCharsets.UTF_8);
response.setContentLength(encryptedBytes.length);
response.getOutputStream().write(encryptedBytes);
return;
} catch (JsonProcessingException ex) {
log.error("[API-PORTAL] 序列化安全错误响应失败", ex);
} catch (IllegalArgumentException | IllegalStateException ex) {
log.error("[API-PORTAL] 安全错误响应加密失败", ex);
} catch (IOException ex) {
log.error("[API-PORTAL] 写入加密安全响应失败", ex);
}
}
ServletUtils.writeJSON(response, envelope);
}
private boolean shouldEncryptErrorResponse(ApiGatewayProperties.Security security, ApiClientCredentialDO credential) {
if (security == null || credential == null) {
return false; return false;
} }
String secret = properties.getSignatureSecret(); if (!security.isEncryptResponse()) {
if (!StringUtils.hasText(secret)) {
log.warn("Signature verification enabled but no secret configured");
return false; return false;
} }
String payload = request.getRequestURI() + "|" + (request.getQueryString() == null ? "" : request.getQueryString()); String encryptionKey = credential.getEncryptionKey();
String computed = HmacUtils.hmacSha256Hex(secret, payload); String encryptionType = resolveEncryptionType(credential, security);
return headerSignature.equalsIgnoreCase(computed); return StringUtils.hasText(encryptionKey) && StringUtils.hasText(encryptionType);
} }
} }

View File

@@ -0,0 +1,18 @@
package com.zt.plat.module.databus.framework.integration.gateway.security;
/**
* Exception thrown when signature verification fails.
*/
public class SignatureValidationException extends RuntimeException {
private final int status;
public SignatureValidationException(int status, String message) {
super(message);
this.status = status;
}
public int getStatus() {
return status;
}
}

View File

@@ -8,6 +8,7 @@ import com.zt.plat.module.databus.framework.integration.gateway.model.ApiInvocat
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.integration.core.GenericHandler; import org.springframework.integration.core.GenericHandler;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.List; import java.util.List;
@@ -23,7 +24,8 @@ public class StepHandlerFactory {
private final List<ApiStepHandler> stepHandlers; private final List<ApiStepHandler> stepHandlers;
public GenericHandler<ApiInvocationContext> build(ApiDefinitionAggregate aggregate, ApiStepDefinition stepDefinition) { public GenericHandler<ApiInvocationContext> build(ApiDefinitionAggregate aggregate, ApiStepDefinition stepDefinition) {
ApiStepTypeEnum type = ApiStepTypeEnum.valueOf(stepDefinition.getStep().getType().toUpperCase()); String rawType = stepDefinition.getStep().getType();
ApiStepTypeEnum type = resolveType(rawType);
return stepHandlers.stream() return stepHandlers.stream()
.filter(handler -> handler.supports(type.name())) .filter(handler -> handler.supports(type.name()))
.findFirst() .findFirst()
@@ -31,4 +33,15 @@ public class StepHandlerFactory {
.build(aggregate, stepDefinition); .build(aggregate, stepDefinition);
} }
private ApiStepTypeEnum resolveType(String rawType) {
if (!StringUtils.hasText(rawType)) {
throw ServiceExceptionUtil.exception(API_STEP_UNSUPPORTED_TYPE, "");
}
for (ApiStepTypeEnum candidate : ApiStepTypeEnum.values()) {
if (candidate.name().equalsIgnoreCase(rawType)) {
return candidate;
}
}
throw ServiceExceptionUtil.exception(API_STEP_UNSUPPORTED_TYPE, rawType);
}
} }

View File

@@ -0,0 +1,74 @@
package com.zt.plat.module.databus.framework.integration.gateway.step.impl;
import com.zt.plat.framework.common.exception.ServiceException;
import com.zt.plat.framework.common.exception.util.ServiceExceptionUtil;
import com.zt.plat.module.databus.enums.gateway.ExpressionTypeEnum;
import com.zt.plat.module.databus.framework.integration.gateway.domain.ApiDefinitionAggregate;
import com.zt.plat.module.databus.framework.integration.gateway.domain.ApiStepDefinition;
import com.zt.plat.module.databus.framework.integration.gateway.expression.ExpressionExecutor;
import com.zt.plat.module.databus.framework.integration.gateway.expression.ExpressionSpec;
import com.zt.plat.module.databus.framework.integration.gateway.expression.ExpressionSpecParser;
import com.zt.plat.module.databus.framework.integration.gateway.expression.GatewayExpressionHelper;
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiInvocationContext;
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiStepResult;
import com.zt.plat.module.databus.framework.integration.gateway.step.ApiStepHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.integration.core.GenericHandler;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_STEP_END_EXECUTION_FAILED;
/**
* Handler for END orchestration nodes.
*/
@Component
@RequiredArgsConstructor
public class EndStepHandler implements ApiStepHandler {
private final ExpressionExecutor expressionExecutor;
@Override
public boolean supports(String stepType) {
return "END".equalsIgnoreCase(stepType);
}
@Override
public GenericHandler<ApiInvocationContext> build(ApiDefinitionAggregate aggregate, ApiStepDefinition stepDefinition) {
return (payload, headers) -> {
Instant start = Instant.now();
Object snapshotBefore = GatewayExpressionHelper.snapshotResponse(payload);
try {
ExpressionSpec spec = ExpressionSpecParser.parse(stepDefinition.getStep().getResponseMappingExpr(), ExpressionTypeEnum.JSON);
if (spec != null) {
Object evaluated = expressionExecutor.evaluate(spec, payload, payload.getResponseBody(), headers);
GatewayExpressionHelper.applyContextMutations(payload, evaluated, false, true);
}
payload.addStepResult(ApiStepResult.builder()
.stepId(stepDefinition.getStep().getId())
.stepType(stepDefinition.getStep().getType())
.request(snapshotBefore)
.response(GatewayExpressionHelper.snapshotResponse(payload))
.success(true)
.elapsed(Duration.between(start, Instant.now()))
.build());
} catch (Exception ex) {
payload.addStepResult(ApiStepResult.builder()
.stepId(stepDefinition.getStep().getId())
.stepType(stepDefinition.getStep().getType())
.request(snapshotBefore)
.success(false)
.errorMessage(ex.getMessage())
.elapsed(Duration.between(start, Instant.now()))
.build());
if (ex instanceof ServiceException serviceException) {
throw serviceException;
}
throw ServiceExceptionUtil.exception(API_STEP_END_EXECUTION_FAILED, ex.getMessage());
}
return payload;
};
}
}

View File

@@ -27,13 +27,7 @@ import reactor.core.publisher.Mono;
import java.net.URI; import java.net.URI;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.*;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_STEP_HTTP_ENDPOINT_INVALID; import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_STEP_HTTP_ENDPOINT_INVALID;
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_STEP_HTTP_EXECUTION_FAILED; import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_STEP_HTTP_EXECUTION_FAILED;
@@ -383,13 +377,7 @@ public class HttpStepHandler implements ApiStepHandler {
} }
private boolean supportsRequestBody(HttpMethod method) { private boolean supportsRequestBody(HttpMethod method) {
if (method == null) { // 所有请求都要传递请求体
return true; return true;
}
return !(HttpMethod.GET.equals(method)
|| HttpMethod.DELETE.equals(method)
|| HttpMethod.HEAD.equals(method)
|| HttpMethod.OPTIONS.equals(method)
|| HttpMethod.TRACE.equals(method));
} }
} }

View File

@@ -0,0 +1,73 @@
package com.zt.plat.module.databus.framework.integration.gateway.step.impl;
import com.zt.plat.framework.common.exception.ServiceException;
import com.zt.plat.framework.common.exception.util.ServiceExceptionUtil;
import com.zt.plat.module.databus.enums.gateway.ExpressionTypeEnum;
import com.zt.plat.module.databus.framework.integration.gateway.domain.ApiDefinitionAggregate;
import com.zt.plat.module.databus.framework.integration.gateway.domain.ApiStepDefinition;
import com.zt.plat.module.databus.framework.integration.gateway.expression.ExpressionExecutor;
import com.zt.plat.module.databus.framework.integration.gateway.expression.ExpressionSpec;
import com.zt.plat.module.databus.framework.integration.gateway.expression.ExpressionSpecParser;
import com.zt.plat.module.databus.framework.integration.gateway.expression.GatewayExpressionHelper;
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiInvocationContext;
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiStepResult;
import com.zt.plat.module.databus.framework.integration.gateway.step.ApiStepHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.integration.core.GenericHandler;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_STEP_START_EXECUTION_FAILED;
/**
* Handler for START orchestration nodes.
*/
@Component
@RequiredArgsConstructor
public class StartStepHandler implements ApiStepHandler {
private final ExpressionExecutor expressionExecutor;
@Override
public boolean supports(String stepType) {
return "START".equalsIgnoreCase(stepType);
}
@Override
public GenericHandler<ApiInvocationContext> build(ApiDefinitionAggregate aggregate, ApiStepDefinition stepDefinition) {
return (payload, headers) -> {
Instant start = Instant.now();
Object snapshotBefore = GatewayExpressionHelper.snapshotRequest(payload);
try {
ExpressionSpec spec = ExpressionSpecParser.parse(stepDefinition.getStep().getRequestMappingExpr(), ExpressionTypeEnum.JSON);
if (spec != null) {
Object evaluated = expressionExecutor.evaluate(spec, payload, payload.getRequestBody(), headers);
GatewayExpressionHelper.applyContextMutations(payload, evaluated, true, false);
}
payload.addStepResult(ApiStepResult.builder()
.stepId(stepDefinition.getStep().getId())
.stepType(stepDefinition.getStep().getType())
.request(snapshotBefore)
.response(GatewayExpressionHelper.snapshotRequest(payload))
.success(true)
.elapsed(Duration.between(start, Instant.now()))
.build());
} catch (Exception ex) {
payload.addStepResult(ApiStepResult.builder()
.stepId(stepDefinition.getStep().getId())
.stepType(stepDefinition.getStep().getType())
.request(snapshotBefore)
.success(false)
.errorMessage(ex.getMessage())
.elapsed(Duration.between(start, Instant.now()))
.build());
if (ex instanceof ServiceException serviceException) {
throw serviceException;
}
throw ServiceExceptionUtil.exception(API_STEP_START_EXECUTION_FAILED, ex.getMessage());
}
return payload;
};
}
}

View File

@@ -0,0 +1,26 @@
package com.zt.plat.module.databus.service.gateway;
import com.zt.plat.framework.common.pojo.PageResult;
import com.zt.plat.module.databus.controller.admin.gateway.vo.credential.ApiClientCredentialPageReqVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.credential.ApiClientCredentialSaveReqVO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiClientCredentialDO;
import java.util.List;
import java.util.Optional;
public interface ApiClientCredentialService {
PageResult<ApiClientCredentialDO> getPage(ApiClientCredentialPageReqVO reqVO);
Long create(ApiClientCredentialSaveReqVO reqVO);
void update(ApiClientCredentialSaveReqVO reqVO);
void delete(Long id);
ApiClientCredentialDO get(Long id);
List<ApiClientCredentialDO> listEnabled();
Optional<ApiClientCredentialDO> findActiveCredential(String appId);
}

View File

@@ -1,46 +0,0 @@
package com.zt.plat.module.databus.service.gateway;
import com.zt.plat.framework.common.pojo.PageResult;
import com.zt.plat.module.databus.controller.admin.gateway.vo.policy.ApiPolicyPageReqVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.policy.ApiPolicySaveReqVO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiPolicyAuthDO;
import java.util.List;
import java.util.Optional;
/**
* Authentication policy operations.
*/
public interface ApiPolicyAuthService {
/**
* Paginate policies.
*/
PageResult<ApiPolicyAuthDO> getPage(ApiPolicyPageReqVO reqVO);
/**
* Fetch all active policies for dropdowns.
*/
List<ApiPolicyAuthDO> getSimpleList();
/**
* Find policy detail.
*/
Optional<ApiPolicyAuthDO> get(Long id);
/**
* Create policy definition.
*/
Long create(ApiPolicySaveReqVO reqVO);
/**
* Update policy definition.
*/
void update(ApiPolicySaveReqVO reqVO);
/**
* Delete policy definition.
*/
void delete(Long id);
}

View File

@@ -0,0 +1,133 @@
package com.zt.plat.module.databus.service.gateway.impl;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import com.zt.plat.framework.common.exception.util.ServiceExceptionUtil;
import com.zt.plat.framework.common.pojo.PageResult;
import com.zt.plat.framework.common.util.object.BeanUtils;
import com.zt.plat.module.databus.controller.admin.gateway.vo.credential.ApiClientCredentialPageReqVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.credential.ApiClientCredentialSaveReqVO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiClientCredentialDO;
import com.zt.plat.module.databus.dal.mysql.gateway.ApiClientCredentialMapper;
import com.zt.plat.module.databus.service.gateway.ApiClientCredentialService;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_CREDENTIAL_DUPLICATE_APP;
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_CREDENTIAL_NOT_FOUND;
@Slf4j
@Service
@RequiredArgsConstructor
public class ApiClientCredentialServiceImpl implements ApiClientCredentialService {
private final ApiClientCredentialMapper credentialMapper;
private LoadingCache<String, Optional<ApiClientCredentialDO>> credentialCache;
@PostConstruct
public void initCache() {
credentialCache = Caffeine.newBuilder()
.maximumSize(256)
.expireAfterWrite(Duration.ofMinutes(5))
.build(this::loadCredentialSync);
}
@Override
public PageResult<ApiClientCredentialDO> getPage(ApiClientCredentialPageReqVO reqVO) {
return credentialMapper.selectPage(reqVO);
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long create(ApiClientCredentialSaveReqVO reqVO) {
ensureAppIdUnique(reqVO.getAppId(), null);
ApiClientCredentialDO credential = BeanUtils.toBean(reqVO, ApiClientCredentialDO.class);
credential.setId(null);
credential.setDeleted(Boolean.FALSE);
credentialMapper.insert(credential);
invalidateCache(credential.getAppId());
return credential.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void update(ApiClientCredentialSaveReqVO reqVO) {
ApiClientCredentialDO existing = ensureExists(reqVO.getId());
ensureAppIdUnique(reqVO.getAppId(), existing.getId());
ApiClientCredentialDO updateObj = BeanUtils.toBean(reqVO, ApiClientCredentialDO.class);
credentialMapper.updateById(updateObj);
invalidateCache(existing.getAppId());
invalidateCache(updateObj.getAppId());
}
@Override
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
ApiClientCredentialDO existing = ensureExists(id);
credentialMapper.deleteById(id);
invalidateCache(existing.getAppId());
}
@Override
public ApiClientCredentialDO get(Long id) {
return ensureExists(id);
}
@Override
public List<ApiClientCredentialDO> listEnabled() {
return credentialMapper.selectEnabledList();
}
@Override
public Optional<ApiClientCredentialDO> findActiveCredential(String appId) {
if (!StringUtils.hasText(appId)) {
return Optional.empty();
}
return credentialCache.get(appId.trim());
}
private Optional<ApiClientCredentialDO> loadCredentialSync(String appId) {
Optional<ApiClientCredentialDO> credential = credentialMapper.selectByAppId(appId)
.filter(item -> Boolean.TRUE.equals(item.getEnabled()));
if (credential.isEmpty()) {
log.debug("[API-PORTAL] 未找到 appId={} 的有效凭证", appId);
}
return credential;
}
private void ensureAppIdUnique(String appId, Long currentId) {
credentialMapper.selectByAppId(appId)
.filter(existing -> currentId == null || !Objects.equals(existing.getId(), currentId))
.ifPresent(existing -> { throw ServiceExceptionUtil.exception(API_CREDENTIAL_DUPLICATE_APP); });
}
private ApiClientCredentialDO ensureExists(Long id) {
if (id == null) {
throw ServiceExceptionUtil.exception(API_CREDENTIAL_NOT_FOUND);
}
ApiClientCredentialDO credential = credentialMapper.selectById(id);
if (credential == null || Boolean.TRUE.equals(credential.getDeleted())) {
throw ServiceExceptionUtil.exception(API_CREDENTIAL_NOT_FOUND);
}
return credential;
}
private void invalidateCache(String appId) {
if (!StringUtils.hasText(appId)) {
return;
}
credentialCache.invalidate(appId.trim());
}
}

View File

@@ -16,13 +16,11 @@ import com.zt.plat.module.databus.controller.admin.gateway.vo.definition.ApiDefi
import com.zt.plat.module.databus.controller.admin.gateway.vo.definition.ApiDefinitionTransformSaveReqVO; import com.zt.plat.module.databus.controller.admin.gateway.vo.definition.ApiDefinitionTransformSaveReqVO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiDefinitionDO; import com.zt.plat.module.databus.dal.dataobject.gateway.ApiDefinitionDO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiFlowPublishDO; import com.zt.plat.module.databus.dal.dataobject.gateway.ApiFlowPublishDO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiPolicyAuthDO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiPolicyRateLimitDO; import com.zt.plat.module.databus.dal.dataobject.gateway.ApiPolicyRateLimitDO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiStepDO; import com.zt.plat.module.databus.dal.dataobject.gateway.ApiStepDO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiTransformDO; import com.zt.plat.module.databus.dal.dataobject.gateway.ApiTransformDO;
import com.zt.plat.module.databus.dal.mysql.gateway.ApiDefinitionMapper; import com.zt.plat.module.databus.dal.mysql.gateway.ApiDefinitionMapper;
import com.zt.plat.module.databus.dal.mysql.gateway.ApiFlowPublishMapper; import com.zt.plat.module.databus.dal.mysql.gateway.ApiFlowPublishMapper;
import com.zt.plat.module.databus.dal.mysql.gateway.ApiPolicyAuthMapper;
import com.zt.plat.module.databus.dal.mysql.gateway.ApiPolicyRateLimitMapper; import com.zt.plat.module.databus.dal.mysql.gateway.ApiPolicyRateLimitMapper;
import com.zt.plat.module.databus.dal.mysql.gateway.ApiStepMapper; import com.zt.plat.module.databus.dal.mysql.gateway.ApiStepMapper;
import com.zt.plat.module.databus.dal.mysql.gateway.ApiTransformMapper; import com.zt.plat.module.databus.dal.mysql.gateway.ApiTransformMapper;
@@ -58,7 +56,6 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
private final ApiDefinitionMapper apiDefinitionMapper; private final ApiDefinitionMapper apiDefinitionMapper;
private final ApiStepMapper apiStepMapper; private final ApiStepMapper apiStepMapper;
private final ApiTransformMapper apiTransformMapper; private final ApiTransformMapper apiTransformMapper;
private final ApiPolicyAuthMapper apiPolicyAuthMapper;
private final ApiPolicyRateLimitMapper apiPolicyRateLimitMapper; private final ApiPolicyRateLimitMapper apiPolicyRateLimitMapper;
private final ApiFlowPublishMapper apiFlowPublishMapper; private final ApiFlowPublishMapper apiFlowPublishMapper;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@@ -186,7 +183,7 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
ApiDefinitionAggregate aggregate = objectMapper.readValue(json, ApiDefinitionAggregate.class); ApiDefinitionAggregate aggregate = objectMapper.readValue(json, ApiDefinitionAggregate.class);
return Optional.of(aggregate); return Optional.of(aggregate);
} catch (JsonProcessingException | DataAccessException ex) { } catch (JsonProcessingException | DataAccessException ex) {
log.warn("Failed to deserialize API definition aggregate from redis for key {}", cacheKey, ex); log.warn("反序列化 Redis key {} 的 API 定义聚合失败", cacheKey, ex);
return Optional.empty(); return Optional.empty();
} }
} }
@@ -196,7 +193,7 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
String json = objectMapper.writeValueAsString(aggregate); String json = objectMapper.writeValueAsString(aggregate);
stringRedisTemplate.opsForValue().set(REDIS_CACHE_PREFIX + cacheKey, json, 5, TimeUnit.MINUTES); stringRedisTemplate.opsForValue().set(REDIS_CACHE_PREFIX + cacheKey, json, 5, TimeUnit.MINUTES);
} catch (JsonProcessingException | DataAccessException ex) { } catch (JsonProcessingException | DataAccessException ex) {
log.warn("Failed to persist API definition aggregate to redis for key {}", cacheKey, ex); log.warn("将 API 定义聚合写入 Redis key {} 失败", cacheKey, ex);
} }
} }
@@ -204,7 +201,7 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
try { try {
stringRedisTemplate.delete(REDIS_CACHE_PREFIX + cacheKey); stringRedisTemplate.delete(REDIS_CACHE_PREFIX + cacheKey);
} catch (DataAccessException ex) { } catch (DataAccessException ex) {
log.warn("Failed to delete API definition aggregate from redis for key {}", cacheKey, ex); log.warn("删除 Redis key {} 的 API 定义聚合失败", cacheKey, ex);
} }
} }
@@ -219,7 +216,6 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
for (ApiStepDO stepDO : stepDOS) { for (ApiStepDO stepDO : stepDOS) {
List<ApiTransformDefinition> transforms = convertTransforms(apiTransformMapper.selectByStepId(stepDO.getId())); List<ApiTransformDefinition> transforms = convertTransforms(apiTransformMapper.selectByStepId(stepDO.getId()));
Map<String, Object> metadata = new LinkedHashMap<>(); Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("retryStrategy", parseJson(stepDO.getRetryStrategy()));
metadata.put("fallbackStrategy", parseJson(stepDO.getFallbackStrategy())); metadata.put("fallbackStrategy", parseJson(stepDO.getFallbackStrategy()));
metadata.put("timeout", stepDO.getTimeout()); metadata.put("timeout", stepDO.getTimeout());
metadata.put("stopOnError", stepDO.getStopOnError()); metadata.put("stopOnError", stepDO.getStopOnError());
@@ -234,9 +230,6 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
for (ApiTransformDefinition transform : convertTransforms(apiTransformMapper.selectApiLevelTransforms(definition.getId()))) { for (ApiTransformDefinition transform : convertTransforms(apiTransformMapper.selectApiLevelTransforms(definition.getId()))) {
apiTransforms.put(transform.getPhase(), transform); apiTransforms.put(transform.getPhase(), transform);
} }
ApiPolicyAuthDO authPolicy = Optional.ofNullable(definition.getAuthPolicyId())
.map(apiPolicyAuthMapper::selectById)
.orElse(null);
ApiPolicyRateLimitDO rateLimitPolicy = Optional.ofNullable(definition.getRateLimitId()) ApiPolicyRateLimitDO rateLimitPolicy = Optional.ofNullable(definition.getRateLimitId())
.map(apiPolicyRateLimitMapper::selectById) .map(apiPolicyRateLimitMapper::selectById)
.orElse(null); .orElse(null);
@@ -247,7 +240,6 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
.definition(definition) .definition(definition)
.steps(stepDefinitions) .steps(stepDefinitions)
.apiLevelTransforms(apiTransforms) .apiLevelTransforms(apiTransforms)
.authPolicy(authPolicy)
.rateLimitPolicy(rateLimitPolicy) .rateLimitPolicy(rateLimitPolicy)
.publication(publication) .publication(publication)
.build(); .build();
@@ -277,7 +269,7 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
try { try {
return objectMapper.readValue(json, new TypeReference<Map<String, Object>>() {}); return objectMapper.readValue(json, new TypeReference<Map<String, Object>>() {});
} catch (JsonProcessingException ex) { } catch (JsonProcessingException ex) {
log.warn("Failed to parse configuration JSON: {}", json, ex); log.warn("解析配置 JSON 失败: {}", json, ex);
return Collections.emptyMap(); return Collections.emptyMap();
} }
} }
@@ -331,6 +323,21 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
ApiStepDO stepDO = BeanUtils.toBean(stepVO, ApiStepDO.class); ApiStepDO stepDO = BeanUtils.toBean(stepVO, ApiStepDO.class);
stepDO.setId(null); stepDO.setId(null);
stepDO.setApiId(apiId); stepDO.setApiId(apiId);
if (isStartStep(stepVO)) {
stepDO.setParallelGroup(null);
stepDO.setTargetEndpoint(null);
stepDO.setFallbackStrategy(null);
stepDO.setConditionExpr(null);
stepDO.setStopOnError(Boolean.FALSE);
stepDO.setTimeout(null);
} else if (isEndStep(stepVO)) {
stepDO.setParallelGroup(null);
stepDO.setTargetEndpoint(null);
stepDO.setFallbackStrategy(null);
stepDO.setConditionExpr(null);
stepDO.setStopOnError(Boolean.FALSE);
stepDO.setTimeout(null);
}
applyTenantDefaults(stepDO); applyTenantDefaults(stepDO);
apiStepMapper.insert(stepDO); apiStepMapper.insert(stepDO);
persistStepTransforms(apiId, stepDO.getId(), stepVO.getTransforms()); persistStepTransforms(apiId, stepDO.getId(), stepVO.getTransforms());
@@ -377,14 +384,43 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
if (CollUtil.isEmpty(reqVO.getSteps())) { if (CollUtil.isEmpty(reqVO.getSteps())) {
throw ServiceExceptionUtil.exception(API_DEFINITION_STEP_EMPTY); throw ServiceExceptionUtil.exception(API_DEFINITION_STEP_EMPTY);
} }
if (reqVO.getSteps().size() < 2) {
throw ServiceExceptionUtil.exception(API_DEFINITION_STEP_EMPTY);
}
Set<Integer> orders = new HashSet<>(); Set<Integer> orders = new HashSet<>();
ApiDefinitionStepSaveReqVO startStep = null;
ApiDefinitionStepSaveReqVO endStep = null;
for (ApiDefinitionStepSaveReqVO step : reqVO.getSteps()) { for (ApiDefinitionStepSaveReqVO step : reqVO.getSteps()) {
Integer order = step.getStepOrder(); Integer order = step.getStepOrder();
if (order == null || !orders.add(order)) { if (order == null || order <= 0 || !orders.add(order)) {
throw ServiceExceptionUtil.exception(API_DEFINITION_STEP_ORDER_DUPLICATE); throw ServiceExceptionUtil.exception(API_DEFINITION_STEP_ORDER_DUPLICATE);
} }
if (isStartStep(step)) {
if (startStep != null) {
throw ServiceExceptionUtil.exception(API_DEFINITION_START_STEP_DUPLICATE);
}
startStep = step;
} else if (isEndStep(step)) {
if (endStep != null) {
throw ServiceExceptionUtil.exception(API_DEFINITION_END_STEP_DUPLICATE);
}
endStep = step;
}
validateTransformPhases(step.getTransforms()); validateTransformPhases(step.getTransforms());
} }
if (startStep == null) {
throw ServiceExceptionUtil.exception(API_DEFINITION_START_STEP_REQUIRED);
}
if (endStep == null) {
throw ServiceExceptionUtil.exception(API_DEFINITION_END_STEP_REQUIRED);
}
if (!Objects.equals(startStep.getStepOrder(), 1)) {
throw ServiceExceptionUtil.exception(API_DEFINITION_START_STEP_INVALID);
}
int expectedEndOrder = reqVO.getSteps().size();
if (!Objects.equals(endStep.getStepOrder(), expectedEndOrder)) {
throw ServiceExceptionUtil.exception(API_DEFINITION_END_STEP_INVALID);
}
validateTransformPhases(reqVO.getApiLevelTransforms()); validateTransformPhases(reqVO.getApiLevelTransforms());
} }
@@ -401,10 +437,15 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
} }
} }
private boolean isStartStep(ApiDefinitionStepSaveReqVO step) {
return StringUtils.hasText(step.getType()) && "START".equalsIgnoreCase(step.getType());
}
private boolean isEndStep(ApiDefinitionStepSaveReqVO step) {
return StringUtils.hasText(step.getType()) && "END".equalsIgnoreCase(step.getType());
}
private void validatePolicies(ApiDefinitionSaveReqVO reqVO) { private void validatePolicies(ApiDefinitionSaveReqVO reqVO) {
if (reqVO.getAuthPolicyId() != null && apiPolicyAuthMapper.selectById(reqVO.getAuthPolicyId()) == null) {
throw ServiceExceptionUtil.exception(API_POLICY_NOT_FOUND);
}
if (reqVO.getRateLimitId() != null && apiPolicyRateLimitMapper.selectById(reqVO.getRateLimitId()) == null) { if (reqVO.getRateLimitId() != null && apiPolicyRateLimitMapper.selectById(reqVO.getRateLimitId()) == null) {
throw ServiceExceptionUtil.exception(API_POLICY_NOT_FOUND); throw ServiceExceptionUtil.exception(API_POLICY_NOT_FOUND);
} }

View File

@@ -1,98 +0,0 @@
package com.zt.plat.module.databus.service.gateway.impl;
import cn.hutool.core.util.StrUtil;
import com.zt.plat.framework.common.exception.util.ServiceExceptionUtil;
import com.zt.plat.framework.common.pojo.PageResult;
import com.zt.plat.framework.tenant.core.context.TenantContextHolder;
import com.zt.plat.module.databus.controller.admin.gateway.vo.policy.ApiPolicyPageReqVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.policy.ApiPolicySaveReqVO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiPolicyAuthDO;
import com.zt.plat.module.databus.dal.mysql.gateway.ApiDefinitionMapper;
import com.zt.plat.module.databus.dal.mysql.gateway.ApiPolicyAuthMapper;
import com.zt.plat.module.databus.service.gateway.ApiPolicyAuthService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.Assert;
import java.util.List;
import java.util.Optional;
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_POLICY_IN_USE;
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_POLICY_NOT_FOUND;
@Service
@RequiredArgsConstructor
public class ApiPolicyAuthServiceImpl implements ApiPolicyAuthService {
private final ApiPolicyAuthMapper authMapper;
private final ApiDefinitionMapper apiDefinitionMapper;
@Override
public PageResult<ApiPolicyAuthDO> getPage(ApiPolicyPageReqVO reqVO) {
return authMapper.selectPage(reqVO);
}
@Override
public List<ApiPolicyAuthDO> getSimpleList() {
return authMapper.selectSimpleList();
}
@Override
public Optional<ApiPolicyAuthDO> get(Long id) {
if (id == null) {
return Optional.empty();
}
return Optional.ofNullable(authMapper.selectById(id))
.filter(policy -> !Boolean.TRUE.equals(policy.getDeleted()));
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long create(ApiPolicySaveReqVO reqVO) {
ApiPolicyAuthDO policy = new ApiPolicyAuthDO();
apply(reqVO, policy);
policy.setId(null);
policy.setTenantId(TenantContextHolder.getTenantId());
policy.setDeleted(Boolean.FALSE);
authMapper.insert(policy);
return policy.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void update(ApiPolicySaveReqVO reqVO) {
ApiPolicyAuthDO existing = ensureExists(reqVO.getId());
apply(reqVO, existing);
authMapper.updateById(existing);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
ApiPolicyAuthDO existing = ensureExists(id);
Long referenceCount = apiDefinitionMapper.selectCountByAuthPolicyId(existing.getId());
if (referenceCount != null && referenceCount > 0) {
throw ServiceExceptionUtil.exception(API_POLICY_IN_USE);
}
authMapper.deleteById(existing.getId());
}
private ApiPolicyAuthDO ensureExists(Long id) {
Assert.notNull(id, "策略编号不能为空");
return get(id).orElseThrow(() -> ServiceExceptionUtil.exception(API_POLICY_NOT_FOUND));
}
private void apply(ApiPolicySaveReqVO reqVO, ApiPolicyAuthDO target) {
target.setName(StrUtil.trim(reqVO.getName()));
target.setType(StrUtil.trim(reqVO.getType()));
target.setConfig(normalizeNullable(reqVO.getConfig()));
target.setDescription(normalizeNullable(reqVO.getDescription()));
}
private String normalizeNullable(String value) {
String trimmed = StrUtil.trim(value);
return StrUtil.isEmpty(trimmed) ? null : trimmed;
}
}

View File

@@ -9,11 +9,17 @@ public interface GatewayServiceErrorCodeConstants {
ErrorCode API_DEFINITION_NOT_FOUND = new ErrorCode(1_010_000_001, "API 定义未发布或已下线"); ErrorCode API_DEFINITION_NOT_FOUND = new ErrorCode(1_010_000_001, "API 定义未发布或已下线");
ErrorCode API_DEFINITION_DUPLICATE = new ErrorCode(1_010_000_002, "API 编码与版本已存在"); ErrorCode API_DEFINITION_DUPLICATE = new ErrorCode(1_010_000_002, "API 编码与版本已存在");
ErrorCode API_DEFINITION_STEP_EMPTY = new ErrorCode(1_010_000_003, "至少需要配置一个编排步骤"); ErrorCode API_DEFINITION_STEP_EMPTY = new ErrorCode(1_010_000_003, "至少需要包含开始和结束节点");
ErrorCode API_DEFINITION_STEP_ORDER_DUPLICATE = new ErrorCode(1_010_000_004, "步骤序号重复"); ErrorCode API_DEFINITION_STEP_ORDER_DUPLICATE = new ErrorCode(1_010_000_004, "步骤序号重复");
ErrorCode API_TRANSFORM_PHASE_DUPLICATE = new ErrorCode(1_010_000_005, "同一级别的变换阶段重复"); ErrorCode API_TRANSFORM_PHASE_DUPLICATE = new ErrorCode(1_010_000_005, "同一级别的变换阶段重复");
ErrorCode API_POLICY_NOT_FOUND = new ErrorCode(1_010_000_006, "绑定的策略不存在"); ErrorCode API_POLICY_NOT_FOUND = new ErrorCode(1_010_000_006, "绑定的策略不存在");
ErrorCode API_POLICY_IN_USE = new ErrorCode(1_010_000_028, "策略已被 API 定义引用,无法删除"); ErrorCode API_POLICY_IN_USE = new ErrorCode(1_010_000_028, "策略已被 API 定义引用,无法删除");
ErrorCode API_DEFINITION_START_STEP_REQUIRED = new ErrorCode(1_010_000_029, "必须包含开始节点");
ErrorCode API_DEFINITION_END_STEP_REQUIRED = new ErrorCode(1_010_000_030, "必须包含结束节点");
ErrorCode API_DEFINITION_START_STEP_INVALID = new ErrorCode(1_010_000_031, "开始节点必须位于第一个位置");
ErrorCode API_DEFINITION_END_STEP_INVALID = new ErrorCode(1_010_000_032, "结束节点必须位于最后一个位置");
ErrorCode API_DEFINITION_START_STEP_DUPLICATE = new ErrorCode(1_010_000_035, "开始节点只能存在一个");
ErrorCode API_DEFINITION_END_STEP_DUPLICATE = new ErrorCode(1_010_000_036, "结束节点只能存在一个");
ErrorCode API_FLOW_NOT_FOUND = new ErrorCode(1_010_000_007, "未找到可用的 API 调度流程code={}, version={}"); ErrorCode API_FLOW_NOT_FOUND = new ErrorCode(1_010_000_007, "未找到可用的 API 调度流程code={}, version={}");
ErrorCode API_FLOW_NO_REPLY = new ErrorCode(1_010_000_008, "集成流程未返回响应code={}, version={}"); ErrorCode API_FLOW_NO_REPLY = new ErrorCode(1_010_000_008, "集成流程未返回响应code={}, version={}");
ErrorCode API_AUTH_UNAUTHORIZED = new ErrorCode(1_010_000_009, "请求未通过认证"); ErrorCode API_AUTH_UNAUTHORIZED = new ErrorCode(1_010_000_009, "请求未通过认证");
@@ -35,5 +41,16 @@ public interface GatewayServiceErrorCodeConstants {
ErrorCode API_JSONATA_BIND_FAILED = new ErrorCode(1_010_000_025, "表达式环境绑定失败"); ErrorCode API_JSONATA_BIND_FAILED = new ErrorCode(1_010_000_025, "表达式环境绑定失败");
ErrorCode API_STEP_EXECUTION_ERROR = new ErrorCode(1_010_000_026, "步骤执行出现异常"); ErrorCode API_STEP_EXECUTION_ERROR = new ErrorCode(1_010_000_026, "步骤执行出现异常");
ErrorCode API_STEP_UNSUPPORTED_TYPE = new ErrorCode(1_010_000_027, "不支持的步骤类型:{}"); ErrorCode API_STEP_UNSUPPORTED_TYPE = new ErrorCode(1_010_000_027, "不支持的步骤类型:{}");
ErrorCode API_STEP_START_EXECUTION_FAILED = new ErrorCode(1_010_000_033, "开始节点执行失败:{}");
ErrorCode API_STEP_END_EXECUTION_FAILED = new ErrorCode(1_010_000_034, "结束节点执行失败:{}");
ErrorCode API_SIGNATURE_MISSING_HEADERS = new ErrorCode(1_010_000_037, "签名验证缺少必要头信息");
ErrorCode API_SIGNATURE_INVALID_TIMESTAMP = new ErrorCode(1_010_000_038, "签名时间戳不合法");
ErrorCode API_SIGNATURE_EXPIRED = new ErrorCode(1_010_000_039, "签名时间戳已过期");
ErrorCode API_SIGNATURE_APP_NOT_FOUND = new ErrorCode(1_010_000_040, "签名应用不存在或已禁用");
ErrorCode API_SIGNATURE_INVALID = new ErrorCode(1_010_000_041, "签名校验失败");
ErrorCode API_SIGNATURE_NONCE_REPLAY = new ErrorCode(1_010_000_042, "签名随机串重复使用");
ErrorCode API_SIGNATURE_CONFIG_INVALID = new ErrorCode(1_010_000_043, "签名策略配置异常");
ErrorCode API_CREDENTIAL_NOT_FOUND = new ErrorCode(1_010_000_044, "应用凭证不存在或已删除");
ErrorCode API_CREDENTIAL_DUPLICATE_APP = new ErrorCode(1_010_000_045, "应用标识已存在");
} }

View File

@@ -108,16 +108,27 @@ zt:
web: web:
admin-ui: admin-ui:
url: http://dashboard.zt.iocoder.cn # Admin 管理后台 UI 的地址 url: http://dashboard.zt.iocoder.cn # Admin 管理后台 UI 的地址
security:
permit-all-urls:
- ${databus.api-portal.base-path:/admin-api/databus/api/portal}/**
- /admin-api/databus/api/portal/**
- /databus/api/portal/**
xss: xss:
enable: false enable: false
exclude-urls: # 如下两个 url仅仅是为了演示去掉配置也没关系 exclude-urls: # 如下两个 url仅仅是为了演示去掉配置也没关系
- ${spring.boot.admin.context-path}/** # 不处理 Spring Boot Admin 的请求 - ${spring.boot.admin.context-path}/** # 不处理 Spring Boot Admin 的请求
- ${management.endpoints.web.base-path}/** # 不处理 Actuator 的请求 - ${management.endpoints.web.base-path}/** # 不处理 Actuator 的请求
swagger: swagger:
title: 管理后台 title: 统一对外 API 网关
description: 提供管理员管理的所有功能 description: 提供统一对外 API 网关
version: ${zt.info.version} version: ${zt.info.version}
tenant: # 多租户相关配置项 tenant: # 多租户相关配置项
enable: true enable: true
ignore-urls:
- ${databus.api-portal.base-path:/admin-api/databus/api/portal}/**
- /admin-api/databus/api/portal/**
- /databus/api/portal/**
ignore-tables:
- databus_api_client_credential
debug: false debug: false

View File

@@ -0,0 +1,63 @@
package com.zt.plat.module.databus.controller.admin.gateway;
import com.zt.plat.module.databus.controller.admin.gateway.vo.ApiGatewayInvokeReqVO;
import com.zt.plat.module.databus.framework.integration.gateway.core.ApiGatewayExecutionService;
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiGatewayResponse;
import com.zt.plat.module.databus.service.gateway.ApiDefinitionService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(ApiGatewayController.class)
@AutoConfigureMockMvc(addFilters = false)
@TestPropertySource(properties = {
"spring.config.import=optional:",
"spring.cloud.nacos.config.enabled=false",
"spring.cloud.nacos.discovery.enabled=false"
})
class ApiGatewayControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ApiGatewayExecutionService executionService;
@MockBean
private ApiDefinitionService apiDefinitionService;
@Test
void invokeShouldReturnGatewayEnvelope() throws Exception {
ApiGatewayResponse response = ApiGatewayResponse.builder()
.code(200)
.message("OK")
.response(Map.of("code", 0))
.traceId("trace-123")
.build();
when(executionService.invokeForDebug(any(ApiGatewayInvokeReqVO.class)))
.thenReturn(ResponseEntity.ok(response));
mockMvc.perform(post("/databus/gateway/invoke")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"apiCode\":\"demo\",\"version\":\"v1\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.message").value("OK"))
.andExpect(jsonPath("$.response.code").value(0))
.andExpect(jsonPath("$.traceId").value("trace-123"));
}
}

View File

@@ -0,0 +1,105 @@
package com.zt.plat.module.databus.framework.integration.gateway.core;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zt.plat.module.databus.framework.integration.config.ApiGatewayProperties;
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiInvocationContext;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.web.servlet.HandlerMapping;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class ApiGatewayRequestMapperTest {
private ApiGatewayRequestMapper mapper;
@BeforeEach
void setUp() {
ApiGatewayProperties properties = new ApiGatewayProperties();
mapper = new ApiGatewayRequestMapper(new ObjectMapper(), properties);
}
@Test
void shouldUseUriTemplateVariablesWhenPresent() {
Map<String, Object> headers = new HashMap<>();
headers.put(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, Map.of("apiCode", "demo.api", "version", "v1"));
headers.put(org.springframework.integration.http.HttpHeaders.REQUEST_METHOD, "GET");
headers.put(org.springframework.integration.http.HttpHeaders.PREFIX + "requestUri", "/admin-api/databus/api/portal/demo.api/v1");
headers.put(org.springframework.integration.http.HttpHeaders.PREFIX + "requestHeaders", Map.of(org.springframework.http.HttpHeaders.CONTENT_TYPE, org.springframework.http.MediaType.APPLICATION_JSON_VALUE));
ApiInvocationContext context = mapper.map("", headers);
assertThat(context.getApiCode()).isEqualTo("demo.api");
assertThat(context.getApiVersion()).isEqualTo("v1");
assertThat(context.getRequestPath()).isEqualTo("/admin-api/databus/api/portal/demo.api/v1");
assertThat(context.getHttpMethod()).isEqualTo("GET");
}
@Test
void shouldInferFromAbsoluteRequestUrlWhenVariablesMissing() {
Map<String, Object> headers = new HashMap<>();
headers.put(org.springframework.integration.http.HttpHeaders.PREFIX + "requestUri", "http://localhost:48080/admin-api/databus/api/portal/system.auth.quick-login/v1?foo=bar");
headers.put(org.springframework.integration.http.HttpHeaders.PREFIX + "requestHeaders", Map.of(org.springframework.http.HttpHeaders.CONTENT_TYPE, org.springframework.http.MediaType.TEXT_PLAIN_VALUE));
ApiInvocationContext context = mapper.map("payload", headers);
assertThat(context.getApiCode()).isEqualTo("system.auth.quick-login");
assertThat(context.getApiVersion()).isEqualTo("v1");
assertThat(context.getRequestPath()).isEqualTo("/admin-api/databus/api/portal/system.auth.quick-login/v1");
}
@Test
void shouldFallbackToHeadersWhenAvailable() {
Map<String, Object> headers = new HashMap<>();
headers.put("apiCode", "override.api");
headers.put("version", "v3");
headers.put(org.springframework.integration.http.HttpHeaders.PREFIX + "requestUri", "/another/path");
headers.put(org.springframework.integration.http.HttpHeaders.PREFIX + "requestHeaders", Map.of(org.springframework.http.HttpHeaders.CONTENT_TYPE, org.springframework.http.MediaType.APPLICATION_JSON_VALUE));
ApiInvocationContext context = mapper.map("", headers);
assertThat(context.getApiCode()).isEqualTo("override.api");
assertThat(context.getApiVersion()).isEqualTo("v3");
}
@Test
void shouldNormalizeHeaderValuesAndSupportCaseInsensitiveLookup() {
Map<String, Object> headers = new HashMap<>();
headers.put(org.springframework.integration.http.HttpHeaders.PREFIX + "requestHeaders", Map.of("ZT-Auth-Token", List.of("token-123")));
ApiInvocationContext context = mapper.map("", headers);
assertThat(context.getRequestHeaders().get("ZT-Auth-Token")).isEqualTo("token-123");
assertThat(context.getRequestHeaders().get("zt-auth-token")).isEqualTo("token-123");
}
@Test
void shouldExtractQueryParamsFromHeaders() {
Map<String, Object> headers = new HashMap<>();
headers.put(org.springframework.integration.http.HttpHeaders.PREFIX + "requestUri", "/api/demo");
headers.put("http_requestParams", Map.of(
"single", new String[]{"value"},
"multi", List.of("a", "b")
));
ApiInvocationContext context = mapper.map("", headers);
assertThat(context.getRequestQueryParams()).containsEntry("single", "value");
assertThat(context.getRequestQueryParams().get("multi")).isEqualTo(List.of("a", "b"));
}
@Test
void shouldFallbackToQueryStringWhenHeaderMissing() {
Map<String, Object> headers = new HashMap<>();
headers.put(org.springframework.integration.http.HttpHeaders.PREFIX + "requestUri", "/api/demo?foo=bar&arr=1&arr=2");
ApiInvocationContext context = mapper.map("", headers);
assertThat(context.getRequestQueryParams()).containsEntry("foo", "bar");
assertThat(context.getRequestQueryParams().get("arr")).isEqualTo(List.of("1", "2"));
}
}

View File

@@ -0,0 +1,56 @@
package com.zt.plat.module.databus.framework.integration.gateway.policy;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zt.plat.module.databus.framework.integration.gateway.security.GatewayJwtResolver;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class DefaultAuthPolicyEvaluatorTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void shouldResolveTokenFromPrimaryHeader() {
Map<String, Object> headers = new HashMap<>();
headers.put(GatewayJwtResolver.HEADER_ZT_AUTH_TOKEN, List.of(" token-123 "));
String token = GatewayJwtResolver.resolveJwtToken(headers, Map.of(), objectMapper);
assertThat(token).isEqualTo("token-123");
}
@Test
void shouldFallbackToAuthorizationHeader() {
Map<String, Object> headers = new HashMap<>();
headers.put("Authorization", "Bearer token-456");
String token = GatewayJwtResolver.resolveJwtToken(headers, Map.of(), objectMapper);
assertThat(token).isEqualTo("token-456");
}
@Test
void shouldParseTokenFromStructuredPayload() {
Map<String, Object> headers = new HashMap<>();
headers.put("Authorization", List.of("", "{\"token\":\"abc-789\"}"));
String token = GatewayJwtResolver.resolveJwtToken(headers, Map.of(), objectMapper);
assertThat(token).isEqualTo("abc-789");
}
@Test
void shouldUseQueryParameterAsLastResort() {
Map<String, Object> headers = Map.of();
Map<String, Object> queryParams = Map.of("token", " token-999 ");
String token = GatewayJwtResolver.resolveJwtToken(headers, queryParams, objectMapper);
assertThat(token).isEqualTo("token-999");
}
}

View File

@@ -0,0 +1,159 @@
package com.zt.plat.module.databus.framework.integration.gateway.security;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiClientCredentialDO;
import com.zt.plat.module.databus.framework.integration.config.ApiGatewayProperties;
import com.zt.plat.module.databus.service.gateway.ApiClientCredentialService;
import com.zt.plat.framework.common.util.security.CryptoSignatureUtils;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.http.HttpStatus;
import java.time.Duration;
import java.util.Collections;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class GatewaySecurityFilterTest {
@Test
void shouldAllowRequestWhenIpPermitted() throws Exception {
ApiGatewayProperties properties = createProperties();
properties.getSecurity().setEnabled(false);
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
ApiClientCredentialService credentialService = mock(ApiClientCredentialService.class);
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, new ObjectMapper());
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-api/databus/api/portal/demo/v1");
request.setRemoteAddr("127.0.0.1");
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
filter.doFilter(request, response, chain);
assertThat(response.getStatus()).isEqualTo(200);
assertThat(chain.getRequest()).isNotNull();
}
@Test
void shouldRejectRequestWhenIpDenied() throws Exception {
ApiGatewayProperties properties = createProperties();
properties.setDeniedIps(Collections.singletonList("10.0.0.1"));
properties.getSecurity().setEnabled(false);
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
ApiClientCredentialService credentialService = mock(ApiClientCredentialService.class);
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, new ObjectMapper());
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-api/databus/api/portal/demo/v1");
request.setRemoteAddr("10.0.0.1");
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
filter.doFilter(request, response, chain);
assertThat(response.getStatus()).isEqualTo(403);
assertThat(chain.getRequest()).isNull();
}
@Test
void shouldValidateSecurityHeadersAndEncryptResponse() throws Exception {
ApiGatewayProperties properties = createProperties();
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(Boolean.TRUE);
ApiClientCredentialService credentialService = mock(ApiClientCredentialService.class);
ApiClientCredentialDO credential = new ApiClientCredentialDO();
credential.setAppId("demo-app");
credential.setSignatureType(null);
credential.setEncryptionKey(null);
credential.setEncryptionType(null);
when(credentialService.findActiveCredential("demo-app")).thenReturn(java.util.Optional.of(credential));
properties.getSecurity().setRequireBodyEncryption(false);
properties.getSecurity().setEncryptResponse(false);
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, new ObjectMapper());
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-api/databus/api/portal/demo/v1");
request.setRemoteAddr("127.0.0.1");
long timestamp = System.currentTimeMillis();
String nonce = UUID.randomUUID().toString().replaceAll("-", "");
String signature = "d41d8cd98f00b204e9800998ecf8427e";
request.addHeader(properties.getSecurity().getAppIdHeader(), "demo-app");
request.addHeader(properties.getSecurity().getTimestampHeader(), String.valueOf(timestamp));
request.addHeader(properties.getSecurity().getNonceHeader(), nonce);
request.addHeader(properties.getSecurity().getSignatureHeader(), signature);
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
filter.doFilter(request, response, chain);
assertThat(response.getStatus()).isEqualTo(200);
assertThat(chain.getRequest()).isNotNull();
}
@Test
void shouldEncryptErrorResponseWhenValidationFails() throws Exception {
ApiGatewayProperties properties = createProperties();
properties.getSecurity().setRequireBodyEncryption(false);
properties.getSecurity().setEncryptResponse(true);
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(Boolean.TRUE);
ApiClientCredentialService credentialService = mock(ApiClientCredentialService.class);
ApiClientCredentialDO credential = new ApiClientCredentialDO();
credential.setAppId("demo-app");
credential.setEncryptionKey("demo-secret-key");
credential.setEncryptionType(CryptoSignatureUtils.ENCRYPT_TYPE_AES);
when(credentialService.findActiveCredential("demo-app")).thenReturn(Optional.of(credential));
GatewaySecurityFilter filter = new GatewaySecurityFilter(properties, redisTemplate, credentialService, new ObjectMapper());
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-api/databus/api/portal/demo/v1");
request.setRemoteAddr("127.0.0.1");
long timestamp = System.currentTimeMillis();
String nonce = UUID.randomUUID().toString().replaceAll("-", "");
request.addHeader(properties.getSecurity().getAppIdHeader(), "demo-app");
request.addHeader(properties.getSecurity().getTimestampHeader(), String.valueOf(timestamp));
request.addHeader(properties.getSecurity().getNonceHeader(), nonce);
request.addHeader(properties.getSecurity().getSignatureHeader(), "invalid-signature");
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, new MockFilterChain());
assertThat(response.getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED.value());
String cipherText = response.getContentAsString();
assertThat(cipherText).isNotBlank();
assertThat(cipherText.trim()).doesNotStartWith("{");
String decrypted = CryptoSignatureUtils.decrypt(cipherText, credential.getEncryptionKey(), credential.getEncryptionType());
JsonNode node = new ObjectMapper().readTree(decrypted);
assertThat(node.get("code").asInt()).isEqualTo(HttpStatus.UNAUTHORIZED.value());
assertThat(node.get("message").asText()).isEqualTo("签名校验失败");
}
private ApiGatewayProperties createProperties() {
ApiGatewayProperties properties = new ApiGatewayProperties();
properties.setBasePath("/admin-api/databus/api/portal");
properties.setAllowedIps(Collections.singletonList("127.0.0.1"));
return properties;
}
}

View File

@@ -0,0 +1,100 @@
package com.zt.plat.module.databus.framework.integration.gateway.step.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.zt.plat.framework.common.exception.ServiceException;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiStepDO;
import com.zt.plat.module.databus.enums.gateway.ExpressionTypeEnum;
import com.zt.plat.module.databus.framework.integration.gateway.domain.ApiDefinitionAggregate;
import com.zt.plat.module.databus.framework.integration.gateway.domain.ApiStepDefinition;
import com.zt.plat.module.databus.framework.integration.gateway.expression.ExpressionEvaluatorRegistry;
import com.zt.plat.module.databus.framework.integration.gateway.expression.ExpressionExecutor;
import com.zt.plat.module.databus.framework.integration.gateway.expression.JsonataExpressionEvaluator;
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiInvocationContext;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.integration.core.GenericHandler;
import org.springframework.messaging.MessageHeaders;
import java.util.Collections;
import java.util.Map;
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_STEP_END_EXECUTION_FAILED;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class EndStepHandlerTest {
private EndStepHandler handler;
private ApiDefinitionAggregate aggregate;
private static MessageHeaders emptyHeaders() {
return new MessageHeaders(Collections.emptyMap());
}
private static ObjectMapper createObjectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return mapper;
}
@BeforeEach
void setUp() {
ExpressionEvaluatorRegistry registry = new ExpressionEvaluatorRegistry();
registry.register(ExpressionTypeEnum.JSON, new JsonataExpressionEvaluator(createObjectMapper()));
handler = new EndStepHandler(new ExpressionExecutor(registry));
aggregate = ApiDefinitionAggregate.builder().build();
}
@Test
void shouldApplyResponseMappingsToInvocationContext() {
ApiStepDO stepDO = new ApiStepDO();
stepDO.setId(303L);
stepDO.setType("END");
stepDO.setResponseMappingExpr("JSON::{\"responseBody\": {\"final\": $.raw}, \"responseStatus\": 201, \"responseMessage\": \"accepted\"}");
ApiStepDefinition stepDefinition = ApiStepDefinition.builder().step(stepDO).build();
ApiInvocationContext context = ApiInvocationContext.create();
context.setResponseBody(Map.of("raw", Map.of("value", 1)));
context.setResponseStatus(200);
context.setResponseMessage("ok");
GenericHandler<ApiInvocationContext> genericHandler = handler.build(aggregate, stepDefinition);
ApiInvocationContext result = (ApiInvocationContext) genericHandler.handle(context, emptyHeaders());
assertThat(result).isSameAs(context);
assertThat(result.getResponseBody()).isInstanceOf(Map.class);
@SuppressWarnings("unchecked")
Map<String, ?> responseBody = (Map<String, ?>) result.getResponseBody();
assertThat(responseBody).containsKey("final");
assertThat(result.getResponseStatus()).isEqualTo(201);
assertThat(result.getResponseMessage()).isEqualTo("accepted");
assertThat(result.getStepResults()).hasSize(1);
assertThat(result.getStepResults().get(0).isSuccess()).isTrue();
assertThat(result.getStepResults().get(0).getStepId()).isEqualTo(303L);
}
@Test
void shouldRecordFailureWhenExpressionInvalid() {
ApiStepDO stepDO = new ApiStepDO();
stepDO.setId(404L);
stepDO.setType("END");
stepDO.setResponseMappingExpr("JSON::{broken}");
ApiStepDefinition stepDefinition = ApiStepDefinition.builder().step(stepDO).build();
ApiInvocationContext context = ApiInvocationContext.create();
GenericHandler<ApiInvocationContext> genericHandler = handler.build(aggregate, stepDefinition);
assertThatThrownBy(() -> {
genericHandler.handle(context, emptyHeaders());
})
.isInstanceOf(ServiceException.class)
.extracting(ex -> ((ServiceException) ex).getCode())
.isEqualTo(API_STEP_END_EXECUTION_FAILED.getCode());
assertThat(context.getStepResults()).hasSize(1);
assertThat(context.getStepResults().get(0).isSuccess()).isFalse();
assertThat(context.getStepResults().get(0).getStepId()).isEqualTo(404L);
}
}

View File

@@ -0,0 +1,99 @@
package com.zt.plat.module.databus.framework.integration.gateway.step.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.zt.plat.framework.common.exception.ServiceException;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiStepDO;
import com.zt.plat.module.databus.enums.gateway.ExpressionTypeEnum;
import com.zt.plat.module.databus.framework.integration.gateway.domain.ApiDefinitionAggregate;
import com.zt.plat.module.databus.framework.integration.gateway.domain.ApiStepDefinition;
import com.zt.plat.module.databus.framework.integration.gateway.expression.ExpressionEvaluatorRegistry;
import com.zt.plat.module.databus.framework.integration.gateway.expression.ExpressionExecutor;
import com.zt.plat.module.databus.framework.integration.gateway.expression.JsonataExpressionEvaluator;
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiInvocationContext;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.integration.core.GenericHandler;
import org.springframework.messaging.MessageHeaders;
import java.util.Collections;
import java.util.Map;
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.API_STEP_START_EXECUTION_FAILED;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class StartStepHandlerTest {
private StartStepHandler handler;
private ApiDefinitionAggregate aggregate;
private static MessageHeaders emptyHeaders() {
return new MessageHeaders(Collections.emptyMap());
}
private static ObjectMapper createObjectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return mapper;
}
@BeforeEach
void setUp() {
ExpressionEvaluatorRegistry registry = new ExpressionEvaluatorRegistry();
registry.register(ExpressionTypeEnum.JSON, new JsonataExpressionEvaluator(createObjectMapper()));
handler = new StartStepHandler(new ExpressionExecutor(registry));
aggregate = ApiDefinitionAggregate.builder().build();
}
@Test
void shouldApplyRequestMappingsToInvocationContext() {
ApiStepDO stepDO = new ApiStepDO();
stepDO.setId(101L);
stepDO.setType("START");
stepDO.setRequestMappingExpr("JSON::{\"requestHeaders\": {\"x-trace\": \"trace-1\"}, \"body\": {\"wrapped\": $}, \"requestQuery\": {\"flag\": \"Y\"}} ");
ApiStepDefinition stepDefinition = ApiStepDefinition.builder().step(stepDO).build();
ApiInvocationContext context = ApiInvocationContext.create();
context.setRequestBody(Map.of("original", "value"));
context.getRequestHeaders().put("tenant-id", "42");
GenericHandler<ApiInvocationContext> genericHandler = handler.build(aggregate, stepDefinition);
ApiInvocationContext result = (ApiInvocationContext) genericHandler.handle(context, emptyHeaders());
assertThat(result).isSameAs(context);
assertThat(result.getRequestHeaders()).containsEntry("x-trace", "trace-1");
assertThat(result.getRequestQueryParams()).containsEntry("flag", "Y");
assertThat(result.getRequestBody()).isInstanceOf(Map.class);
@SuppressWarnings("unchecked")
Map<String, ?> mappedBody = (Map<String, ?>) result.getRequestBody();
assertThat(mappedBody).containsKey("wrapped");
assertThat(result.getStepResults()).hasSize(1);
assertThat(result.getStepResults().get(0).isSuccess()).isTrue();
assertThat(result.getStepResults().get(0).getStepId()).isEqualTo(101L);
}
@Test
void shouldRecordFailureWhenExpressionInvalid() {
ApiStepDO stepDO = new ApiStepDO();
stepDO.setId(202L);
stepDO.setType("START");
stepDO.setRequestMappingExpr("JSON::{invalid}");
ApiStepDefinition stepDefinition = ApiStepDefinition.builder().step(stepDO).build();
ApiInvocationContext context = ApiInvocationContext.create();
GenericHandler<ApiInvocationContext> genericHandler = handler.build(aggregate, stepDefinition);
assertThatThrownBy(() -> {
genericHandler.handle(context, emptyHeaders());
})
.isInstanceOf(ServiceException.class)
.extracting(ex -> ((ServiceException) ex).getCode())
.isEqualTo(API_STEP_START_EXECUTION_FAILED.getCode());
assertThat(context.getStepResults()).hasSize(1);
assertThat(context.getStepResults().get(0).isSuccess()).isFalse();
assertThat(context.getStepResults().get(0).getStepId()).isEqualTo(202L);
}
}

View File

@@ -8,12 +8,10 @@ import com.zt.plat.module.databus.controller.admin.gateway.vo.definition.ApiDefi
import com.zt.plat.module.databus.controller.admin.gateway.vo.definition.ApiDefinitionStepSaveReqVO; import com.zt.plat.module.databus.controller.admin.gateway.vo.definition.ApiDefinitionStepSaveReqVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.definition.ApiDefinitionTransformSaveReqVO; import com.zt.plat.module.databus.controller.admin.gateway.vo.definition.ApiDefinitionTransformSaveReqVO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiDefinitionDO; import com.zt.plat.module.databus.dal.dataobject.gateway.ApiDefinitionDO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiPolicyAuthDO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiPolicyRateLimitDO; import com.zt.plat.module.databus.dal.dataobject.gateway.ApiPolicyRateLimitDO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiStepDO; import com.zt.plat.module.databus.dal.dataobject.gateway.ApiStepDO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiTransformDO; import com.zt.plat.module.databus.dal.dataobject.gateway.ApiTransformDO;
import com.zt.plat.module.databus.dal.mysql.gateway.ApiDefinitionMapper; import com.zt.plat.module.databus.dal.mysql.gateway.ApiDefinitionMapper;
import com.zt.plat.module.databus.dal.mysql.gateway.ApiPolicyAuthMapper;
import com.zt.plat.module.databus.dal.mysql.gateway.ApiPolicyRateLimitMapper; import com.zt.plat.module.databus.dal.mysql.gateway.ApiPolicyRateLimitMapper;
import com.zt.plat.module.databus.dal.mysql.gateway.ApiStepMapper; import com.zt.plat.module.databus.dal.mysql.gateway.ApiStepMapper;
import com.zt.plat.module.databus.dal.mysql.gateway.ApiTransformMapper; import com.zt.plat.module.databus.dal.mysql.gateway.ApiTransformMapper;
@@ -60,8 +58,6 @@ class ApiDefinitionServiceImplTest extends BaseDbUnitTest {
@Resource @Resource
private ApiTransformMapper apiTransformMapper; private ApiTransformMapper apiTransformMapper;
@Resource @Resource
private ApiPolicyAuthMapper apiPolicyAuthMapper;
@Resource
private ApiPolicyRateLimitMapper apiPolicyRateLimitMapper; private ApiPolicyRateLimitMapper apiPolicyRateLimitMapper;
@MockBean @MockBean
@@ -84,9 +80,8 @@ class ApiDefinitionServiceImplTest extends BaseDbUnitTest {
@Test @Test
void testCreate_success() { void testCreate_success() {
TenantContextHolder.setTenantId(1L); TenantContextHolder.setTenantId(1L);
Long authId = insertAuthPolicy();
Long rateId = insertRateLimitPolicy(); Long rateId = insertRateLimitPolicy();
ApiDefinitionSaveReqVO reqVO = buildSaveReq(null, authId, rateId); ApiDefinitionSaveReqVO reqVO = buildSaveReq(null, rateId);
Long definitionId = apiDefinitionService.create(reqVO); Long definitionId = apiDefinitionService.create(reqVO);
ApiDefinitionDO definition = apiDefinitionMapper.selectById(definitionId); ApiDefinitionDO definition = apiDefinitionMapper.selectById(definitionId);
@@ -98,16 +93,18 @@ class ApiDefinitionServiceImplTest extends BaseDbUnitTest {
assertEquals(reqVO.getDescription(), definition.getDescription()); assertEquals(reqVO.getDescription(), definition.getDescription());
List<ApiStepDO> steps = apiStepMapper.selectByApiId(definitionId); List<ApiStepDO> steps = apiStepMapper.selectByApiId(definitionId);
assertEquals(1, steps.size()); assertEquals(3, steps.size());
ApiStepDO step = steps.get(0); assertEquals("START", steps.get(0).getType());
assertEquals(1, step.getStepOrder()); assertEquals(Integer.valueOf(1), steps.get(0).getStepOrder());
assertEquals("HTTP", step.getType()); assertEquals("HTTP", steps.get(1).getType());
assertEquals(Integer.valueOf(2), steps.get(1).getStepOrder());
assertEquals("END", steps.get(2).getType());
assertEquals(Integer.valueOf(3), steps.get(2).getStepOrder());
List<ApiTransformDO> apiLevelTransforms = apiTransformMapper.selectApiLevelTransforms(definitionId); List<ApiTransformDO> apiLevelTransforms = apiTransformMapper.selectApiLevelTransforms(definitionId);
assertEquals(1, apiLevelTransforms.size()); assertEquals(0, apiLevelTransforms.size());
assertEquals("REQUEST_PRE", apiLevelTransforms.get(0).getPhase());
List<ApiTransformDO> stepTransforms = apiTransformMapper.selectByStepId(step.getId()); List<ApiTransformDO> stepTransforms = apiTransformMapper.selectByStepId(steps.get(1).getId());
assertEquals(1, stepTransforms.size()); assertEquals(1, stepTransforms.size());
assertEquals("RESPONSE_PRE", stepTransforms.get(0).getPhase()); assertEquals("RESPONSE_PRE", stepTransforms.get(0).getPhase());
} }
@@ -121,11 +118,10 @@ class ApiDefinitionServiceImplTest extends BaseDbUnitTest {
definition.setApiCode("order.create"); definition.setApiCode("order.create");
definition.setVersion("v1"); definition.setVersion("v1");
definition.setHttpMethod("POST"); definition.setHttpMethod("POST");
definition.setUriPattern("/order/create");
definition.setStatus(ApiStatusEnum.ONLINE.getStatus()); definition.setStatus(ApiStatusEnum.ONLINE.getStatus());
apiDefinitionMapper.insert(definition); apiDefinitionMapper.insert(definition);
ApiDefinitionSaveReqVO reqVO = buildSaveReq(null, null, null); ApiDefinitionSaveReqVO reqVO = buildSaveReq(null, null);
ServiceException exception = assertThrows(ServiceException.class, () -> apiDefinitionService.create(reqVO)); ServiceException exception = assertThrows(ServiceException.class, () -> apiDefinitionService.create(reqVO));
assertEquals(GatewayServiceErrorCodeConstants.API_DEFINITION_DUPLICATE.getCode(), exception.getCode()); assertEquals(GatewayServiceErrorCodeConstants.API_DEFINITION_DUPLICATE.getCode(), exception.getCode());
@@ -134,7 +130,6 @@ class ApiDefinitionServiceImplTest extends BaseDbUnitTest {
@Test @Test
void testUpdate_replaceSteps() { void testUpdate_replaceSteps() {
TenantContextHolder.setTenantId(1L); TenantContextHolder.setTenantId(1L);
Long authId = insertAuthPolicy();
Long rateId = insertRateLimitPolicy(); Long rateId = insertRateLimitPolicy();
ApiDefinitionDO definition = new ApiDefinitionDO(); ApiDefinitionDO definition = new ApiDefinitionDO();
definition.setTenantId(1L); definition.setTenantId(1L);
@@ -142,7 +137,6 @@ class ApiDefinitionServiceImplTest extends BaseDbUnitTest {
definition.setApiCode("order.update"); definition.setApiCode("order.update");
definition.setVersion("v1"); definition.setVersion("v1");
definition.setHttpMethod("POST"); definition.setHttpMethod("POST");
definition.setUriPattern("/order/update");
definition.setStatus(ApiStatusEnum.ONLINE.getStatus()); definition.setStatus(ApiStatusEnum.ONLINE.getStatus());
apiDefinitionMapper.insert(definition); apiDefinitionMapper.insert(definition);
@@ -164,20 +158,21 @@ class ApiDefinitionServiceImplTest extends BaseDbUnitTest {
oldTransform.setDeleted(false); oldTransform.setDeleted(false);
apiTransformMapper.insert(oldTransform); apiTransformMapper.insert(oldTransform);
ApiDefinitionSaveReqVO reqVO = buildSaveReq(definition.getId(), authId, rateId); ApiDefinitionSaveReqVO reqVO = buildSaveReq(definition.getId(), rateId);
reqVO.setApiCode("order.update"); reqVO.setApiCode("order.update");
reqVO.setVersion("v2"); reqVO.setVersion("v2");
reqVO.getSteps().get(0).setStepOrder(2);
apiDefinitionService.update(reqVO); apiDefinitionService.update(reqVO);
List<ApiStepDO> steps = apiStepMapper.selectByApiId(definition.getId()); List<ApiStepDO> steps = apiStepMapper.selectByApiId(definition.getId());
assertEquals(1, steps.size()); assertEquals(3, steps.size());
assertEquals(2, steps.get(0).getStepOrder()); assertEquals("START", steps.get(0).getType());
assertEquals("HTTP", steps.get(1).getType());
assertEquals("END", steps.get(2).getType());
List<ApiTransformDO> transforms = apiTransformMapper.selectByApiId(definition.getId()); List<ApiTransformDO> transforms = apiTransformMapper.selectByApiId(definition.getId());
assertThat(transforms) assertThat(transforms)
.extracting(ApiTransformDO::getPhase) .extracting(ApiTransformDO::getPhase)
.containsExactlyInAnyOrder("REQUEST_PRE", "RESPONSE_PRE"); .containsExactlyInAnyOrder("RESPONSE_PRE");
} }
@Test @Test
@@ -189,7 +184,6 @@ class ApiDefinitionServiceImplTest extends BaseDbUnitTest {
definition.setApiCode("order.delete"); definition.setApiCode("order.delete");
definition.setVersion("v1"); definition.setVersion("v1");
definition.setHttpMethod("DELETE"); definition.setHttpMethod("DELETE");
definition.setUriPattern("/order/delete");
definition.setStatus(ApiStatusEnum.ONLINE.getStatus()); definition.setStatus(ApiStatusEnum.ONLINE.getStatus());
apiDefinitionMapper.insert(definition); apiDefinitionMapper.insert(definition);
@@ -219,26 +213,24 @@ class ApiDefinitionServiceImplTest extends BaseDbUnitTest {
assertThat(apiTransformMapper.selectByApiId(definition.getId())).isEmpty(); assertThat(apiTransformMapper.selectByApiId(definition.getId())).isEmpty();
} }
private ApiDefinitionSaveReqVO buildSaveReq(Long id, Long authId, Long rateId) { private ApiDefinitionSaveReqVO buildSaveReq(Long id, Long rateId) {
ApiDefinitionSaveReqVO reqVO = new ApiDefinitionSaveReqVO(); ApiDefinitionSaveReqVO reqVO = new ApiDefinitionSaveReqVO();
reqVO.setId(id); reqVO.setId(id);
reqVO.setApiCode("order.create"); reqVO.setApiCode("order.create");
reqVO.setVersion("v1"); reqVO.setVersion("v1");
reqVO.setHttpMethod("POST"); reqVO.setHttpMethod("POST");
reqVO.setUriPattern("/order/create");
reqVO.setStatus(ApiStatusEnum.ONLINE.getStatus()); reqVO.setStatus(ApiStatusEnum.ONLINE.getStatus());
reqVO.setDescription("create order"); reqVO.setDescription("create order");
reqVO.setAuthPolicyId(authId);
reqVO.setRateLimitId(rateId); reqVO.setRateLimitId(rateId);
ApiDefinitionTransformSaveReqVO apiTransform = new ApiDefinitionTransformSaveReqVO(); ApiDefinitionStepSaveReqVO start = new ApiDefinitionStepSaveReqVO();
apiTransform.setPhase("REQUEST_PRE"); start.setStepOrder(1);
apiTransform.setExpressionType("JSON"); start.setType("START");
apiTransform.setExpression("{}"); start.setRequestMappingExpr("JSON::{}");
reqVO.getApiLevelTransforms().add(apiTransform); reqVO.getSteps().add(start);
ApiDefinitionStepSaveReqVO step = new ApiDefinitionStepSaveReqVO(); ApiDefinitionStepSaveReqVO step = new ApiDefinitionStepSaveReqVO();
step.setStepOrder(1); step.setStepOrder(2);
step.setType("HTTP"); step.setType("HTTP");
step.setTargetEndpoint("https://api.example.com/order"); step.setTargetEndpoint("https://api.example.com/order");
@@ -247,20 +239,14 @@ class ApiDefinitionServiceImplTest extends BaseDbUnitTest {
stepTransform.setExpressionType("JSON"); stepTransform.setExpressionType("JSON");
stepTransform.setExpression("{}"); stepTransform.setExpression("{}");
step.getTransforms().add(stepTransform); step.getTransforms().add(stepTransform);
reqVO.getSteps().add(step); reqVO.getSteps().add(step);
return reqVO;
}
private Long insertAuthPolicy() { ApiDefinitionStepSaveReqVO end = new ApiDefinitionStepSaveReqVO();
ApiPolicyAuthDO policy = new ApiPolicyAuthDO(); end.setStepOrder(3);
policy.setName("auth"); end.setType("END");
policy.setType("BASIC"); end.setResponseMappingExpr("JSON::{}");
policy.setConfig("{}"); reqVO.getSteps().add(end);
policy.setTenantId(1L); return reqVO;
policy.setDeleted(false);
apiPolicyAuthMapper.insert(policy);
return policy.getId();
} }
private Long insertRateLimitPolicy() { private Long insertRateLimitPolicy() {

View File

@@ -1,7 +1,6 @@
DELETE FROM "databus_api_transform"; DELETE FROM "databus_api_transform";
DELETE FROM "databus_api_step"; DELETE FROM "databus_api_step";
DELETE FROM "databus_api_definition"; DELETE FROM "databus_api_definition";
DELETE FROM "databus_policy_auth";
DELETE FROM "databus_policy_rate_limit"; DELETE FROM "databus_policy_rate_limit";
DELETE FROM "databus_policy_audit"; DELETE FROM "databus_policy_audit";
DELETE FROM "databus_api_flow_publish"; DELETE FROM "databus_api_flow_publish";

View File

@@ -1,18 +1,14 @@
CREATE TABLE IF NOT EXISTS databus_api_definition ( CREATE TABLE IF NOT EXISTS databus_api_definition (
id BIGINT PRIMARY KEY, id BIGINT PRIMARY KEY,
api_code VARCHAR(255) NOT NULL, api_code VARCHAR(255) NOT NULL,
uri_pattern VARCHAR(512),
http_method VARCHAR(16), http_method VARCHAR(16),
version VARCHAR(64), version VARCHAR(64),
status INT, status INT,
description VARCHAR(1024), description VARCHAR(1024),
auth_policy_id BIGINT,
rate_limit_id BIGINT, rate_limit_id BIGINT,
audit_policy_id BIGINT, audit_policy_id BIGINT,
response_template CLOB, response_template CLOB,
cache_strategy VARCHAR(255),
updated_at TIMESTAMP, updated_at TIMESTAMP,
grey_released BOOLEAN,
tenant_id BIGINT, tenant_id BIGINT,
create_time TIMESTAMP, create_time TIMESTAMP,
update_time TIMESTAMP, update_time TIMESTAMP,
@@ -32,7 +28,6 @@ CREATE TABLE IF NOT EXISTS databus_api_step (
response_mapping_expr VARCHAR(1024), response_mapping_expr VARCHAR(1024),
transform_id BIGINT, transform_id BIGINT,
timeout BIGINT, timeout BIGINT,
retry_strategy VARCHAR(255),
fallback_strategy VARCHAR(255), fallback_strategy VARCHAR(255),
condition_expr VARCHAR(1024), condition_expr VARCHAR(1024),
stop_on_error BOOLEAN, stop_on_error BOOLEAN,
@@ -60,20 +55,6 @@ CREATE TABLE IF NOT EXISTS databus_api_transform (
deleted BOOLEAN deleted BOOLEAN
); );
CREATE TABLE IF NOT EXISTS databus_policy_auth (
id BIGINT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
type VARCHAR(64),
config CLOB,
description VARCHAR(512),
tenant_id BIGINT,
create_time TIMESTAMP,
update_time TIMESTAMP,
creator VARCHAR(64),
updater VARCHAR(64),
deleted BOOLEAN
);
CREATE TABLE IF NOT EXISTS databus_policy_rate_limit ( CREATE TABLE IF NOT EXISTS databus_policy_rate_limit (
id BIGINT PRIMARY KEY, id BIGINT PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,

View File

@@ -114,7 +114,7 @@ zt:
- ${spring.boot.admin.context-path}/** # 不处理 Spring Boot Admin 的请求 - ${spring.boot.admin.context-path}/** # 不处理 Spring Boot Admin 的请求
- ${management.endpoints.web.base-path}/** # 不处理 Actuator 的请求 - ${management.endpoints.web.base-path}/** # 不处理 Actuator 的请求
swagger: swagger:
title: 管理后台 title: html转pdf能力
description: 提供管理员管理的所有功能 description: 提供管理员管理的所有功能
version: ${zt.info.version} version: ${zt.info.version}
tenant: # 多租户相关配置项 tenant: # 多租户相关配置项

View File

@@ -173,8 +173,8 @@ zt:
topic: ${spring.application.name}-websocket # 消息发送的 Kafka Topic topic: ${spring.application.name}-websocket # 消息发送的 Kafka Topic
consumer-group: ${spring.application.name}-websocket-consumer # 消息发送的 Kafka Consumer Group consumer-group: ${spring.application.name}-websocket-consumer # 消息发送的 Kafka Consumer Group
swagger: swagger:
title: 管理后台 title: 基础设施
description: 提供管理员管理的所有功能 description: 提供基础设施功能
version: ${zt.info.version} version: ${zt.info.version}
codegen: codegen:
base-package: com.zt.plat base-package: com.zt.plat

View File

@@ -140,8 +140,8 @@ zt:
exclude-urls: # 如下 url仅仅是为了演示去掉配置也没关系 exclude-urls: # 如下 url仅仅是为了演示去掉配置也没关系
- ${management.endpoints.web.base-path}/** # 不处理 Actuator 的请求 - ${management.endpoints.web.base-path}/** # 不处理 Actuator 的请求
swagger: swagger:
title: 管理后台 title: IOT能力
description: 提供管理员管理的所有功能 description: 提供 IOT 能力
version: ${zt.info.version} version: ${zt.info.version}
tenant: # 多租户相关配置项 tenant: # 多租户相关配置项
enable: true enable: true

View File

@@ -134,8 +134,8 @@ zt:
permit-all_urls: permit-all_urls:
- /admin-api/mp/open/** # 微信公众号开放平台,微信回调接口,不需要登录 - /admin-api/mp/open/** # 微信公众号开放平台,微信回调接口,不需要登录
swagger: swagger:
title: 管理后台 title: 小程序
description: 提供管理员管理的所有功能 description: 提供小程序功能
version: ${zt.info.version} version: ${zt.info.version}
tenant: # 多租户相关配置项 tenant: # 多租户相关配置项
enable: true enable: true

View File

@@ -121,8 +121,8 @@ zt:
exclude-urls: # 如下 url仅仅是为了演示去掉配置也没关系 exclude-urls: # 如下 url仅仅是为了演示去掉配置也没关系
- ${management.endpoints.web.base-path}/** # 不处理 Actuator 的请求 - ${management.endpoints.web.base-path}/** # 不处理 Actuator 的请求
swagger: swagger:
title: 管理后台 title: 报表
description: 提供管理员管理的所有功能 description: 提供报表功能
version: ${zt.info.version} version: ${zt.info.version}
tenant: # 多租户相关配置项 tenant: # 多租户相关配置项
enable: true enable: true

View File

@@ -114,8 +114,8 @@ zt:
- ${spring.boot.admin.context-path}/** # 不处理 Spring Boot Admin 的请求 - ${spring.boot.admin.context-path}/** # 不处理 Spring Boot Admin 的请求
- ${management.endpoints.web.base-path}/** # 不处理 Actuator 的请求 - ${management.endpoints.web.base-path}/** # 不处理 Actuator 的请求
swagger: swagger:
title: 理后台 title: 规则引擎
description: 提供管理员管理的所有功能 description: 提供规则引擎功能
version: ${zt.info.version} version: ${zt.info.version}
tenant: # 多租户相关配置项 tenant: # 多租户相关配置项
enable: true enable: true

View File

@@ -55,6 +55,15 @@ public class DeptController {
return success(true); return success(true);
} }
@PostMapping("init-codes")
@Operation(summary = "初始化部门编码", description = "按照层级自动为全部部门重新生成编码")
@PreAuthorize("@ss.hasPermission('system:dept:init-code')")
@TenantIgnore
public CommonResult<Boolean> initializeDeptCodes() {
deptService.initializeDeptCodes();
return success(true);
}
@DeleteMapping("delete") @DeleteMapping("delete")
@Operation(summary = "删除部门") @Operation(summary = "删除部门")
@Parameter(name = "id", description = "编号", required = true, example = "1024") @Parameter(name = "id", description = "编号", required = true, example = "1024")

View File

@@ -19,7 +19,7 @@ public class DeptSaveReqVO {
@Schema(description = "部门编号", example = "1024") @Schema(description = "部门编号", example = "1024")
private Long id; private Long id;
@Schema(description = "部门编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "DEPT_001") @Schema(description = "部门编码", example = "ZT001001")
@Size(max = 50, message = "部门编码长度不能超过 50 个字符") @Size(max = 50, message = "部门编码长度不能超过 50 个字符")
private String code; private String code;

View File

@@ -2,6 +2,7 @@ package com.zt.plat.module.system.controller.admin.sync;
import com.zt.plat.framework.common.biz.system.oauth2.OAuth2TokenCommonApi; import com.zt.plat.framework.common.biz.system.oauth2.OAuth2TokenCommonApi;
import com.zt.plat.framework.common.biz.system.oauth2.dto.OAuth2AccessTokenCheckRespDTO; import com.zt.plat.framework.common.biz.system.oauth2.dto.OAuth2AccessTokenCheckRespDTO;
import com.zt.plat.framework.common.util.security.CryptoSignatureUtils;
import com.zt.plat.framework.security.core.LoginUser; import com.zt.plat.framework.security.core.LoginUser;
import com.zt.plat.framework.security.core.util.SecurityFrameworkUtils; import com.zt.plat.framework.security.core.util.SecurityFrameworkUtils;
import com.zt.plat.framework.tenant.core.aop.TenantIgnore; import com.zt.plat.framework.tenant.core.aop.TenantIgnore;
@@ -558,7 +559,7 @@ public class SyncController {
syncLogService.logDecryptResult(logId, bimRequestId, bodyJson, authUser, true); syncLogService.logDecryptResult(logId, bimRequestId, bodyJson, authUser, true);
// 签名验证 // 签名验证
boolean signatureValid = SyncVerifyUtil.verifySignature(map, "MD5"); boolean signatureValid = CryptoSignatureUtils.verifySignature(map, CryptoSignatureUtils.SIGNATURE_TYPE_MD5);
syncLogService.logSignatureVerifyResult(logId, signatureValid); syncLogService.logSignatureVerifyResult(logId, signatureValid);
if (!signatureValid) { if (!signatureValid) {
throw exception(SYNC_SIGNATURE_VERIFY_FAILED); throw exception(SYNC_SIGNATURE_VERIFY_FAILED);
@@ -608,8 +609,8 @@ public class SyncController {
String bodyJson; String bodyJson;
String jsonString = JSON.toJSONString(object); String jsonString = JSON.toJSONString(object);
try { try {
bodyJson = SyncVerifyUtil.encrypt(jsonString, encryptKey, "AES"); bodyJson = CryptoSignatureUtils.encrypt(jsonString, encryptKey, CryptoSignatureUtils.ENCRYPT_TYPE_AES);
} catch (Exception e) { } catch (IllegalArgumentException | IllegalStateException e) {
throw exception(SYNC_DECRYPT_TYPE); throw exception(SYNC_DECRYPT_TYPE);
} }
return bodyJson; return bodyJson;

View File

@@ -5,6 +5,7 @@ import com.zt.plat.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.zt.plat.module.system.controller.admin.dept.vo.dept.DeptListReqVO; import com.zt.plat.module.system.controller.admin.dept.vo.dept.DeptListReqVO;
import com.zt.plat.module.system.dal.dataobject.dept.DeptDO; import com.zt.plat.module.system.dal.dataobject.dept.DeptDO;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import java.util.Collection; import java.util.Collection;
@@ -88,6 +89,20 @@ public interface DeptMapper extends BaseMapperX<DeptDO> {
); );
} }
/**
* 查询指定父部门下编码最大的子部门
*
* @param parentId 父部门ID
* @return 编码最大的子部门
*/
default DeptDO selectLastChildByCode(Long parentId) {
return selectOne(new LambdaQueryWrapper<DeptDO>()
.eq(DeptDO::getParentId, parentId)
.isNotNull(DeptDO::getCode)
.orderByDesc(DeptDO::getCode)
.last("LIMIT 1"));
}
/** /**
* 根据部门编码查询部门 * 根据部门编码查询部门
* *

View File

@@ -152,4 +152,9 @@ public interface DeptService {
* @return 公司列表 * @return 公司列表
*/ */
List<DeptDO> getAllCompanyList(); List<DeptDO> getAllCompanyList();
/**
* 按照新的编码规则初始化全部部门编码
*/
void initializeDeptCodes();
} }

Some files were not shown because too many files have changed in this diff Show More