Merge pull request '发布已通过 TEST 回归的 2026-10-08 批次至灰度' (#24) from test into main
This commit was merged in pull request #24.
This commit is contained in:
@@ -25,7 +25,7 @@
|
||||
### 后端任务最小读取集
|
||||
|
||||
- 单服务 Controller/Service/Mapper 任务:本文件、目标服务 `AGENTS.md`、目标类及其直接调用点。
|
||||
- 跨服务 API/事件任务:在上述基础上增加直接调用方、被调用方和对应 Gateway/OpenAPI;不因业务关键词自动加载整个业务专题。
|
||||
- 跨服务 API/事件任务:在上述基础上增加直接调用方、被调用方及受影响的 OpenAPI/事件契约;公开路径、访问域或入口鉴权变化才增加 Gateway,内部调用和事件不经公网 Gateway。不因业务关键词自动加载整个业务专题。
|
||||
- 只有状态、权限、数据库或验收边界无法从上述入口确定时,才读取 `docs/业务快速索引.md`、专题 README 或完整契约。
|
||||
- 读取预算遵循工作区根指引:超过约 250 行或 8000 字符时先读职责、硬边界和验证章节,再检索任务命中段;不得仅因行数少而全文加载长业务表。聚合构建或架构任务才读取相关 `README.md`、`pom.xml` 和目录结构文档,已知单模块任务不增加概览文档。
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` |
|
||||
| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` |
|
||||
| Lastleg 内部模块化与依赖基线 | `lastleg-service/` |
|
||||
| 车辆、司机证照、车次、外接预订、车队报价、出车记录 | `lastleg-service/fleet/`;先读 `lastleg-service/AGENTS.md` 对应入口,客户排车关联再沿 `dispatch/fleet/` 实际调用 |
|
||||
| PDF 标签、审计、业务事件直投 | `print/`、`audit-service/`、`common-event-contract/` |
|
||||
|
||||
## 公共模块快速索引
|
||||
@@ -105,7 +106,7 @@
|
||||
|
||||
## 契约、数据库与验证入口
|
||||
|
||||
- 对外 Controller/API 新增、改名或删除时,同时核对调用端、区域 Gateway、OpenAPI 和权限;提交前运行 `node --test scripts/gateway-route-contract.test.mjs`,自动发现生产 Controller 并校验网关目标服务与内部路径隔离。失败不得提交或标记完成;网关配置变化必须列入部署清单,不以业务服务单测代替入口检查。
|
||||
- 对外 Controller/API 新增、改名或删除时,同时核对调用端、统一 Gateway、OpenAPI 和权限;提交前运行 `node --test scripts/gateway-route-contract.test.mjs`,自动发现生产 Controller 并校验网关目标服务与内部路径隔离。失败不得提交或标记完成;网关配置变化必须列入部署清单,不以业务服务单测代替入口检查。
|
||||
|
||||
- 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。
|
||||
- 修改 Java 方法、构造器、Controller、Client、DTO 或 Mapper 参数签名时,先用 `rg` 定向查找符号及旧参数形态,并同步更新 `src/main/`、`src/test/`、Mock、Fixture、契约测试和脚本中的全部调用点。新增可选参数也必须补齐旧测试调用,不能因生产调用已更新就视为完成。
|
||||
@@ -140,7 +141,7 @@
|
||||
|
||||
- 订单与客户:`../docs/订单与客户/README.md`、`../docs/订单与客户/订单与客户头程订单闭环设计.md`
|
||||
- 入库测量与计费:`../docs/入库测量与计费/README.md`;流程细节按 `../docs/头程到尾程/README.md` 指向的当前阶段文档读取,需求确认和联调报告仅在历史追溯或验收证据任务中读取
|
||||
- 国内货柜履约:`../docs/国内货柜履约/README.md`、`../docs/国内货柜履约/报关资料与船期价格接入梳理.md`;开发规划、开工门禁、需求确认和验收材料已归档,只有历史追溯或验收证据任务才读取
|
||||
- 国内货柜履约:`../docs/国内货柜履约/README.md`,报关规则变化再查 `../docs/国内货柜履约/报关资料与船期价格接入梳理.md`。规划、确认与验收材料按归档位置及文档角色判断,当前入口指定的有效规则仍按需核对,历史材料只用于追溯或证据任务
|
||||
- 海外仓业务:`../docs/海外仓业务/README.md`;统一入口 `gateway/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/`
|
||||
- 海外仓全流程:以正式 Controller、前端路由、权限、SQL 与跨区事件契约为范围依据;海外接柜、入仓、库存、库内服务、排车、出库、派送、一件代发、异常退回和客户回访进入 `lastleg-service/`,国内 SKU/箱唛及暂扣协同进入 `order/`、`firstleg/`、`common-event-contract/`
|
||||
- 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束
|
||||
|
||||
Vendored
+5
-2
@@ -161,7 +161,7 @@ pipeline {
|
||||
choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境')
|
||||
booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署')
|
||||
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试执行(仍编译测试源码)')
|
||||
string(name: 'IMAGE_REPOSITORY', defaultValue: 'registry.example.com/team/baiyue-firstleg', description: '共享镜像仓库地址')
|
||||
string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库地址 host[:port]/owner/image')
|
||||
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
|
||||
choice(name: 'DOWNSTREAM_JOB_PREFIX', choices: downstreamJobPrefixChoicesForJob(env.JOB_BASE_NAME), description: '由当前总控 Job 固定的单服务 Job 前缀')
|
||||
}
|
||||
@@ -190,9 +190,12 @@ pipeline {
|
||||
|
||||
|
||||
String requestedRepository = params.IMAGE_REPOSITORY?.trim()
|
||||
if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') {
|
||||
if (!requestedRepository) {
|
||||
error('必须填写真实镜像仓库地址')
|
||||
}
|
||||
if (requestedRepository.tokenize('/')[0] ==~ /(?i)(?:[a-z0-9-]+\.)*example\.(?:com|net|org)(?::[0-9]+)?/) {
|
||||
error('IMAGE_REPOSITORY 不能使用 example 示例域名,请填写已核实的镜像仓库地址')
|
||||
}
|
||||
if (requestedRepository.contains('://') || !requestedRepository.contains('/')) {
|
||||
error('IMAGE_REPOSITORY 格式错误:请填写 host[:port]/owner/image,不要包含 http:// 或 https://')
|
||||
}
|
||||
|
||||
+4
-1
@@ -49,7 +49,7 @@ pipeline {
|
||||
booleanParam(name: 'ALLOW_SAME_CONTRACT_ROLLBACK', defaultValue: false, description: '仅确认相同协议与Schema时允许回滚;架构切换保持关闭')
|
||||
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
|
||||
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
|
||||
string(name: 'IMAGE_REPOSITORY', defaultValue: 'registry.example.com/team/baiyue-firstleg', description: '共享镜像仓库基础路径,不包含服务名和标签')
|
||||
string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库基础路径,不包含服务名和标签')
|
||||
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
|
||||
}
|
||||
|
||||
@@ -101,6 +101,9 @@ pipeline {
|
||||
if (!imageRepository || imageRepository.contains('://')) {
|
||||
error('IMAGE_REPOSITORY 不能为空且不能包含协议前缀')
|
||||
}
|
||||
if (imageRepository.tokenize('/')[0] ==~ /(?i)(?:[a-z0-9-]+\.)*example\.(?:com|net|org)(?::[0-9]+)?/) {
|
||||
error('IMAGE_REPOSITORY 不能使用 example 示例域名,请填写已核实的镜像仓库地址')
|
||||
}
|
||||
if (!imageRepository.contains('/')) {
|
||||
error('IMAGE_REPOSITORY 必须使用 host[:port]/owner/image 格式')
|
||||
}
|
||||
|
||||
+1
@@ -20,6 +20,7 @@ public class JwksJwtDecoderAutoConfiguration {
|
||||
JwtDecoder jwksJwtDecoder(@Value("${baiyue.security.jwk-set-uri:${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json}}") String uri,
|
||||
@Value("${baiyue.security.issuer:auth-service}") String issuer,
|
||||
@Value("${baiyue.security.audience:lastleg-api}") String audience) {
|
||||
// Fetch and cache JWKS on token verification, so startup does not depend on Auth availability.
|
||||
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(uri)
|
||||
.jwsAlgorithm(SignatureAlgorithm.RS256).build();
|
||||
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import com.nimbusds.jose.JWSAlgorithm;
|
||||
import com.nimbusds.jose.JWSHeader;
|
||||
import com.nimbusds.jose.crypto.RSASSASigner;
|
||||
import com.nimbusds.jose.jwk.JWKSet;
|
||||
import com.nimbusds.jose.jwk.RSAKey;
|
||||
import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
|
||||
import com.nimbusds.jwt.JWTClaimsSet;
|
||||
import com.nimbusds.jwt.SignedJWT;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.oauth2.jwt.JwtException;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.ServerSocket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class JwksJwtDecoderAutoConfigurationTest {
|
||||
@Test
|
||||
void createsDecoderWhenAuthIsUnavailableButRejectsTokenVerification() throws Exception {
|
||||
int port;
|
||||
try (var socket = new ServerSocket(0)) {
|
||||
port = socket.getLocalPort();
|
||||
}
|
||||
var decoder = new JwksJwtDecoderAutoConfiguration().jwksJwtDecoder(
|
||||
"http://127.0.0.1:" + port + "/jwks", "auth-service", "lastleg-api");
|
||||
var key = new RSAKeyGenerator(2048).keyID("current").generate();
|
||||
String token = token(key, "auth-service", "lastleg-api", 120, JWSAlgorithm.RS256);
|
||||
assertThatThrownBy(() -> decoder.decode(token)).isInstanceOf(JwtException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchesOnFirstVerificationCachesKeysAndRefreshesForRotation() throws Exception {
|
||||
var current = new RSAKeyGenerator(2048).keyID("current").generate();
|
||||
var next = new RSAKeyGenerator(2048).keyID("next").generate();
|
||||
var published = new AtomicReference<>(new JWKSet(current.toPublicJWK()));
|
||||
var requests = new AtomicInteger();
|
||||
var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/jwks", exchange -> {
|
||||
requests.incrementAndGet();
|
||||
byte[] body = published.get().toString().getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type", "application/json");
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
try (var output = exchange.getResponseBody()) {
|
||||
output.write(body);
|
||||
}
|
||||
});
|
||||
server.start();
|
||||
try {
|
||||
var decoder = new JwksJwtDecoderAutoConfiguration().jwksJwtDecoder(
|
||||
"http://127.0.0.1:" + server.getAddress().getPort() + "/jwks",
|
||||
"auth-service", "lastleg-api");
|
||||
assertThat(requests.get()).isZero();
|
||||
String valid = token(current, "auth-service", "lastleg-api", 120, JWSAlgorithm.RS256);
|
||||
assertThat(decoder.decode(valid).getSubject()).isEqualTo("42");
|
||||
assertThatCode(() -> decoder.decode(valid)).doesNotThrowAnyException();
|
||||
assertThat(requests.get()).isEqualTo(1);
|
||||
|
||||
for (String invalid : new String[]{
|
||||
token(current, "other", "lastleg-api", 120, JWSAlgorithm.RS256),
|
||||
token(current, "auth-service", "other", 120, JWSAlgorithm.RS256),
|
||||
token(current, "auth-service", "lastleg-api", -120, JWSAlgorithm.RS256),
|
||||
token(current, "auth-service", "lastleg-api", 120, JWSAlgorithm.RS512)}) {
|
||||
assertThatThrownBy(() -> decoder.decode(invalid)).isInstanceOf(JwtException.class);
|
||||
}
|
||||
|
||||
published.set(new JWKSet(next.toPublicJWK()));
|
||||
assertThat(decoder.decode(token(next, "auth-service", "lastleg-api", 120,
|
||||
JWSAlgorithm.RS256)).getSubject()).isEqualTo("42");
|
||||
assertThat(requests.get()).isEqualTo(2);
|
||||
} finally {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
private String token(RSAKey key, String issuer, String audience, int ttl, JWSAlgorithm algorithm)
|
||||
throws Exception {
|
||||
var jwt = new SignedJWT(new JWSHeader.Builder(algorithm).keyID(key.getKeyID()).build(),
|
||||
new JWTClaimsSet.Builder().subject("42").issuer(issuer).audience(audience)
|
||||
.issueTime(Date.from(Instant.now().minusSeconds(300)))
|
||||
.expirationTime(Date.from(Instant.now().plusSeconds(ttl))).build());
|
||||
jwt.sign(new RSASSASigner(key));
|
||||
return jwt.serialize();
|
||||
}
|
||||
}
|
||||
@@ -123,4 +123,76 @@ baiyue:
|
||||
max-rows: 200000
|
||||
max-attempts: 3
|
||||
batch-size: 500
|
||||
LAST_MILE_ORDER_LIST:
|
||||
provider-service: lastleg-service
|
||||
provider-path: /internal/exports/v1/LAST_MILE_ORDER_LIST/batches
|
||||
required-permission: platform:overseas-warehouse:last-mile-order:export
|
||||
app-scope: PLATFORM
|
||||
enabled: true
|
||||
max-rows: 200000
|
||||
max-attempts: 3
|
||||
batch-size: 200
|
||||
OVERSEAS_INVENTORY_BALANCE:
|
||||
provider-service: lastleg-service
|
||||
provider-path: /internal/exports/v1/OVERSEAS_INVENTORY_BALANCE/batches
|
||||
required-permission: platform:overseas-warehouse:inventory:export
|
||||
app-scope: PLATFORM
|
||||
enabled: true
|
||||
max-rows: 200000
|
||||
max-attempts: 3
|
||||
batch-size: 200
|
||||
OVERSEAS_INVENTORY_LEDGER:
|
||||
provider-service: lastleg-service
|
||||
provider-path: /internal/exports/v1/OVERSEAS_INVENTORY_LEDGER/batches
|
||||
required-permission: platform:overseas-warehouse:inventory:export
|
||||
app-scope: PLATFORM
|
||||
enabled: true
|
||||
max-rows: 200000
|
||||
max-attempts: 3
|
||||
batch-size: 200
|
||||
OVERSEAS_QUOTE_LIST:
|
||||
provider-service: lastleg-service
|
||||
provider-path: /internal/exports/v1/OVERSEAS_QUOTE_LIST/batches
|
||||
required-permission: platform:overseas-master-data:quote:export
|
||||
app-scope: PLATFORM
|
||||
enabled: true
|
||||
max-rows: 200000
|
||||
max-attempts: 3
|
||||
batch-size: 200
|
||||
MONTHLY_EXCHANGE_RATE_LIST:
|
||||
provider-service: lastleg-service
|
||||
provider-path: /internal/exports/v1/MONTHLY_EXCHANGE_RATE_LIST/batches
|
||||
required-permission: platform:overseas-master-data:exchange-rate:export
|
||||
app-scope: PLATFORM
|
||||
enabled: true
|
||||
max-rows: 200000
|
||||
max-attempts: 3
|
||||
batch-size: 200
|
||||
VEHICLE_LIST:
|
||||
provider-service: lastleg-service
|
||||
provider-path: /internal/exports/v1/VEHICLE_LIST/batches
|
||||
required-permission: platform:overseas-master-data:vehicle:export
|
||||
app-scope: PLATFORM
|
||||
enabled: true
|
||||
max-rows: 200000
|
||||
max-attempts: 3
|
||||
batch-size: 200
|
||||
FLEET_QUOTE_LIST:
|
||||
provider-service: lastleg-service
|
||||
provider-path: /internal/exports/v1/FLEET_QUOTE_LIST/batches
|
||||
required-permission: platform:fleet:quote:export
|
||||
app-scope: PLATFORM
|
||||
enabled: true
|
||||
max-rows: 200000
|
||||
max-attempts: 3
|
||||
batch-size: 200
|
||||
TRIP_OPERATIONS_LIST:
|
||||
provider-service: lastleg-service
|
||||
provider-path: /internal/exports/v1/TRIP_OPERATIONS_LIST/batches
|
||||
required-permission: platform:fleet:operations:export
|
||||
app-scope: PLATFORM
|
||||
enabled: true
|
||||
max-rows: 200000
|
||||
max-attempts: 3
|
||||
batch-size: 200
|
||||
baiyue.clients.finance.url: ${FINANCE_SERVICE_URL:}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
| 区域 | 完整入口 | 服务 schema | 表数 |
|
||||
|---|---|---|---:|
|
||||
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 |
|
||||
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 157 |
|
||||
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 159 |
|
||||
|
||||
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
|
||||
- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。
|
||||
|
||||
@@ -6327,7 +6327,7 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id,
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107001,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:edit','编辑海外尾程订单',NULL,'PUT',701,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107002,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:export','导出海外尾程订单',NULL,'GET',702,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107003,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve','审核仓储转运单',NULL,'POST',703,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107004,0,'PLATFORM','BUTTON',5107000,'lastleg:cargo-discard:approve','审核货物废弃申请',NULL,'POST',704,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107004,0,'PLATFORM','BUTTON',5107130,'platform:overseas-warehouse:discard:approve','审核货物废弃申请',NULL,'POST',831,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107004,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107005,0,'PLATFORM','BUTTON',5107000,'lastleg:warehouse-transfer:manage','执行仓储转运',NULL,'POST',705,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107005,0,CURRENT_TIMESTAMP(3));
|
||||
@@ -6383,8 +6383,10 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id,
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107121,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:create','创建本土散板单',NULL,'POST',821,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107122,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:edit','编辑本土散板单',NULL,'PUT',822,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107123,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:status','变更本土散板状态',NULL,'PATCH',823,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107130,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:discard:list','废弃单','/overseas-warehouse/cargo-discards','GET',830,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107190,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:return-inventory:list','退货库存管理','/overseas-warehouse/return-inventory','GET',890,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107191,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:return-cartons:list','退货箱号管理','/overseas-warehouse/return-cartons','GET',891,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107044,0,'PLATFORM','BUTTON',5107040,'lastleg:inventory:manage','后台退货库存管理',NULL,'POST',744,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107200,0,'PLATFORM','MENU',0,'platform:overseas-master-data:quote:list','海外仓报价','/overseas-master-data/quotes','GET',900,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107201,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:create','创建海外仓报价',NULL,'POST',901,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107202,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:edit','编辑海外仓报价',NULL,'PUT',902,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
@@ -6755,8 +6757,10 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at)
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107121,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107122,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107123,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107130,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107190,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107191,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107044,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107200,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107201,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107202,0,CURRENT_TIMESTAMP(3));
|
||||
|
||||
@@ -13,6 +13,7 @@ USE baiyue_lastleg;
|
||||
CREATE TABLE ll_cargo_discard_application (
|
||||
id bigint NOT NULL COMMENT '主键ID',
|
||||
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
|
||||
customer_confirmed_at datetime(3) DEFAULT NULL COMMENT '客户确认完成时间',
|
||||
discard_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '废弃申请单号',
|
||||
overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID',
|
||||
overseas_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓名称快照',
|
||||
@@ -68,6 +69,7 @@ CREATE TABLE ll_cargo_discard_item (
|
||||
product_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '产品名称',
|
||||
available_packages_snapshot int NOT NULL COMMENT '可用箱数快照',
|
||||
discard_packages int NOT NULL COMMENT '申请废弃箱数',
|
||||
completion_photo_file_ids text COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA完工照片文件ID JSON数组',
|
||||
actual_discard_packages int DEFAULT NULL COMMENT 'PDA确认实际废弃箱数',
|
||||
pda_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT 'PDA处理状态:待处理PENDING、已完成COMPLETED',
|
||||
pda_operator_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA操作人',
|
||||
@@ -377,7 +379,6 @@ CREATE TABLE ll_dispatch_item (
|
||||
source_business_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源业务单号',
|
||||
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源',
|
||||
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id),
|
||||
KEY idx_ll_dispatch_item_order (customer_id,order_id),
|
||||
@@ -436,7 +437,6 @@ CREATE TABLE ll_dispatch_pallet_lock (
|
||||
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源',
|
||||
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_dispatch_active_pallet (customer_id,active_key),
|
||||
KEY idx_ll_dispatch_pallet_lock_order (customer_id,dispatch_order_id,status),
|
||||
@@ -463,12 +463,13 @@ CREATE TABLE ll_dispatch_pda_pick (
|
||||
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
|
||||
task_id bigint NOT NULL COMMENT 'PDA任务ID',
|
||||
dispatch_item_id bigint NOT NULL COMMENT '派车明细ID',
|
||||
inventory_position_id bigint NOT NULL COMMENT '库存仓位记录ID',
|
||||
inventory_position_id BIGINT NULL COMMENT '普通库存仓位记录ID',
|
||||
packages bigint NOT NULL COMMENT '本次操作箱数',
|
||||
product_quantity bigint NOT NULL DEFAULT '0' COMMENT '产品数量',
|
||||
idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键',
|
||||
picked_at datetime(3) NOT NULL COMMENT '拣货时间',
|
||||
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
return_carton_id BIGINT NULL COMMENT '独立退货库存箱ID,与普通仓位互斥',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_dispatch_pda_pick_key (customer_id,idempotency_key),
|
||||
KEY idx_ll_dispatch_pda_pick_item (customer_id,task_id,dispatch_item_id)
|
||||
@@ -508,10 +509,9 @@ CREATE TABLE ll_dispatch_pda_pick_order (
|
||||
product_quantity bigint NOT NULL DEFAULT '0' COMMENT '产品数量',
|
||||
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源',
|
||||
return_carton_id BIGINT NULL COMMENT '原生退货箱来源ID,与实收来源互斥',
|
||||
KEY idx_dispatch_pick_return_receipt(customer_id,return_receipt_id),
|
||||
CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL AND return_receipt_id IS NULL) OR (receipt_id IS NULL AND return_receipt_id IS NOT NULL)),
|
||||
|
||||
|
||||
CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL) + (return_receipt_id IS NOT NULL) + (return_carton_id IS NOT NULL) = 1),
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_ll_dispatch_pick_order_pick (customer_id,pick_id),
|
||||
KEY idx_ll_dispatch_pick_order_receipt (customer_id,receipt_id)
|
||||
@@ -2373,10 +2373,13 @@ CREATE TABLE ll_return_inventory_carton (
|
||||
merged_at datetime(3) DEFAULT NULL COMMENT '合箱完成时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_return_inventory_carton_mark (carton_mark),
|
||||
UNIQUE KEY uk_ll_return_inventory_carton_receipt (source_receipt_id),
|
||||
KEY idx_ll_return_inventory_carton_customer (customer_id,overseas_warehouse_id,status,created_at),
|
||||
CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((((status in (_utf8mb4'AVAILABLE',_utf8mb4'RELABEL_PENDING',_utf8mb4'OUTBOUNDED',_utf8mb4'DISCARD_PENDING')) and (sku_types > 0) and (total_quantity > 0)) or ((status in (_utf8mb4'MERGED',_utf8mb4'DISCARDED')) and (sku_types = 0) and (total_quantity = 0)))),
|
||||
CONSTRAINT chk_ll_return_inventory_carton_status CHECK ((status in (_utf8mb4'AVAILABLE',_utf8mb4'RELABEL_PENDING',_utf8mb4'MERGED',_utf8mb4'OUTBOUNDED',_utf8mb4'DISCARD_PENDING',_utf8mb4'DISCARDED')))
|
||||
CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','OUTBOUNDED','DISCARD_PENDING') AND sku_types>0 AND total_quantity>0) OR (status IN('MERGED','DISCARDED') AND sku_types=0 AND total_quantity=0)),
|
||||
CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED')),
|
||||
source_unit_code VARCHAR(128) NULL COMMENT '来源单元业务码',
|
||||
location_code VARCHAR(128) NULL COMMENT '退货箱当前库位',
|
||||
putaway_at DATETIME(3) NULL COMMENT '退货箱上架时间',
|
||||
source_unit_type VARCHAR(32) NULL COMMENT 'PALLET或BOX实物箱 PARCEL散SKU'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货实物库存箱及箱唛';
|
||||
|
||||
CREATE TABLE ll_return_inventory_carton_item (
|
||||
@@ -2389,7 +2392,8 @@ CREATE TABLE ll_return_inventory_carton_item (
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_return_inventory_carton_item (carton_id,sku_code),
|
||||
KEY idx_ll_return_inventory_carton_item_customer (customer_id,sku_code),
|
||||
CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK ((quantity > 0))
|
||||
CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK ((quantity > 0)),
|
||||
packing_reserved_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '合箱计划预占件数'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存箱内SKU和数量';
|
||||
|
||||
CREATE TABLE ll_return_inventory_operation (
|
||||
@@ -2403,7 +2407,8 @@ CREATE TABLE ll_return_inventory_operation (
|
||||
updated_at datetime(3) NOT NULL COMMENT '最后更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_return_inventory_operation_operator_key (operator_id,idempotency_key),
|
||||
KEY idx_ll_return_inventory_operation_result (result_carton_id)
|
||||
KEY idx_ll_return_inventory_operation_result (result_carton_id),
|
||||
result_json JSON NULL COMMENT '幂等操作不可变返回快照'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存PDA合箱幂等操作审计';
|
||||
|
||||
CREATE TABLE ll_return_order (
|
||||
@@ -2479,7 +2484,14 @@ CREATE TABLE ll_return_receipt (
|
||||
UNIQUE KEY uk_ll_return_receipt_operator_key (operator_id,idempotency_key),
|
||||
KEY idx_ll_return_receipt_order (return_order_id,signed_at),
|
||||
CONSTRAINT chk_ll_return_receipt_bound CHECK ((bound_by in (_utf8mb4'RETURN_NO',_utf8mb4'SKU'))),
|
||||
CONSTRAINT chk_ll_return_receipt_quantity CHECK ((received_quantity > 0))
|
||||
CONSTRAINT chk_ll_return_receipt_quantity CHECK ((received_quantity > 0)),
|
||||
unit_type VARCHAR(16) NULL COMMENT '签收类型 PALLET/BOX/PARCEL',
|
||||
unit_code VARCHAR(128) NULL COMMENT '签收单元业务码',
|
||||
total_box_count BIGINT NULL COMMENT '签收SKU箱数汇总',
|
||||
length DECIMAL(12,3) NULL COMMENT '实测尺寸cm',
|
||||
width DECIMAL(12,3) NULL COMMENT '实测尺寸cm',
|
||||
height DECIMAL(12,3) NULL COMMENT '实测尺寸cm',
|
||||
weight DECIMAL(12,3) NULL COMMENT '实测重量kg'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA退货实收签收事实';
|
||||
|
||||
CREATE TABLE ll_return_receipt_batch (
|
||||
@@ -2508,7 +2520,8 @@ CREATE TABLE ll_return_receipt_item (
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_ll_return_receipt_item_receipt (receipt_id),
|
||||
KEY idx_ll_return_receipt_item_order_item (return_order_item_id),
|
||||
CONSTRAINT chk_ll_return_receipt_item_quantity CHECK ((received_quantity > 0))
|
||||
CONSTRAINT chk_ll_return_receipt_item_quantity CHECK ((received_quantity > 0)),
|
||||
box_count BIGINT NULL COMMENT 'SKU签收箱数'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA退货签收SKU实际数量明细';
|
||||
|
||||
|
||||
@@ -3350,6 +3363,36 @@ CREATE TABLE ll_warehouse_transfer_source_order (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转仓来源历史订单绑定';
|
||||
|
||||
-- 服务 schema:baiyue_lastlag_warehouse
|
||||
CREATE TABLE ll_return_packing_plan (
|
||||
id BIGINT NOT NULL COMMENT '合箱计划ID', plan_no VARCHAR(128) NOT NULL COMMENT '计划业务号',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID',
|
||||
return_nos_json JSON NOT NULL COMMENT '来源退货单范围', boxes_json JSON NOT NULL COMMENT '目标箱明细',
|
||||
mode VARCHAR(32) NOT NULL COMMENT 'SKU散件或BOX箱合并', status VARCHAR(32) NOT NULL COMMENT 'PENDING_REVIEW待审 READY已推送 COMPLETED完成 CANCELLED取消',
|
||||
remark VARCHAR(1000) NULL COMMENT '合箱备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
|
||||
pushed_at DATETIME(3) NULL COMMENT '审核推送时间', completed_at DATETIME(3) NULL COMMENT '作业完成时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_packing_plan_no(plan_no),
|
||||
UNIQUE KEY uk_ll_return_packing_plan_key(customer_id,idempotency_key),
|
||||
KEY idx_ll_return_packing_plan_warehouse(overseas_warehouse_id,status,pushed_at),
|
||||
source_items_json JSON NULL COMMENT '精确来源箱和SKU锁定数量',
|
||||
result_marks_json JSON NULL COMMENT '完成生成或并入的目标箱唛',
|
||||
request_hash VARCHAR(64) NULL COMMENT '创建载荷摘要',
|
||||
completion_hash VARCHAR(64) NULL COMMENT '完成载荷摘要',
|
||||
result_labels_json JSON NULL COMMENT '计划完工标签快照'
|
||||
) ENGINE=InnoDB COMMENT='退货合箱计划';
|
||||
|
||||
CREATE TABLE ll_return_packing_trace (
|
||||
id BIGINT NOT NULL COMMENT '合箱追溯ID', customer_id VARCHAR(128) NOT NULL COMMENT '原客户隔离键',
|
||||
operation_id BIGINT NOT NULL COMMENT '合箱操作ID', source_carton_mark VARCHAR(128) NOT NULL COMMENT '来源箱唛 空值表示目标箱原有库存快照',
|
||||
target_carton_mark VARCHAR(128) NOT NULL COMMENT '目标箱唛', return_no VARCHAR(128) NOT NULL COMMENT '原始退货单号',
|
||||
sku_code VARCHAR(128) NOT NULL COMMENT '商品SKU编码', quantity BIGINT NOT NULL COMMENT '本次移动件数',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '发生时间', PRIMARY KEY(id),
|
||||
KEY idx_return_packing_trace_order(customer_id,return_no,created_at),
|
||||
KEY idx_return_packing_trace_target(customer_id,target_carton_mark,sku_code),
|
||||
KEY idx_return_packing_trace_source(customer_id,source_carton_mark,sku_code),
|
||||
CONSTRAINT chk_return_packing_trace_quantity CHECK(quantity>0)
|
||||
) ENGINE=InnoDB COMMENT='退货合箱逐SKU原始退货单不可变追溯';
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS baiyue_lastlag_warehouse DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
USE baiyue_lastlag_warehouse;
|
||||
|
||||
@@ -3540,4 +3583,4 @@ CREATE TABLE event_delivery_attempt (
|
||||
KEY idx_delivery_attempt_outcome (outcome,started_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计';
|
||||
|
||||
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 157 AS initialized_tables;
|
||||
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 159 AS initialized_tables;
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# Event Bridge Service 模块指引
|
||||
|
||||
> 本目录为退役源码,已退出根 POM 和部署映射。下文仅记录旧实现,不是现行规则。事件任务先按 `../../docs/统一认证与服务通信契约.md` 的逐事件表定位生产者/直接消费者与业务 Relay;不得恢复区域分派或重新加入构建。源码清理按 `../../docs/测试环境单机部署与配置契约.md` 核对引用及运行证据。
|
||||
|
||||
## 职责边界
|
||||
|
||||
- Mapper 只由 `EventBridgeApplication.java` 对 `com.baiyue.eventbridge` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
|
||||
- 负责跨区事件接收、Inbox 持久化、按目标区域向海外尾程或国内头程投递、失败重试、投递记录和人工补投。
|
||||
- 只承载可靠传输,不执行尾程建单逻辑,不查询头程或尾程业务表。
|
||||
- 事件结构和 Payload 哈希以 `common-event-contract` 为准。
|
||||
|
||||
## 快速索引
|
||||
|
||||
| 功能 | 入口 |
|
||||
|---|---|
|
||||
| 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` |
|
||||
| Inbox 接收与幂等 | `service/EventInboxService.java` |
|
||||
| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 的 `BUSINESS_FEE_SOURCE_CHANGED` 与 `WALLET_COMMAND_REQUESTED` 定向投 Finance,其余国内回投 Firstleg;海外业务事件及 `WALLET_COMMAND_COMPLETED` 投 Lastleg 通用入口,三类 IAM 安全事件按 eventType 投 Lastleg `/internal/lastleg/security/v1/events`,传输层不解析 payload |
|
||||
| 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` |
|
||||
| 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` |
|
||||
| 国内 Finance 事件客户端 | `service/FinanceEventClient.java`、`HttpFinanceEventClient.java`;费用入口 `/internal/finance/v1/business-fee-source-events`,资金指令入口 `/internal/finance/v1/wallet-command-events`,分别配置 `finance-consumer-url`、`finance-wallet-consumer-url`,不得误投 Firstleg |
|
||||
| 人工补投 | `service/EventReplayService.java`;只允许 `DEAD` 转回 `RETRY`,记录操作人与原因,并保留原 `eventId`、payload 与 `payloadHash` |
|
||||
| Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper |
|
||||
| 传输指标与 Inbox 采样 | `metrics/EventBridgeMetrics.java`、`EventBridgeInboxMetricsSampler.java`;SLO 与告警合同 `docs/development-observability-slo.md` |
|
||||
| 事件 Trace 与原始 HTTP 传播 | `trace/EventBridgeTraceScope.java`、`EventBridgeHttpTracePropagation.java`;契约与已知缺口 `docs/event-trace-propagation.md` |
|
||||
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
|
||||
| 数据库 | `src/main/resources/db/event-bridge.sql` |
|
||||
| 测试 | `src/test/java/com/baiyue/eventbridge/` |
|
||||
|
||||
## 可靠投递规则
|
||||
|
||||
- `RECEIVED` 和到期的 `RETRY` 都必须可被调度;只有尾程确认成功后才能标记 `PROCESSED`。
|
||||
- 每次投递保存 attempt、错误和时间;达到上限进入 `DEAD`,经授权人工补投后重新进入重试队列。
|
||||
- 领取任务必须有数据库租约/并发保护,避免多实例重复发送;尾程幂等仍是最终保障。
|
||||
- 内部接口使用 `X-Internal-Service-Token`,生产还应置于受信网络或 mTLS 后。
|
||||
- 指标标签只允许有限 `target_region`、`event_type`、`result`,未知区域/类型折叠为 `OTHER`,禁止 eventId、customerId、错误消息等高基数标签。Inbox 聚合按现有状态索引定时采样,Prometheus scrape 不得查询数据库;开发阶段 SLO/告警只代表代码合同,不代表监控平台已部署。
|
||||
- 投递线程只从 Inbox 已冻结 `trace_id` 建立 MDC;合法 32 位 trace ID 或规范 UUID 可贯通,非法历史值安全重建。每次 JDK HttpClient 投递生成 child span,并覆盖三类标准 Trace 头;不得把 correlationId、eventId、customerId、聚合 ID、JWT 或内部令牌写入 Trace 字段。
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- 本模块已退出聚合,不能从根使用 `mvn -pl event-bridge-service`;现行投递验证直接生产者/消费者及对应 Outbox/Inbox、ACK、重试和审计。
|
||||
- 重试相关修改至少覆盖 `RECEIVED`、`RETRY`、成功、失败、租约、最大次数和人工补投。
|
||||
@@ -1,67 +0,0 @@
|
||||
# Event Bridge 开发阶段可观测性与 SLO 合同
|
||||
|
||||
## 状态与边界
|
||||
|
||||
本文冻结代码侧指标、SLI/SLO 和告警表达式模板。它不表示任何环境已经部署、接入或启用了
|
||||
Prometheus、Alertmanager 或 Grafana,也不授权部署、访问环境或修改环境配置。
|
||||
|
||||
指标标签只允许 `target_region`、`event_type`、`result`:
|
||||
|
||||
- `target_region` 仅为 `CN`、`OVERSEAS`、`OTHER`。
|
||||
- `event_type` 仅为 `EventTypes` 已登记类型或 `OTHER`。
|
||||
- `result` 仅为代码中冻结的接收或传输结果枚举。
|
||||
- 严禁把 `eventId`、`customerId`、聚合 ID、错误消息、请求 URL 或操作人写入指标标签。
|
||||
|
||||
## 指标合同
|
||||
|
||||
| Prometheus 指标 | 含义 |
|
||||
|---|---|
|
||||
| `baiyue_event_bridge_receive_total` | 接收结果;`result=RECEIVED|DUPLICATE|CONFLICT` |
|
||||
| `baiyue_event_bridge_transport_total` | 传输结果;`result=DELIVERED|RETRY|DEAD|ACKNOWLEDGED_LEASE_LOST|FAILED_LEASE_LOST|MANUAL_REPLAY` |
|
||||
| `baiyue_event_bridge_delivery_latency_seconds_bucket` | 从 Event Bridge `received_at` 到下游首次成功确认;固定包含 60 秒和 900 秒 SLO 桶 |
|
||||
| `baiyue_event_bridge_inbox_backlog` | 当前 `RECEIVED`、`RETRY`、`PROCESSING` 数量 |
|
||||
| `baiyue_event_bridge_inbox_oldest_age_seconds` | 当前最老待处理事件自 `received_at` 起的年龄 |
|
||||
| `baiyue_event_bridge_inbox_dead` | 当前 `DEAD` 数量 |
|
||||
|
||||
`DELIVERED` 与 `ACKNOWLEDGED_LEASE_LOST` 都表示下游已经确认,因此均记录确认延迟;后者仍单独计数,
|
||||
不得冒充本地 Inbox 已成功更新。`RETRY`、`DEAD`、`FAILED_LEASE_LOST` 不记录成功延迟。
|
||||
|
||||
Inbox Gauge 由服务每 30 秒执行一次受控聚合查询并缓存;Prometheus scrape 只读取 Micrometer Gauge,
|
||||
不得触发数据库查询。查询仅处理 `RECEIVED`、`RETRY`、`PROCESSING`、`DEAD`,状态过滤以现有
|
||||
`idx_bridge_inbox_consume(status, next_retry_at, lease_until)` 为索引依据;本合同不新增或修改 SQL 结构。
|
||||
|
||||
## 开发阶段 SLI 与 SLO
|
||||
|
||||
SLI 从事件首次得到 `result=RECEIVED` 开始,到下游首次成功确认结束;`DUPLICATE` 和 `CONFLICT`
|
||||
不重复进入分母。滚动 30 天目标:
|
||||
|
||||
- 99% 的新接收跨区事件在 60 秒内得到下游确认。
|
||||
- 99.9% 的新接收跨区事件在 15 分钟内得到下游确认。
|
||||
|
||||
代码暴露固定延迟桶,目标查询模板如下;按 `target_region` 汇总,必要时可保留有限 `event_type` 分组:
|
||||
|
||||
```promql
|
||||
sum(rate(baiyue_event_bridge_delivery_latency_seconds_bucket{le="60.0"}[30d]))
|
||||
/
|
||||
sum(rate(baiyue_event_bridge_receive_total{result="RECEIVED"}[30d])) >= 0.99
|
||||
```
|
||||
|
||||
```promql
|
||||
sum(rate(baiyue_event_bridge_delivery_latency_seconds_bucket{le="900.0"}[30d]))
|
||||
/
|
||||
sum(rate(baiyue_event_bridge_receive_total{result="RECEIVED"}[30d])) >= 0.999
|
||||
```
|
||||
|
||||
## 告警合同
|
||||
|
||||
规则由实际监控平台按 30 秒或更短评估周期配置;以下只是表达式合同:
|
||||
|
||||
- 新增 DEAD:下一次评估立即告警,不设置持续等待。
|
||||
`sum(increase(baiyue_event_bridge_transport_total{result="DEAD"}[1m])) > 0`
|
||||
- 最老待处理年龄预警:任一有限标签序列超过 120 秒。
|
||||
`max(baiyue_event_bridge_inbox_oldest_age_seconds) > 120`
|
||||
- 最老待处理年龄严重:任一有限标签序列超过 900 秒。
|
||||
`max(baiyue_event_bridge_inbox_oldest_age_seconds) > 900`
|
||||
- 当前 DEAD 存量用于持续可见性:`sum(baiyue_event_bridge_inbox_dead) > 0`;它不替代“新增 DEAD”告警。
|
||||
|
||||
告警通知中可通过日志和业务查询再定位 event ID;禁止为了通知便利把高基数标识加入指标标签。
|
||||
@@ -1,16 +0,0 @@
|
||||
# Event Bridge Trace 传播基线
|
||||
|
||||
- Event Bridge 领取 Inbox 后,仅从事件信封已冻结的 `traceId` 建立投递线程 MDC。
|
||||
- 32 位非零十六进制 trace ID 直接沿用;历史规范 UUID 去除连字符后沿用。
|
||||
- 其他旧值不满足 W3C Trace ID 约束,投递时安全重建新 trace,但不覆盖或迁移原事件信封。
|
||||
- 每次 HTTP 投递生成新的 `X-Request-Id` 和 child span,并统一覆盖 `X-Trace-Id`、`traceparent`。
|
||||
- `correlationId`、`eventId`、customer ID、聚合 ID、JWT 和内部服务令牌不得写入 Trace 标准字段。
|
||||
- 投递日志在 MDC 和显式 `traceId` 字段中记录本次标准 trace;投递 attempt 仍通过 `event_id`
|
||||
关联 `bridge_inbox.trace_id`,本轮不增加数据库字段。
|
||||
|
||||
## 共享契约缺口
|
||||
|
||||
当前 `EventEnvelope.traceId` 仍是通用字符串契约,没有强制 32 位 W3C Trace ID;因此历史任意字符串不能
|
||||
无损映射为 W3C trace。若后续要求所有事件跨重试、跨语言绝对保持同一标准 trace,必须由
|
||||
`common-event-contract` 生产者、消费者和 Event Bridge 共同冻结字段校验后再收敛。本轮没有修改共享
|
||||
Payload、信封字段、哈希规则或可靠性状态机。
|
||||
@@ -1,23 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.baiyue.firstleg</groupId><artifactId>baiyue-firstleg-platform</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version><relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
<artifactId>event-bridge-service</artifactId>
|
||||
<name>event-bridge-service</name>
|
||||
<dependencies>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-web</artifactId><version>${project.version}</version></dependency>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>common-event-contract</artifactId><version>${project.version}</version></dependency>
|
||||
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId></dependency>
|
||||
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency>
|
||||
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
|
||||
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
||||
</dependencies>
|
||||
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
|
||||
</project>
|
||||
@@ -1,20 +0,0 @@
|
||||
package com.baiyue.eventbridge;
|
||||
|
||||
import com.baiyue.eventbridge.config.EventDispatcherProperties;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
|
||||
/**
|
||||
* 跨区域事件桥启动入口。
|
||||
*/
|
||||
@MapperScan(basePackages = "com.baiyue.eventbridge", annotationClass = Mapper.class)
|
||||
@SpringBootApplication
|
||||
@EnableConfigurationProperties(EventDispatcherProperties.class)
|
||||
public class EventBridgeApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(EventBridgeApplication.class, args);
|
||||
}
|
||||
}
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
package com.baiyue.eventbridge.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
|
||||
@ConfigurationProperties(prefix = "baiyue.event-dispatcher")
|
||||
public record EventDispatcherProperties(
|
||||
URI consumerUrl,
|
||||
int batchSize,
|
||||
int maxAttempts,
|
||||
Duration leaseDuration,
|
||||
Duration initialBackoff,
|
||||
Duration maxBackoff,
|
||||
Duration connectTimeout,
|
||||
Duration requestTimeout) {
|
||||
|
||||
public EventDispatcherProperties {
|
||||
consumerUrl = consumerUrl == null
|
||||
? URI.create("http://127.0.0.1:9303/internal/lastleg/events") : consumerUrl;
|
||||
batchSize = batchSize <= 0 ? 50 : batchSize;
|
||||
maxAttempts = maxAttempts <= 0 ? 10 : maxAttempts;
|
||||
leaseDuration = positiveOrDefault(leaseDuration, Duration.ofSeconds(30));
|
||||
initialBackoff = positiveOrDefault(initialBackoff, Duration.ofSeconds(5));
|
||||
maxBackoff = positiveOrDefault(maxBackoff, Duration.ofMinutes(15));
|
||||
connectTimeout = positiveOrDefault(connectTimeout, Duration.ofSeconds(3));
|
||||
requestTimeout = positiveOrDefault(requestTimeout, Duration.ofSeconds(10));
|
||||
}
|
||||
|
||||
private static Duration positiveOrDefault(Duration value, Duration fallback) {
|
||||
return value == null || value.isNegative() || value.isZero() ? fallback : value;
|
||||
}
|
||||
}
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
package com.baiyue.eventbridge.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.eventbridge.dto.BridgeStatus;
|
||||
import com.baiyue.eventbridge.dto.EventReceiveResult;
|
||||
import com.baiyue.eventbridge.dto.EventReplayRequest;
|
||||
import com.baiyue.eventbridge.service.EventInboxService;
|
||||
import com.baiyue.eventbridge.service.EventReplayService;
|
||||
import com.baiyue.eventbridge.security.InternalServiceTokenGuard;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
|
||||
/**
|
||||
* 事件桥内部接口。生产环境必须在网关或基础设施层增加 mTLS 与来源白名单。
|
||||
*/
|
||||
@Tag(name = "事件桥内部接口", description = "跨区域事件的幂等接收与能力状态")
|
||||
@RestController
|
||||
@RequestMapping("/internal/event-bridge")
|
||||
public class EventBridgeController {
|
||||
@Resource private EventInboxService eventInboxService;
|
||||
@Resource private EventReplayService eventReplayService;
|
||||
@Resource private InternalServiceTokenGuard internalServiceTokenGuard;
|
||||
|
||||
@Value("${baiyue.region:OVERSEAS}")
|
||||
private String region;
|
||||
|
||||
@Operation(summary = "幂等接收事件", description = "按当前部署区域校验目标区域、事件信封与 payloadHash,幂等持久化后进入租约保护的重试投递队列")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "事件已接收或判定为重复"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "内部服务令牌无效"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "503", description = "内部服务令牌未配置,接收接口关闭")
|
||||
})
|
||||
@PostMapping("/events")
|
||||
public ApiResponse<EventReceiveResult> receive(
|
||||
@Parameter(description = "内部服务共享令牌")
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestBody EventEnvelope envelope) {
|
||||
internalServiceTokenGuard.verify(token);
|
||||
return ApiResponse.success(eventInboxService.receive(envelope));
|
||||
}
|
||||
|
||||
@Operation(summary = "人工补投死信事件",
|
||||
description = "仅允许 DEAD 事件转回 RETRY;保留原 eventId、payload 与 payloadHash,必须记录操作人与补投原因")
|
||||
@PostMapping("/events/{eventId}/replay")
|
||||
public ApiResponse<Void> replay(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestHeader("X-Operator-Id") String operatorId,
|
||||
@PathVariable String eventId,
|
||||
@RequestBody EventReplayRequest request) {
|
||||
internalServiceTokenGuard.verify(token);
|
||||
eventReplayService.replayDead(eventId, operatorId, request.reason());
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@Operation(summary = "查询事件桥能力状态")
|
||||
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"))
|
||||
@GetMapping("/status")
|
||||
public ApiResponse<BridgeStatus> status() {
|
||||
return ApiResponse.success(new BridgeStatus("运行中", region, true, false));
|
||||
}
|
||||
}
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
package com.baiyue.eventbridge.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("bridge_delivery_attempt")
|
||||
public class BridgeDeliveryAttempt {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String eventId;
|
||||
private String sourceRegion;
|
||||
private String targetRegion;
|
||||
private Integer attemptNo;
|
||||
private String status;
|
||||
private String responseCode;
|
||||
private String errorMessage;
|
||||
private LocalDateTime startedAt;
|
||||
private LocalDateTime finishedAt;
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package com.baiyue.eventbridge.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 事件桥入站幂等记录。
|
||||
*/
|
||||
@Data
|
||||
@TableName("bridge_inbox")
|
||||
public class BridgeInbox {
|
||||
@TableId
|
||||
private String eventId;
|
||||
private String idempotencyKey;
|
||||
private String eventType;
|
||||
private Integer eventVersion;
|
||||
private String sourceRegion;
|
||||
private String targetRegion;
|
||||
private String customerId;
|
||||
private String aggregateType;
|
||||
private String aggregateId;
|
||||
private Long aggregateVersion;
|
||||
private Long handoverVersion;
|
||||
private String traceId;
|
||||
private String correlationId;
|
||||
private String causationId;
|
||||
private Instant occurredAt;
|
||||
private String payloadHash;
|
||||
private String payloadJson;
|
||||
private String status;
|
||||
private String leaseOwner;
|
||||
private LocalDateTime leaseUntil;
|
||||
private Integer retryCount;
|
||||
private LocalDateTime nextRetryAt;
|
||||
private String lastError;
|
||||
private LocalDateTime receivedAt;
|
||||
private LocalDateTime processedAt;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package com.baiyue.eventbridge.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
/**
|
||||
* 事件桥能力状态,不代表生产网络链路已经开通。
|
||||
*/
|
||||
@Schema(description = "事件桥能力状态")
|
||||
public record BridgeStatus(
|
||||
@Schema(description = "服务状态") String status,
|
||||
@Schema(description = "当前区域") String region,
|
||||
@Schema(description = "数据库幂等接收是否启用") boolean inboxEnabled,
|
||||
@Schema(description = "生产级跨区传输是否启用") boolean productionTransportEnabled) {
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package com.baiyue.eventbridge.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 事件接收结果。
|
||||
*/
|
||||
@Schema(description = "事件接收结果")
|
||||
public record EventReceiveResult(
|
||||
@Schema(description = "事件标识") String eventId,
|
||||
@Schema(description = "接收结果:已接收或重复") String result,
|
||||
@Schema(description = "接收时间") LocalDateTime receivedAt) {
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
package com.baiyue.eventbridge.dto;
|
||||
|
||||
public record EventReplayRequest(String reason) {
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
package com.baiyue.eventbridge.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface BridgeDeliveryAttemptMapper extends BaseMapper<BridgeDeliveryAttempt> {
|
||||
}
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
package com.baiyue.eventbridge.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
import com.baiyue.eventbridge.metrics.InboxMetricsSnapshot;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 入站事件持久化映射。
|
||||
*/
|
||||
@Mapper
|
||||
public interface BridgeInboxMapper extends BaseMapper<BridgeInbox> {
|
||||
@Select("""
|
||||
SELECT event_id FROM bridge_inbox
|
||||
WHERE ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3)))
|
||||
OR (status = 'PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)))
|
||||
ORDER BY received_at LIMIT #{limit}
|
||||
""")
|
||||
List<String> dispatchCandidates(@Param("limit") int limit);
|
||||
|
||||
@Update("""
|
||||
UPDATE bridge_inbox SET status='PROCESSING', lease_owner=#{owner},
|
||||
lease_until=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{leaseSeconds} SECOND)
|
||||
WHERE event_id=#{eventId}
|
||||
AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3)))
|
||||
OR (status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)))
|
||||
""")
|
||||
int claim(@Param("eventId") String eventId, @Param("owner") String owner,
|
||||
@Param("leaseSeconds") long leaseSeconds);
|
||||
|
||||
@Update("""
|
||||
UPDATE bridge_inbox SET status='PROCESSED', processed_at=CURRENT_TIMESTAMP(3),
|
||||
lease_owner=NULL, lease_until=NULL, next_retry_at=NULL, last_error=NULL
|
||||
WHERE event_id=#{eventId} AND status='PROCESSING' AND lease_owner=#{owner}
|
||||
""")
|
||||
int markProcessed(@Param("eventId") String eventId, @Param("owner") String owner);
|
||||
|
||||
@Update("""
|
||||
UPDATE bridge_inbox SET retry_count=retry_count+1,
|
||||
status=CASE WHEN retry_count+1 >= #{maxAttempts} THEN 'DEAD' ELSE 'RETRY' END,
|
||||
next_retry_at=CASE WHEN retry_count+1 >= #{maxAttempts} THEN NULL
|
||||
ELSE DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{backoffSeconds} SECOND) END,
|
||||
lease_owner=NULL, lease_until=NULL, last_error=#{error}
|
||||
WHERE event_id=#{eventId} AND status='PROCESSING' AND lease_owner=#{owner}
|
||||
""")
|
||||
int markFailed(@Param("eventId") String eventId, @Param("owner") String owner,
|
||||
@Param("maxAttempts") int maxAttempts,
|
||||
@Param("backoffSeconds") long backoffSeconds, @Param("error") String error);
|
||||
|
||||
@Select("SELECT COALESCE(MAX(attempt_no), 0) + 1 FROM bridge_delivery_attempt WHERE event_id=#{eventId}")
|
||||
int nextAttemptNo(@Param("eventId") String eventId);
|
||||
|
||||
@Update("""
|
||||
UPDATE bridge_inbox SET status='RETRY', retry_count=0, next_retry_at=CURRENT_TIMESTAMP(3),
|
||||
lease_owner=NULL, lease_until=NULL, last_error=#{reason}
|
||||
WHERE event_id=#{eventId} AND status='DEAD'
|
||||
""")
|
||||
int replayDead(@Param("eventId") String eventId, @Param("reason") String reason);
|
||||
|
||||
/**
|
||||
* Scheduled observability aggregation. The status predicate uses idx_bridge_inbox_consume;
|
||||
* Prometheus collection never invokes this query directly.
|
||||
*/
|
||||
@Select("""
|
||||
SELECT target_region AS targetRegion, event_type AS eventType,
|
||||
SUM(CASE WHEN status IN ('RECEIVED','RETRY','PROCESSING') THEN 1 ELSE 0 END) AS backlogCount,
|
||||
COALESCE(TIMESTAMPDIFF(MICROSECOND,
|
||||
MIN(CASE WHEN status IN ('RECEIVED','RETRY','PROCESSING') THEN received_at END),
|
||||
CURRENT_TIMESTAMP(3)) / 1000000.0, 0) AS oldestPendingAgeSeconds,
|
||||
SUM(CASE WHEN status='DEAD' THEN 1 ELSE 0 END) AS deadCount
|
||||
FROM bridge_inbox
|
||||
WHERE status IN ('RECEIVED','RETRY','PROCESSING','DEAD')
|
||||
GROUP BY target_region, event_type
|
||||
""")
|
||||
List<InboxMetricsSnapshot> observabilitySnapshots();
|
||||
}
|
||||
-103
@@ -1,103 +0,0 @@
|
||||
package com.baiyue.eventbridge.metrics;
|
||||
|
||||
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.MultiGauge;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Periodically samples indexed Inbox aggregates; Prometheus scrapes only the cached gauges. */
|
||||
@Component
|
||||
@Slf4j
|
||||
public class EventBridgeInboxMetricsSampler {
|
||||
public static final String BACKLOG_METRIC = "baiyue.event.bridge.inbox.backlog";
|
||||
public static final String OLDEST_AGE_METRIC = "baiyue.event.bridge.inbox.oldest.age.seconds";
|
||||
public static final String DEAD_METRIC = "baiyue.event.bridge.inbox.dead";
|
||||
|
||||
private final BridgeInboxMapper inboxMapper;
|
||||
private final MultiGauge backlog;
|
||||
private final MultiGauge oldestAge;
|
||||
private final MultiGauge dead;
|
||||
|
||||
public EventBridgeInboxMetricsSampler(BridgeInboxMapper inboxMapper, MeterRegistry registry) {
|
||||
this.inboxMapper = inboxMapper;
|
||||
this.backlog = MultiGauge.builder(BACKLOG_METRIC)
|
||||
.description("Events waiting for downstream acknowledgement").register(registry);
|
||||
this.oldestAge = MultiGauge.builder(OLDEST_AGE_METRIC)
|
||||
.description("Age in seconds of the oldest pending Inbox event").register(registry);
|
||||
this.dead = MultiGauge.builder(DEAD_METRIC)
|
||||
.description("Current number of DEAD Inbox events").register(registry);
|
||||
}
|
||||
|
||||
@Scheduled(initialDelayString = "${baiyue.event-bridge.metrics.initial-delay:5000}",
|
||||
fixedDelayString = "${baiyue.event-bridge.metrics.sample-delay:30000}")
|
||||
public void sample() {
|
||||
final List<InboxMetricsSnapshot> snapshots;
|
||||
try {
|
||||
snapshots = inboxMapper.observabilitySnapshots();
|
||||
} catch (RuntimeException exception) {
|
||||
log.warn("Event Bridge Inbox 指标采样失败,保留上次成功快照", exception);
|
||||
return;
|
||||
}
|
||||
Map<MetricKey, Aggregate> aggregates = aggregate(snapshots);
|
||||
backlog.register(rows(aggregates, MetricValue.BACKLOG), true);
|
||||
oldestAge.register(rows(aggregates, MetricValue.OLDEST_AGE), true);
|
||||
dead.register(rows(aggregates, MetricValue.DEAD), true);
|
||||
}
|
||||
|
||||
private Map<MetricKey, Aggregate> aggregate(List<InboxMetricsSnapshot> snapshots) {
|
||||
Map<MetricKey, Aggregate> result = new LinkedHashMap<>();
|
||||
if (snapshots == null) {
|
||||
return result;
|
||||
}
|
||||
for (InboxMetricsSnapshot snapshot : snapshots) {
|
||||
MetricKey key = new MetricKey(EventBridgeMetrics.targetRegion(snapshot.getTargetRegion()),
|
||||
EventBridgeMetrics.eventType(snapshot.getEventType()));
|
||||
result.computeIfAbsent(key, ignored -> new Aggregate()).add(snapshot);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<MultiGauge.Row<?>> rows(Map<MetricKey, Aggregate> aggregates, MetricValue value) {
|
||||
List<MultiGauge.Row<?>> rows = new ArrayList<>();
|
||||
aggregates.forEach((key, aggregate) -> rows.add(MultiGauge.Row.of(
|
||||
Tags.of("target_region", key.targetRegion, "event_type", key.eventType),
|
||||
value.value(aggregate))));
|
||||
return rows;
|
||||
}
|
||||
|
||||
private record MetricKey(String targetRegion, String eventType) { }
|
||||
|
||||
private static final class Aggregate {
|
||||
private long backlog;
|
||||
private double oldestAge;
|
||||
private long dead;
|
||||
|
||||
void add(InboxMetricsSnapshot snapshot) {
|
||||
backlog += snapshot.getBacklogCount();
|
||||
oldestAge = Math.max(oldestAge, snapshot.getOldestPendingAgeSeconds());
|
||||
dead += snapshot.getDeadCount();
|
||||
}
|
||||
}
|
||||
|
||||
private enum MetricValue {
|
||||
BACKLOG {
|
||||
@Override Number value(Aggregate aggregate) { return aggregate.backlog; }
|
||||
},
|
||||
OLDEST_AGE {
|
||||
@Override Number value(Aggregate aggregate) { return aggregate.oldestAge; }
|
||||
},
|
||||
DEAD {
|
||||
@Override Number value(Aggregate aggregate) { return aggregate.dead; }
|
||||
};
|
||||
|
||||
abstract Number value(Aggregate aggregate);
|
||||
}
|
||||
}
|
||||
-128
@@ -1,128 +0,0 @@
|
||||
package com.baiyue.eventbridge.metrics;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.Timer;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/** Low-cardinality transport metrics for the cross-region event bridge. */
|
||||
@Component
|
||||
@Slf4j
|
||||
public class EventBridgeMetrics {
|
||||
public static final String RECEIVE_METRIC = "baiyue.event.bridge.receive";
|
||||
public static final String TRANSPORT_METRIC = "baiyue.event.bridge.transport";
|
||||
public static final String DELIVERY_LATENCY_METRIC = "baiyue.event.bridge.delivery.latency";
|
||||
|
||||
private static final Set<String> KNOWN_EVENT_TYPES = Set.of(
|
||||
EventTypes.ORDER_APPROVED,
|
||||
EventTypes.ORDER_CANCELLED,
|
||||
EventTypes.FIRST_LEG_SHIPMENT_DEPARTED,
|
||||
EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,
|
||||
EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED,
|
||||
EventTypes.OVERSEAS_CONTAINER_UNLOADED,
|
||||
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED,
|
||||
EventTypes.OVERSEAS_DELIVERY_COMPLETED,
|
||||
EventTypes.BUSINESS_FEE_SOURCE_CHANGED,
|
||||
EventTypes.WALLET_COMMAND_REQUESTED,
|
||||
EventTypes.WALLET_COMMAND_COMPLETED,
|
||||
EventTypes.IAM_USER_STATUS_CHANGED,
|
||||
EventTypes.IAM_TENANT_STATUS_CHANGED,
|
||||
EventTypes.IAM_PERMISSION_VERSION_CHANGED,
|
||||
EventTypes.LAST_MILE_HANDOFF_ACCEPTED,
|
||||
EventTypes.LAST_MILE_HANDOFF_REJECTED,
|
||||
EventTypes.LAST_MILE_MILESTONE_CHANGED,
|
||||
EventTypes.LAST_MILE_COMPLETED,
|
||||
EventTypes.TRACKING_MILESTONE_PROJECTED);
|
||||
|
||||
private final MeterRegistry registry;
|
||||
private final Clock clock;
|
||||
|
||||
@Autowired
|
||||
public EventBridgeMetrics(MeterRegistry registry) {
|
||||
this(registry, Clock.systemDefaultZone());
|
||||
}
|
||||
|
||||
EventBridgeMetrics(MeterRegistry registry, Clock clock) {
|
||||
this.registry = registry;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public void recordReceive(EventEnvelope envelope, ReceiveResult result) {
|
||||
try {
|
||||
registry.counter(RECEIVE_METRIC,
|
||||
"target_region", targetRegion(envelope.targetRegion().name()),
|
||||
"event_type", eventType(envelope.eventType()),
|
||||
"result", result.name()).increment();
|
||||
} catch (RuntimeException exception) {
|
||||
log.warn("Event Bridge 接收指标记录失败,不影响事件事务", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public void recordTransport(BridgeInbox event, TransportResult result) {
|
||||
try {
|
||||
String targetRegion = targetRegion(event.getTargetRegion());
|
||||
String eventType = eventType(event.getEventType());
|
||||
registry.counter(TRANSPORT_METRIC,
|
||||
"target_region", targetRegion,
|
||||
"event_type", eventType,
|
||||
"result", result.name()).increment();
|
||||
if (result.acknowledged() && event.getReceivedAt() != null) {
|
||||
Duration latency = Duration.between(event.getReceivedAt(), LocalDateTime.now(clock));
|
||||
if (latency.isNegative()) {
|
||||
latency = Duration.ZERO;
|
||||
}
|
||||
Timer.builder(DELIVERY_LATENCY_METRIC)
|
||||
.description("Time from Event Bridge receipt to downstream acknowledgement")
|
||||
.serviceLevelObjectives(Duration.ofSeconds(60), Duration.ofMinutes(15))
|
||||
.tags("target_region", targetRegion, "event_type", eventType)
|
||||
.register(registry).record(latency);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
log.warn("Event Bridge 传输指标记录失败,不影响可靠性状态机", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public static String targetRegion(String value) {
|
||||
String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
||||
return "CN".equals(normalized) || "OVERSEAS".equals(normalized) ? normalized : "OTHER";
|
||||
}
|
||||
|
||||
public static String eventType(String value) {
|
||||
return value != null && KNOWN_EVENT_TYPES.contains(value) ? value : "OTHER";
|
||||
}
|
||||
|
||||
public enum ReceiveResult {
|
||||
RECEIVED,
|
||||
DUPLICATE,
|
||||
CONFLICT
|
||||
}
|
||||
|
||||
public enum TransportResult {
|
||||
DELIVERED(true),
|
||||
RETRY(false),
|
||||
DEAD(false),
|
||||
ACKNOWLEDGED_LEASE_LOST(true),
|
||||
FAILED_LEASE_LOST(false),
|
||||
MANUAL_REPLAY(false);
|
||||
|
||||
private final boolean acknowledged;
|
||||
|
||||
TransportResult(boolean acknowledged) {
|
||||
this.acknowledged = acknowledged;
|
||||
}
|
||||
|
||||
boolean acknowledged() {
|
||||
return acknowledged;
|
||||
}
|
||||
}
|
||||
}
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
package com.baiyue.eventbridge.metrics;
|
||||
|
||||
/** Result row of the bounded, scheduled Inbox observability aggregation. */
|
||||
public class InboxMetricsSnapshot {
|
||||
private String targetRegion;
|
||||
private String eventType;
|
||||
private long backlogCount;
|
||||
private double oldestPendingAgeSeconds;
|
||||
private long deadCount;
|
||||
|
||||
public String getTargetRegion() {
|
||||
return targetRegion;
|
||||
}
|
||||
|
||||
public void setTargetRegion(String targetRegion) {
|
||||
this.targetRegion = targetRegion;
|
||||
}
|
||||
|
||||
public String getEventType() {
|
||||
return eventType;
|
||||
}
|
||||
|
||||
public void setEventType(String eventType) {
|
||||
this.eventType = eventType;
|
||||
}
|
||||
|
||||
public long getBacklogCount() {
|
||||
return backlogCount;
|
||||
}
|
||||
|
||||
public void setBacklogCount(long backlogCount) {
|
||||
this.backlogCount = backlogCount;
|
||||
}
|
||||
|
||||
public double getOldestPendingAgeSeconds() {
|
||||
return oldestPendingAgeSeconds;
|
||||
}
|
||||
|
||||
public void setOldestPendingAgeSeconds(double oldestPendingAgeSeconds) {
|
||||
this.oldestPendingAgeSeconds = oldestPendingAgeSeconds;
|
||||
}
|
||||
|
||||
public long getDeadCount() {
|
||||
return deadCount;
|
||||
}
|
||||
|
||||
public void setDeadCount(long deadCount) {
|
||||
this.deadCount = deadCount;
|
||||
}
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
package com.baiyue.eventbridge.security;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
/**
|
||||
* 内部事件接收接口的共享令牌校验器;未配置令牌时默认关闭接收。
|
||||
*/
|
||||
@Component
|
||||
public class InternalServiceTokenGuard {
|
||||
private final String expectedToken;
|
||||
|
||||
public InternalServiceTokenGuard(@Value("${baiyue.security.internal-service-token:}") String expectedToken) {
|
||||
this.expectedToken = expectedToken;
|
||||
}
|
||||
|
||||
public void verify(String actualToken) {
|
||||
if (expectedToken == null || expectedToken.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "内部事件接收令牌未配置,接口已关闭");
|
||||
}
|
||||
byte[] expected = expectedToken.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] actual = actualToken == null ? new byte[0] : actualToken.getBytes(StandardCharsets.UTF_8);
|
||||
if (!MessageDigest.isEqual(expected, actual)) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "内部服务令牌无效");
|
||||
}
|
||||
}
|
||||
}
|
||||
-149
@@ -1,149 +0,0 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
import com.baiyue.eventbridge.dto.EventReceiveResult;
|
||||
import com.baiyue.eventbridge.metrics.EventBridgeMetrics;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 入站事件校验与幂等落库服务。
|
||||
*/
|
||||
@Service
|
||||
public class EventInboxService {
|
||||
@Resource private InboxStore inboxStore;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private EventBridgeMetrics metrics;
|
||||
|
||||
@Value("${baiyue.region:CN}")
|
||||
private String localRegion;
|
||||
|
||||
/** Local single-bridge mode may carry both regional envelopes; production keeps strict routing. */
|
||||
@Value("${baiyue.event-bridge.accept-any-region:false}")
|
||||
private boolean acceptAnyRegion;
|
||||
|
||||
@Transactional
|
||||
public EventReceiveResult receive(EventEnvelope envelope) {
|
||||
envelope.requireValid();
|
||||
if (!acceptAnyRegion && !envelope.targetRegion().name().equalsIgnoreCase(localRegion)) {
|
||||
throw new IllegalArgumentException("事件目标区域与当前事件桥区域不一致");
|
||||
}
|
||||
if (!EventPayloadHash.matches(
|
||||
EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) {
|
||||
throw new IllegalArgumentException("payloadHash 与规范化事件负载不一致");
|
||||
}
|
||||
BridgeInbox existing = inboxStore.findByEventId(envelope.eventId());
|
||||
if (existing != null) {
|
||||
return duplicateOrConflict(existing, envelope);
|
||||
}
|
||||
String idempotencyKey = idempotencyKey(envelope);
|
||||
BridgeInbox sameBusinessEvent = inboxStore.findByIdempotencyKey(idempotencyKey);
|
||||
if (sameBusinessEvent != null) {
|
||||
return duplicateBusinessEventOrConflict(sameBusinessEvent, envelope);
|
||||
}
|
||||
BridgeInbox inbox = toInbox(envelope);
|
||||
try {
|
||||
inboxStore.insert(inbox);
|
||||
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.RECEIVED);
|
||||
return new EventReceiveResult(envelope.eventId(), "已接收", inbox.getReceivedAt());
|
||||
} catch (DuplicateKeyException exception) {
|
||||
BridgeInbox concurrent = inboxStore.findByEventId(envelope.eventId());
|
||||
if (concurrent == null) concurrent = inboxStore.findByIdempotencyKey(idempotencyKey);
|
||||
if (concurrent == null) {
|
||||
throw exception;
|
||||
}
|
||||
return Objects.equals(concurrent.getEventId(), envelope.eventId())
|
||||
? duplicateOrConflict(concurrent, envelope)
|
||||
: duplicateBusinessEventOrConflict(concurrent, envelope);
|
||||
}
|
||||
}
|
||||
|
||||
private EventReceiveResult duplicateBusinessEventOrConflict(BridgeInbox existing, EventEnvelope envelope) {
|
||||
if (!sameBusinessEvent(existing, envelope)) {
|
||||
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.CONFLICT);
|
||||
throw new IllegalArgumentException("相同eventKey对应的事件负载不一致,拒绝柜号或事实漂移");
|
||||
}
|
||||
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.DUPLICATE);
|
||||
return new EventReceiveResult(existing.getEventId(), "重复", existing.getReceivedAt());
|
||||
}
|
||||
|
||||
private EventReceiveResult duplicateOrConflict(BridgeInbox existing, EventEnvelope envelope) {
|
||||
if (!sameEnvelope(existing, envelope)) {
|
||||
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.CONFLICT);
|
||||
throw new IllegalArgumentException("相同 eventId 对应的事件信封或 payloadHash 不一致,拒绝覆盖既有事件");
|
||||
}
|
||||
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.DUPLICATE);
|
||||
return new EventReceiveResult(envelope.eventId(), "重复", existing.getReceivedAt());
|
||||
}
|
||||
|
||||
private boolean sameEnvelope(BridgeInbox existing, EventEnvelope envelope) {
|
||||
return sameBusinessEvent(existing, envelope)
|
||||
&& Objects.equals(normalizeOccurredAt(existing.getOccurredAt()), normalizeOccurredAt(envelope.occurredAt()))
|
||||
&& Objects.equals(existing.getTraceId(), envelope.traceId())
|
||||
&& Objects.equals(existing.getCausationId(), envelope.causationId());
|
||||
}
|
||||
|
||||
private boolean sameBusinessEvent(BridgeInbox existing, EventEnvelope envelope) {
|
||||
return existing.getPayloadHash() != null
|
||||
&& existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())
|
||||
&& Objects.equals(existing.getEventType(), envelope.eventType())
|
||||
&& Objects.equals(existing.getEventVersion(), envelope.eventVersion())
|
||||
&& Objects.equals(existing.getSourceRegion(), envelope.sourceRegion().name())
|
||||
&& Objects.equals(existing.getTargetRegion(), envelope.targetRegion().name())
|
||||
&& Objects.equals(existing.getCustomerId(), envelope.tenantId())
|
||||
&& Objects.equals(existing.getAggregateType(), envelope.aggregateType())
|
||||
&& Objects.equals(existing.getAggregateId(), envelope.aggregateId())
|
||||
&& Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion())
|
||||
&& Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion())
|
||||
&& Objects.equals(existing.getCorrelationId(), envelope.correlationId());
|
||||
}
|
||||
|
||||
private BridgeInbox toInbox(EventEnvelope envelope) {
|
||||
BridgeInbox inbox = new BridgeInbox();
|
||||
inbox.setEventId(envelope.eventId());
|
||||
inbox.setIdempotencyKey(idempotencyKey(envelope));
|
||||
inbox.setEventType(envelope.eventType());
|
||||
inbox.setEventVersion(envelope.eventVersion());
|
||||
inbox.setSourceRegion(envelope.sourceRegion().name());
|
||||
inbox.setTargetRegion(envelope.targetRegion().name());
|
||||
inbox.setCustomerId(envelope.tenantId());
|
||||
inbox.setAggregateType(envelope.aggregateType());
|
||||
inbox.setAggregateId(envelope.aggregateId());
|
||||
inbox.setAggregateVersion(envelope.aggregateVersion());
|
||||
inbox.setHandoverVersion(envelope.handoverVersion());
|
||||
inbox.setTraceId(envelope.traceId());
|
||||
inbox.setCorrelationId(envelope.correlationId());
|
||||
inbox.setCausationId(envelope.causationId());
|
||||
inbox.setOccurredAt(normalizeOccurredAt(envelope.occurredAt()));
|
||||
inbox.setPayloadHash(envelope.payloadHash().toLowerCase(java.util.Locale.ROOT));
|
||||
try {
|
||||
inbox.setPayloadJson(objectMapper.writeValueAsString(envelope.payload()));
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new IllegalArgumentException("事件负载无法序列化", exception);
|
||||
}
|
||||
inbox.setStatus("RECEIVED");
|
||||
inbox.setReceivedAt(LocalDateTime.now());
|
||||
return inbox;
|
||||
}
|
||||
|
||||
private static Instant normalizeOccurredAt(Instant value) {
|
||||
return value == null ? null : value.truncatedTo(ChronoUnit.MILLIS);
|
||||
}
|
||||
|
||||
private static String idempotencyKey(EventEnvelope envelope) {
|
||||
return envelope.sourceRegion() + ":" + envelope.correlationId();
|
||||
}
|
||||
}
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper;
|
||||
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
|
||||
import com.baiyue.eventbridge.metrics.EventBridgeMetrics;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Service
|
||||
public class EventReplayService {
|
||||
@Resource private BridgeInboxMapper inboxMapper;
|
||||
@Resource private BridgeDeliveryAttemptMapper attemptMapper;
|
||||
@Resource private EventBridgeMetrics metrics;
|
||||
|
||||
@Transactional
|
||||
public void replayDead(String eventId, String operatorId, String reason) {
|
||||
if (operatorId == null || operatorId.isBlank()) {
|
||||
throw new IllegalArgumentException("人工补投必须提供操作人");
|
||||
}
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw new IllegalArgumentException("人工补投必须提供原因");
|
||||
}
|
||||
String auditReason = abbreviate("manual replay by " + operatorId + ": " + reason, 1000);
|
||||
if (inboxMapper.replayDead(eventId, auditReason) != 1) {
|
||||
throw new IllegalStateException("事件不存在或未处于 DEAD 状态");
|
||||
}
|
||||
BridgeInbox event = inboxMapper.selectById(eventId);
|
||||
if (event == null) {
|
||||
throw new IllegalStateException("事件补投状态已更新但事件记录不存在");
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
BridgeDeliveryAttempt audit = new BridgeDeliveryAttempt();
|
||||
audit.setEventId(eventId);
|
||||
audit.setSourceRegion(event.getSourceRegion());
|
||||
audit.setTargetRegion(event.getTargetRegion());
|
||||
audit.setAttemptNo(inboxMapper.nextAttemptNo(eventId));
|
||||
audit.setStatus("MANUAL_REPLAY");
|
||||
audit.setErrorMessage(auditReason);
|
||||
audit.setStartedAt(now);
|
||||
audit.setFinishedAt(now);
|
||||
attemptMapper.insert(audit);
|
||||
metrics.recordTransport(event, EventBridgeMetrics.TransportResult.MANUAL_REPLAY);
|
||||
}
|
||||
|
||||
private static String abbreviate(String value, int maxLength) {
|
||||
return value.length() <= maxLength ? value : value.substring(0, maxLength);
|
||||
}
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
|
||||
public interface FinanceEventClient {
|
||||
String deliver(BridgeInbox event) throws Exception;
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
|
||||
public interface FirstlegEventClient {
|
||||
String deliver(BridgeInbox event) throws Exception;
|
||||
}
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.eventbridge.config.EventDispatcherProperties;
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
import com.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
|
||||
@Component
|
||||
public class HttpFinanceEventClient implements FinanceEventClient {
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private EventDispatcherProperties properties;
|
||||
@Resource private EventBridgeHttpTracePropagation tracePropagation;
|
||||
@Value("${baiyue.event-dispatcher.finance-consumer-url:http://127.0.0.1:9203/internal/finance/v1/business-fee-source-events}")
|
||||
private URI consumerUrl;
|
||||
@Value("${baiyue.event-dispatcher.finance-wallet-consumer-url:http://127.0.0.1:9203/internal/finance/v1/wallet-command-events}")
|
||||
private URI walletConsumerUrl;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String serviceToken;
|
||||
private HttpClient httpClient;
|
||||
|
||||
@PostConstruct
|
||||
void initializeHttpClient() {
|
||||
httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String deliver(BridgeInbox row) throws Exception {
|
||||
EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(),
|
||||
row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()),
|
||||
row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(),
|
||||
row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(),
|
||||
objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash());
|
||||
URI target = EventTypes.WALLET_COMMAND_REQUESTED.equals(row.getEventType())
|
||||
? walletConsumerUrl : consumerUrl;
|
||||
HttpRequest request = tracePropagation.apply(HttpRequest.newBuilder(target)
|
||||
.timeout(properties.requestTimeout()).header("Content-Type", "application/json")
|
||||
.header("X-Internal-Service-Token", serviceToken))
|
||||
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build();
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
JsonNode body;
|
||||
try { body = objectMapper.readTree(response.body()); }
|
||||
catch (Exception ignored) { body = null; }
|
||||
if (response.statusCode() / 100 != 2 || body == null || !"200".equals(body.path("code").asText())) {
|
||||
throw new IllegalStateException("Finance消费失败: HTTP " + response.statusCode()
|
||||
+ ", code=" + (body == null ? "missing" : body.path("code").asText("missing")));
|
||||
}
|
||||
return String.valueOf(response.statusCode());
|
||||
}
|
||||
}
|
||||
-75
@@ -1,75 +0,0 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.eventbridge.config.EventDispatcherProperties;
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
import com.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
|
||||
@Component
|
||||
public class HttpFirstlegEventClient implements FirstlegEventClient {
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private EventDispatcherProperties properties;
|
||||
@Resource private EventBridgeHttpTracePropagation tracePropagation;
|
||||
@Value("${baiyue.event-dispatcher.firstleg-consumer-url:http://127.0.0.1:9203/internal/firstleg/v1/container-events}")
|
||||
private URI consumerUrl;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String serviceToken;
|
||||
private HttpClient httpClient;
|
||||
|
||||
@PostConstruct
|
||||
void initializeHttpClient() {
|
||||
httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String deliver(BridgeInbox row) throws Exception {
|
||||
EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(),
|
||||
row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()),
|
||||
row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(),
|
||||
row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(),
|
||||
objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash());
|
||||
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout())
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Internal-Service-Token", serviceToken);
|
||||
HttpRequest request = tracePropagation.apply(requestBuilder)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build();
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
JsonNode responseBody;
|
||||
try {
|
||||
responseBody = objectMapper.readTree(response.body());
|
||||
} catch (Exception ignored) {
|
||||
responseBody = null;
|
||||
}
|
||||
if (response.statusCode() / 100 != 2 || responseBody == null
|
||||
|| !"200".equals(responseBody.path("code").asText())) {
|
||||
throw new IllegalStateException(failureMessage(response.statusCode(), responseBody));
|
||||
}
|
||||
return String.valueOf(response.statusCode());
|
||||
}
|
||||
|
||||
static String failureMessage(int statusCode, JsonNode body) {
|
||||
String code = body == null ? "missing" : body.path("code").asText("missing");
|
||||
String message = body == null ? "non-json response" : body.path("message").asText("missing");
|
||||
String requestId = body == null ? "missing" : body.path("requestId").asText("missing");
|
||||
String traceId = body == null ? "missing" : body.path("traceId").asText("missing");
|
||||
return "头程消费失败: HTTP " + statusCode + ", code=" + bounded(code)
|
||||
+ ", message=" + bounded(message) + ", requestId=" + bounded(requestId)
|
||||
+ ", traceId=" + bounded(traceId);
|
||||
}
|
||||
|
||||
private static String bounded(String value) {
|
||||
String safe = value == null || value.isBlank() ? "missing" : value.replaceAll("[\\r\\n]+", " ");
|
||||
return safe.length() <= 240 ? safe : safe.substring(0, 240);
|
||||
}
|
||||
}
|
||||
-78
@@ -1,78 +0,0 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.eventbridge.config.EventDispatcherProperties;
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
import com.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
|
||||
@Component
|
||||
public class HttpLastlegEventClient implements LastlegEventClient {
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private EventDispatcherProperties properties;
|
||||
@Resource private EventBridgeHttpTracePropagation tracePropagation;
|
||||
|
||||
@Value("${baiyue.security.internal-service-token:}")
|
||||
private String serviceToken;
|
||||
|
||||
private HttpClient httpClient;
|
||||
|
||||
@PostConstruct
|
||||
void initializeHttpClient() {
|
||||
this.httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String deliver(BridgeInbox row) throws Exception {
|
||||
EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(),
|
||||
row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()),
|
||||
row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(),
|
||||
row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(),
|
||||
objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash());
|
||||
String body = objectMapper.writeValueAsString(envelope);
|
||||
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(properties.consumerUrl())
|
||||
.timeout(properties.requestTimeout())
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Internal-Service-Token", serviceToken);
|
||||
HttpRequest request = tracePropagation.apply(requestBuilder)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
JsonNode responseBody;
|
||||
try {
|
||||
responseBody = objectMapper.readTree(response.body());
|
||||
} catch (Exception ignored) {
|
||||
responseBody = null;
|
||||
}
|
||||
if (response.statusCode() / 100 != 2 || responseBody == null
|
||||
|| !"200".equals(responseBody.path("code").asText())) {
|
||||
throw new IllegalStateException(failureMessage(response.statusCode(), responseBody));
|
||||
}
|
||||
return String.valueOf(response.statusCode());
|
||||
}
|
||||
|
||||
static String failureMessage(int statusCode, JsonNode body) {
|
||||
String code = body == null ? "missing" : body.path("code").asText("missing");
|
||||
String message = body == null ? "non-json response" : body.path("message").asText("missing");
|
||||
String requestId = body == null ? "missing" : body.path("requestId").asText("missing");
|
||||
String traceId = body == null ? "missing" : body.path("traceId").asText("missing");
|
||||
return "尾程消费失败: HTTP " + statusCode + ", code=" + bounded(code)
|
||||
+ ", message=" + bounded(message) + ", requestId=" + bounded(requestId)
|
||||
+ ", traceId=" + bounded(traceId);
|
||||
}
|
||||
|
||||
private static String bounded(String value) {
|
||||
String safe = value == null || value.isBlank() ? "missing" : value.replaceAll("[\\r\\n]+", " ");
|
||||
return safe.length() <= 240 ? safe : safe.substring(0, 240);
|
||||
}
|
||||
}
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.eventbridge.config.EventDispatcherProperties;
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
import com.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
|
||||
@Component
|
||||
public class HttpSecurityProjectionEventClient implements SecurityProjectionEventClient {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final EventDispatcherProperties properties;
|
||||
@Resource private EventBridgeHttpTracePropagation tracePropagation;
|
||||
@Value("${baiyue.event-dispatcher.security-consumer-url}") private URI consumerUrl;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String serviceToken;
|
||||
private HttpClient httpClient;
|
||||
|
||||
public HttpSecurityProjectionEventClient(ObjectMapper objectMapper, EventDispatcherProperties properties) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@PostConstruct void initialize() {
|
||||
httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String deliver(BridgeInbox row) throws Exception {
|
||||
EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(),
|
||||
row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()),
|
||||
row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(),
|
||||
row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(),
|
||||
objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash());
|
||||
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout())
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Internal-Service-Token", serviceToken);
|
||||
HttpRequest request = tracePropagation.apply(requestBuilder)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build();
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
JsonNode body;
|
||||
try { body = objectMapper.readTree(response.body()); }
|
||||
catch (Exception exception) { body = null; }
|
||||
requireAcknowledgement(response.statusCode(), body, row.getEventId());
|
||||
return String.valueOf(response.statusCode());
|
||||
}
|
||||
|
||||
static void requireAcknowledgement(int statusCode, JsonNode body, String eventId) {
|
||||
if (statusCode / 100 != 2 || body == null || !"200".equals(body.path("code").asText())
|
||||
|| !eventId.equals(body.path("data").path("eventId").asText())) {
|
||||
throw new IllegalStateException("Lastleg 安全投影消费失败: HTTP " + statusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
|
||||
/**
|
||||
* 入站幂等存储边界,便于隔离数据库实现并进行单元测试。
|
||||
*/
|
||||
public interface InboxStore {
|
||||
BridgeInbox findByEventId(String eventId);
|
||||
BridgeInbox findByIdempotencyKey(String idempotencyKey);
|
||||
void insert(BridgeInbox inbox);
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
|
||||
public interface LastlegEventClient {
|
||||
String deliver(BridgeInbox event) throws Exception;
|
||||
}
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
|
||||
/**
|
||||
* 基于 MyBatis-Plus 的入站幂等存储。
|
||||
*/
|
||||
@Repository
|
||||
public class MybatisInboxStore implements InboxStore {
|
||||
@Resource private BridgeInboxMapper mapper;
|
||||
|
||||
@Override
|
||||
public BridgeInbox findByEventId(String eventId) {
|
||||
return mapper.selectById(eventId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BridgeInbox findByIdempotencyKey(String idempotencyKey) {
|
||||
return mapper.selectOne(new LambdaQueryWrapper<BridgeInbox>()
|
||||
.eq(BridgeInbox::getIdempotencyKey, idempotencyKey));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void insert(BridgeInbox inbox) {
|
||||
mapper.insert(inbox);
|
||||
}
|
||||
}
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.eventbridge.config.EventDispatcherProperties;
|
||||
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper;
|
||||
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
|
||||
import com.baiyue.eventbridge.metrics.EventBridgeMetrics;
|
||||
import com.baiyue.eventbridge.trace.EventBridgeTraceScope;
|
||||
import com.baiyue.common.web.CorrelationContext;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.slf4j.MDC;
|
||||
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class OverseasEventDispatcher {
|
||||
@Resource private BridgeInboxMapper inboxMapper;
|
||||
@Resource private BridgeDeliveryAttemptMapper attemptMapper;
|
||||
@Resource private LastlegEventClient eventClient;
|
||||
@Resource private FirstlegEventClient firstlegEventClient;
|
||||
@Resource private FinanceEventClient financeEventClient;
|
||||
@Resource private SecurityProjectionEventClient securityProjectionEventClient;
|
||||
@Resource private EventDispatcherProperties properties;
|
||||
@Resource private EventBridgeMetrics metrics;
|
||||
private final String leaseOwner = ManagementFactory.getRuntimeMXBean().getName() + ":" + UUID.randomUUID();
|
||||
|
||||
@Scheduled(fixedDelayString = "${baiyue.event-dispatcher.fixed-delay:3000}")
|
||||
public void dispatch() {
|
||||
for (String eventId : inboxMapper.dispatchCandidates(properties.batchSize())) {
|
||||
dispatchOne(eventId);
|
||||
}
|
||||
}
|
||||
|
||||
void dispatchOne(String eventId) {
|
||||
if (inboxMapper.claim(eventId, leaseOwner, properties.leaseDuration().toSeconds()) != 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
BridgeInbox event = inboxMapper.selectById(eventId);
|
||||
try (EventBridgeTraceScope ignored = EventBridgeTraceScope.open(event)) {
|
||||
dispatchClaimed(eventId, event);
|
||||
}
|
||||
}
|
||||
|
||||
private void dispatchClaimed(String eventId, BridgeInbox event) {
|
||||
int retryAttempt = event.getRetryCount() == null ? 1 : event.getRetryCount() + 1;
|
||||
int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId);
|
||||
BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo);
|
||||
String responseCode;
|
||||
try {
|
||||
responseCode = deliver(event);
|
||||
} catch (Exception exception) {
|
||||
String error = abbreviate(exception.getMessage(), 1000);
|
||||
int marked = inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(),
|
||||
backoffSeconds(retryAttempt), error);
|
||||
String attemptStatus = marked == 1
|
||||
? (retryAttempt >= properties.maxAttempts() ? "DEAD" : "RETRY")
|
||||
: "FAILED_LEASE_LOST";
|
||||
finishAttempt(attempt, attemptStatus, null, error);
|
||||
metrics.recordTransport(event, EventBridgeMetrics.TransportResult.valueOf(attemptStatus));
|
||||
log.warn("跨区事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, traceId={}, reason={}",
|
||||
eventId, retryAttempt, deliveryAttemptNo, MDC.get(CorrelationContext.TRACE_ID), error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) {
|
||||
String error = "下游已确认成功,但事件租约已失效;保留当前 Inbox 状态并由新租约持有者幂等续投";
|
||||
finishAttempt(attempt, "ACKNOWLEDGED_LEASE_LOST", responseCode, error);
|
||||
metrics.recordTransport(event, EventBridgeMetrics.TransportResult.ACKNOWLEDGED_LEASE_LOST);
|
||||
log.warn("跨区事件成功确认时租约已失效 eventId={}, deliveryAttempt={}, traceId={}",
|
||||
eventId, deliveryAttemptNo, MDC.get(CorrelationContext.TRACE_ID));
|
||||
return;
|
||||
}
|
||||
finishAttempt(attempt, "DELIVERED", responseCode, null);
|
||||
metrics.recordTransport(event, EventBridgeMetrics.TransportResult.DELIVERED);
|
||||
}
|
||||
|
||||
private String deliver(BridgeInbox event) throws Exception {
|
||||
String eventType = event.getEventType();
|
||||
if (eventType == null || eventType.isBlank()) {
|
||||
throw new IllegalArgumentException("事件类型不能为空,无法选择下游消费者");
|
||||
}
|
||||
return switch (event.getTargetRegion()) {
|
||||
case "CN" -> FINANCE_EVENT_TYPES.contains(eventType)
|
||||
? financeEventClient.deliver(event) : firstlegEventClient.deliver(event);
|
||||
case "OVERSEAS" -> SECURITY_EVENT_TYPES.contains(eventType)
|
||||
? securityProjectionEventClient.deliver(event) : eventClient.deliver(event);
|
||||
default -> throw new IllegalArgumentException("不支持的事件目标区域: " + event.getTargetRegion());
|
||||
};
|
||||
}
|
||||
|
||||
private static final Set<String> SECURITY_EVENT_TYPES = Set.of(
|
||||
EventTypes.IAM_USER_STATUS_CHANGED,
|
||||
EventTypes.IAM_TENANT_STATUS_CHANGED,
|
||||
EventTypes.IAM_PERMISSION_VERSION_CHANGED);
|
||||
|
||||
private static final Set<String> FINANCE_EVENT_TYPES = Set.of(
|
||||
EventTypes.BUSINESS_FEE_SOURCE_CHANGED,
|
||||
EventTypes.WALLET_COMMAND_REQUESTED);
|
||||
|
||||
private BridgeDeliveryAttempt startAttempt(BridgeInbox event, int attemptNo) {
|
||||
BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt();
|
||||
attempt.setEventId(event.getEventId());
|
||||
attempt.setSourceRegion(event.getSourceRegion());
|
||||
attempt.setTargetRegion(event.getTargetRegion());
|
||||
attempt.setAttemptNo(attemptNo);
|
||||
attempt.setStatus("PROCESSING");
|
||||
attempt.setStartedAt(LocalDateTime.now());
|
||||
attemptMapper.insert(attempt);
|
||||
return attempt;
|
||||
}
|
||||
|
||||
private void finishAttempt(BridgeDeliveryAttempt attempt, String status, String responseCode, String error) {
|
||||
attempt.setStatus(status);
|
||||
attempt.setResponseCode(responseCode);
|
||||
attempt.setErrorMessage(error);
|
||||
attempt.setFinishedAt(LocalDateTime.now());
|
||||
attemptMapper.updateById(attempt);
|
||||
}
|
||||
|
||||
private long backoffSeconds(int attemptNo) {
|
||||
long initial = properties.initialBackoff().toSeconds();
|
||||
int shift = Math.min(Math.max(attemptNo - 1, 0), 20);
|
||||
long delay = initial > Long.MAX_VALUE >> shift ? Long.MAX_VALUE : initial << shift;
|
||||
return Math.min(delay, properties.maxBackoff().toSeconds());
|
||||
}
|
||||
|
||||
private static String abbreviate(String value, int maxLength) {
|
||||
String safe = value == null || value.isBlank() ? "未知投递错误" : value;
|
||||
return safe.length() <= maxLength ? safe : safe.substring(0, maxLength);
|
||||
}
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
|
||||
public interface SecurityProjectionEventClient {
|
||||
String deliver(BridgeInbox event) throws Exception;
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
package com.baiyue.eventbridge.trace;
|
||||
|
||||
import com.baiyue.common.web.CorrelationContext;
|
||||
import com.baiyue.common.web.OutboundTraceHeaders;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.net.http.HttpRequest;
|
||||
|
||||
/** Applies a fresh child span to one raw JDK HttpClient request. */
|
||||
@Component
|
||||
public class EventBridgeHttpTracePropagation {
|
||||
public HttpRequest.Builder apply(HttpRequest.Builder builder) {
|
||||
OutboundTraceHeaders outbound = OutboundTraceHeaders.fromCurrentOrNew();
|
||||
return builder
|
||||
.setHeader(CorrelationContext.REQUEST_ID_HEADER, outbound.requestId())
|
||||
.setHeader(CorrelationContext.TRACE_ID_HEADER, outbound.traceId())
|
||||
.setHeader(CorrelationContext.TRACE_PARENT_HEADER, outbound.traceParent());
|
||||
}
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
package com.baiyue.eventbridge.trace;
|
||||
|
||||
import com.baiyue.common.web.CorrelationContext;
|
||||
import com.baiyue.common.web.OutboundTraceHeaders;
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
import org.slf4j.MDC;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/** Establishes one trusted MDC context for an Event Bridge delivery attempt. */
|
||||
public final class EventBridgeTraceScope implements AutoCloseable {
|
||||
private final Map<String, String> previous;
|
||||
|
||||
private EventBridgeTraceScope(Map<String, String> previous) {
|
||||
this.previous = previous;
|
||||
}
|
||||
|
||||
public static EventBridgeTraceScope open(BridgeInbox event) {
|
||||
Map<String, String> previous = MDC.getCopyOfContextMap();
|
||||
OutboundTraceHeaders local = OutboundTraceHeaders.fromTrustedTraceOrNew(event.getTraceId());
|
||||
MDC.put(CorrelationContext.REQUEST_ID, local.requestId());
|
||||
MDC.put(CorrelationContext.TRACE_ID, local.traceId());
|
||||
MDC.put(CorrelationContext.SPAN_ID, local.spanId());
|
||||
return new EventBridgeTraceScope(previous);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (previous == null) {
|
||||
MDC.clear();
|
||||
} else {
|
||||
MDC.setContextMap(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
server:
|
||||
port: ${EVENT_BRIDGE_PORT:9312}
|
||||
|
||||
spring:
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:127.0.0.1}
|
||||
port: ${REDIS_PORT:6380}
|
||||
application:
|
||||
name: event-bridge-service
|
||||
config:
|
||||
import:
|
||||
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
|
||||
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: ${EVENT_BRIDGE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true}
|
||||
username: ${EVENT_BRIDGE_DB_USERNAME:root}
|
||||
password: ${EVENT_BRIDGE_DB_PASSWORD:123456}
|
||||
cloud:
|
||||
nacos:
|
||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
|
||||
username: ${NACOS_USERNAME:nacos}
|
||||
password: ${NACOS_PASSWORD:nacos}
|
||||
discovery:
|
||||
enabled: ${NACOS_DISCOVERY_ENABLED:true}
|
||||
register-enabled: ${NACOS_REGISTER_ENABLED:true}
|
||||
group: ${NACOS_GROUP:BAIYUE}
|
||||
namespace: ${NACOS_NAMESPACE:}
|
||||
config:
|
||||
namespace: ${NACOS_NAMESPACE:}
|
||||
|
||||
baiyue:
|
||||
region: ${REGION_CODE:CN}
|
||||
event-dispatcher:
|
||||
consumer-url: ${EVENT_CONSUMER_URL:${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}}
|
||||
firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://127.0.0.1:9202/internal/firstleg/v1/container-events}
|
||||
finance-consumer-url: ${FINANCE_FEE_EVENT_CONSUMER_URL:http://127.0.0.1:9203/internal/finance/v1/business-fee-source-events}
|
||||
finance-wallet-consumer-url: ${FINANCE_WALLET_EVENT_CONSUMER_URL:http://127.0.0.1:9203/internal/finance/v1/wallet-command-events}
|
||||
security-consumer-url: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/security/v1/events}
|
||||
fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000}
|
||||
batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50}
|
||||
max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10}
|
||||
lease-duration: ${EVENT_DISPATCH_LEASE_DURATION:30s}
|
||||
initial-backoff: ${EVENT_DISPATCH_INITIAL_BACKOFF:5s}
|
||||
max-backoff: ${EVENT_DISPATCH_MAX_BACKOFF:15m}
|
||||
connect-timeout: ${EVENT_DISPATCH_CONNECT_TIMEOUT:3s}
|
||||
request-timeout: ${EVENT_DISPATCH_REQUEST_TIMEOUT:10s}
|
||||
security:
|
||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN:}
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,prometheus
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /v3/api-docs
|
||||
@@ -1,80 +0,0 @@
|
||||
CREATE DATABASE IF NOT EXISTS baiyue_event_bridge DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
USE baiyue_event_bridge;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bridge_outbox (
|
||||
event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识',
|
||||
idempotency_key VARCHAR(191) NOT NULL COMMENT '发送幂等键',
|
||||
event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
|
||||
event_version INT NOT NULL COMMENT '事件契约版本',
|
||||
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
|
||||
target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '租户标识',
|
||||
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
|
||||
aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
|
||||
aggregate_version BIGINT NOT NULL COMMENT '聚合版本',
|
||||
handover_version BIGINT NULL COMMENT '跨区交接版本',
|
||||
trace_id VARCHAR(128) NOT NULL COMMENT '链路标识',
|
||||
correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识',
|
||||
causation_id VARCHAR(128) NULL COMMENT '上游事件标识',
|
||||
occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间',
|
||||
payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要',
|
||||
payload_json JSON NOT NULL COMMENT '事件负载',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态',
|
||||
retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数',
|
||||
next_retry_at DATETIME(3) NULL COMMENT '下次重试时间',
|
||||
lease_owner VARCHAR(128) NULL COMMENT '租约持有者',
|
||||
lease_until DATETIME(3) NULL COMMENT '租约截止时间',
|
||||
last_error VARCHAR(1000) NULL COMMENT '最后错误',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
delivered_at DATETIME(3) NULL COMMENT '投递成功时间',
|
||||
PRIMARY KEY (event_id),
|
||||
UNIQUE KEY uk_bridge_outbox_idempotency (idempotency_key),
|
||||
KEY idx_bridge_outbox_schedule (target_region, status, next_retry_at, lease_until)
|
||||
) ENGINE=InnoDB COMMENT='跨区域事件待投递箱';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bridge_inbox (
|
||||
event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识',
|
||||
idempotency_key VARCHAR(191) NOT NULL COMMENT '来源区域与业务eventKey组成的接收幂等键',
|
||||
event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
|
||||
event_version INT NOT NULL COMMENT '事件契约版本',
|
||||
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
|
||||
target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '租户标识',
|
||||
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
|
||||
aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
|
||||
aggregate_version BIGINT NOT NULL COMMENT '聚合版本',
|
||||
handover_version BIGINT NULL COMMENT '跨区交接版本',
|
||||
trace_id VARCHAR(128) NOT NULL COMMENT '链路标识',
|
||||
correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识',
|
||||
causation_id VARCHAR(128) NULL COMMENT '上游事件标识',
|
||||
occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间',
|
||||
payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要',
|
||||
payload_json JSON NOT NULL COMMENT '事件负载',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态',
|
||||
lease_owner VARCHAR(128) NULL COMMENT '消费租约持有者',
|
||||
lease_until DATETIME(3) NULL COMMENT '消费租约截止时间',
|
||||
retry_count INT NOT NULL DEFAULT 0 COMMENT '消费重试次数',
|
||||
next_retry_at DATETIME(3) NULL COMMENT '下次消费时间',
|
||||
last_error VARCHAR(1000) NULL COMMENT '最后错误',
|
||||
received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间',
|
||||
processed_at DATETIME(3) NULL COMMENT '消费完成时间',
|
||||
PRIMARY KEY (event_id),
|
||||
UNIQUE KEY uk_bridge_inbox_idempotency (idempotency_key),
|
||||
KEY idx_bridge_inbox_consume (status, next_retry_at, lease_until)
|
||||
) ENGINE=InnoDB COMMENT='跨区域事件已接收箱';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bridge_delivery_attempt (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识',
|
||||
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
|
||||
target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
|
||||
attempt_no INT NOT NULL COMMENT '投递序号',
|
||||
status VARCHAR(32) NOT NULL COMMENT '投递结果',
|
||||
response_code VARCHAR(64) NULL COMMENT '对端响应码',
|
||||
error_message VARCHAR(1000) NULL COMMENT '错误信息',
|
||||
started_at DATETIME(3) NOT NULL COMMENT '开始时间',
|
||||
finished_at DATETIME(3) NULL COMMENT '完成时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_bridge_attempt (event_id, attempt_no),
|
||||
KEY idx_bridge_attempt_event (event_id, started_at)
|
||||
) ENGINE=InnoDB COMMENT='跨区域事件投递尝试记录';
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
package com.baiyue.eventbridge.metrics;
|
||||
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoMoreInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class EventBridgeInboxMetricsSamplerTest {
|
||||
|
||||
@Test
|
||||
void samplesDatabaseOnceAndServesCachedLowCardinalityGauges() {
|
||||
BridgeInboxMapper mapper = mock(BridgeInboxMapper.class);
|
||||
SimpleMeterRegistry registry = new SimpleMeterRegistry();
|
||||
InboxMetricsSnapshot known = snapshot("OVERSEAS", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,
|
||||
7, 121.5, 2);
|
||||
InboxMetricsSnapshot unknown = snapshot("region-123", "dynamic-event-123", 3, 900.0, 1);
|
||||
when(mapper.observabilitySnapshots()).thenReturn(List.of(known, unknown));
|
||||
EventBridgeInboxMetricsSampler sampler = new EventBridgeInboxMetricsSampler(mapper, registry);
|
||||
|
||||
sampler.sample();
|
||||
|
||||
verify(mapper).observabilitySnapshots();
|
||||
assertThat(gauge(registry, EventBridgeInboxMetricsSampler.BACKLOG_METRIC,
|
||||
"OVERSEAS", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)).isEqualTo(7.0);
|
||||
assertThat(gauge(registry, EventBridgeInboxMetricsSampler.OLDEST_AGE_METRIC,
|
||||
"OVERSEAS", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)).isEqualTo(121.5);
|
||||
assertThat(gauge(registry, EventBridgeInboxMetricsSampler.DEAD_METRIC,
|
||||
"OTHER", "OTHER")).isEqualTo(1.0);
|
||||
verifyNoMoreInteractions(mapper);
|
||||
}
|
||||
|
||||
private InboxMetricsSnapshot snapshot(String region, String eventType, long backlog,
|
||||
double oldestAge, long dead) {
|
||||
InboxMetricsSnapshot snapshot = new InboxMetricsSnapshot();
|
||||
snapshot.setTargetRegion(region);
|
||||
snapshot.setEventType(eventType);
|
||||
snapshot.setBacklogCount(backlog);
|
||||
snapshot.setOldestPendingAgeSeconds(oldestAge);
|
||||
snapshot.setDeadCount(dead);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private double gauge(SimpleMeterRegistry registry, String name, String region, String eventType) {
|
||||
return registry.get(name).tags("target_region", region, "event_type", eventType).gauge().value();
|
||||
}
|
||||
}
|
||||
-78
@@ -1,78 +0,0 @@
|
||||
package com.baiyue.eventbridge.metrics;
|
||||
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class EventBridgeMetricsTest {
|
||||
|
||||
@Test
|
||||
void productionConstructorIsExplicitForSpringWhenTestClockConstructorAlsoExists() throws Exception {
|
||||
assertThat(EventBridgeMetrics.class.getConstructor(MeterRegistry.class)
|
||||
.isAnnotationPresent(Autowired.class)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void springContextCreatesMetricsBeanWithTheAnnotatedProductionConstructor() {
|
||||
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
|
||||
context.registerBean(MeterRegistry.class, SimpleMeterRegistry::new);
|
||||
context.register(EventBridgeMetrics.class);
|
||||
context.refresh();
|
||||
|
||||
assertThat(context.getBean(EventBridgeMetrics.class)).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void collapsesUnboundedRegionAndEventTypeLabelsToOther() {
|
||||
SimpleMeterRegistry registry = new SimpleMeterRegistry();
|
||||
EventBridgeMetrics metrics = new EventBridgeMetrics(registry);
|
||||
BridgeInbox event = new BridgeInbox();
|
||||
event.setTargetRegion("customer-controlled-region");
|
||||
event.setEventType("dynamic-event-123456");
|
||||
|
||||
metrics.recordTransport(event, EventBridgeMetrics.TransportResult.RETRY);
|
||||
|
||||
assertThat(registry.get(EventBridgeMetrics.TRANSPORT_METRIC)
|
||||
.tags("target_region", "OTHER", "event_type", "OTHER", "result", "RETRY")
|
||||
.counter().count()).isEqualTo(1.0);
|
||||
assertThat(registry.getMeters()).allSatisfy(meter -> assertThat(meter.getId().getTags())
|
||||
.noneMatch(tag -> tag.getValue().contains("dynamic-event")));
|
||||
assertThat(EventBridgeMetrics.eventType(null)).isEqualTo("OTHER");
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyFrozenEventTypeHasABoundedTagValue() {
|
||||
assertThat(Arrays.stream(com.baiyue.common.event.EventTypes.class.getFields())
|
||||
.filter(field -> Modifier.isStatic(field.getModifiers()) && field.getType() == String.class)
|
||||
.map(field -> {
|
||||
try {
|
||||
return (String) field.get(null);
|
||||
} catch (IllegalAccessException exception) {
|
||||
throw new AssertionError(exception);
|
||||
}
|
||||
})).allMatch(value -> EventBridgeMetrics.eventType(value).equals(value));
|
||||
}
|
||||
|
||||
@Test
|
||||
void metricsFailureDoesNotChangeTransportControlFlow() {
|
||||
EventBridgeMetrics metrics = new EventBridgeMetrics(mock(MeterRegistry.class));
|
||||
BridgeInbox event = new BridgeInbox();
|
||||
event.setTargetRegion("CN");
|
||||
event.setEventType(com.baiyue.common.event.EventTypes.OVERSEAS_CONTAINER_UNLOADED);
|
||||
|
||||
assertThatCode(() -> metrics.recordTransport(event, EventBridgeMetrics.TransportResult.RETRY))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
}
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
package com.baiyue.eventbridge.metrics;
|
||||
|
||||
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class EventBridgeObservabilityContractTest {
|
||||
|
||||
@Test
|
||||
void aggregationIsScheduledCacheInputAndUsesExistingStatusIndexBasis() throws Exception {
|
||||
Select annotation = BridgeInboxMapper.class.getMethod("observabilitySnapshots")
|
||||
.getAnnotation(Select.class);
|
||||
String query = String.join(" ", annotation.value()).replaceAll("\\s+", " ");
|
||||
String schema = Files.readString(Path.of("src/main/resources/db/event-bridge.sql"));
|
||||
|
||||
assertThat(query)
|
||||
.contains("WHERE status IN ('RECEIVED','RETRY','PROCESSING','DEAD')")
|
||||
.contains("GROUP BY target_region, event_type")
|
||||
.doesNotContain("event_id AS", "customer_id AS", "last_error AS");
|
||||
assertThat(schema).contains(
|
||||
"KEY idx_bridge_inbox_consume (status, next_retry_at, lease_until)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void developmentSloAndAlertThresholdsAreFrozenWithoutDeploymentClaim() throws Exception {
|
||||
String contract = Files.readString(Path.of("docs/development-observability-slo.md"));
|
||||
|
||||
assertThat(contract)
|
||||
.contains("99% 的新接收跨区事件在 60 秒内")
|
||||
.contains("99.9% 的新接收跨区事件在 15 分钟内")
|
||||
.contains("result=\"DEAD\"", "> 120", "> 900")
|
||||
.contains("不表示任何环境已经部署")
|
||||
.contains("idx_bridge_inbox_consume(status, next_retry_at, lease_until)")
|
||||
.doesNotContain("customer_id\"", "event_id\"");
|
||||
}
|
||||
}
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
package com.baiyue.eventbridge.security;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class InternalServiceTokenGuardTest {
|
||||
@Test
|
||||
void 正确令牌允许访问() {
|
||||
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("a-secure-internal-token");
|
||||
assertThatCode(() -> guard.verify("a-secure-internal-token")).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 未配置令牌时默认关闭接收接口() {
|
||||
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("");
|
||||
assertThatThrownBy(() -> guard.verify("anything"))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("接口已关闭");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 错误令牌被拒绝() {
|
||||
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("expected");
|
||||
assertThatThrownBy(() -> guard.verify("wrong"))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("令牌无效");
|
||||
}
|
||||
}
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class EventBridgeReliabilitySqlContractTest {
|
||||
@Test
|
||||
void schedulingAndLeaseSqlCoverRetryDeadAndExpiredLeaseTakeover() throws Exception {
|
||||
String candidates = sql(BridgeInboxMapper.class.getMethod("dispatchCandidates", int.class), Select.class);
|
||||
String claim = sql(BridgeInboxMapper.class.getMethod(
|
||||
"claim", String.class, String.class, long.class), Update.class);
|
||||
String failed = sql(BridgeInboxMapper.class.getMethod(
|
||||
"markFailed", String.class, String.class, int.class, long.class, String.class), Update.class);
|
||||
String processed = sql(BridgeInboxMapper.class.getMethod(
|
||||
"markProcessed", String.class, String.class), Update.class);
|
||||
|
||||
assertThat(candidates).contains("status IN ('RECEIVED','RETRY')", "next_retry_at <= CURRENT_TIMESTAMP(3)",
|
||||
"status = 'PROCESSING'", "lease_until < CURRENT_TIMESTAMP(3)", "ORDER BY received_at");
|
||||
assertThat(claim).contains("status='PROCESSING'", "lease_owner=#{owner}", "lease_until=DATE_ADD",
|
||||
"status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)");
|
||||
assertThat(failed).contains("retry_count=retry_count+1", "status=CASE", "THEN 'DEAD' ELSE 'RETRY'",
|
||||
"next_retry_at=CASE", "THEN NULL", "last_error=#{error}", "lease_owner=#{owner}");
|
||||
assertThat(processed).contains("status='PROCESSED'", "next_retry_at=NULL", "last_error=NULL",
|
||||
"status='PROCESSING'", "lease_owner=#{owner}");
|
||||
}
|
||||
|
||||
private <A extends java.lang.annotation.Annotation> String sql(Method method, Class<A> type) {
|
||||
A annotation = method.getAnnotation(type);
|
||||
String[] statements = annotation instanceof Select select ? select.value() : ((Update) annotation).value();
|
||||
return String.join(" ", Arrays.asList(statements)).replaceAll("\\s+", " ");
|
||||
}
|
||||
}
|
||||
-218
@@ -1,218 +0,0 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
import com.baiyue.eventbridge.metrics.EventBridgeMetrics;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class EventInboxServiceTest {
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final InboxStore store = mock(InboxStore.class);
|
||||
private final SimpleMeterRegistry registry = new SimpleMeterRegistry();
|
||||
private final EventBridgeMetrics metrics = new EventBridgeMetrics(registry);
|
||||
private final EventInboxService service = service("OVERSEAS");
|
||||
|
||||
@Test
|
||||
void 首次事件落库并返回已接收() {
|
||||
EventEnvelope envelope = envelope("sha256:placeholder");
|
||||
envelope = withHash(envelope);
|
||||
|
||||
assertThat(service.receive(envelope).result()).isEqualTo("已接收");
|
||||
verify(store).insert(any(BridgeInbox.class));
|
||||
assertThat(receiveCount("RECEIVED")).isEqualTo(1.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 拒绝旧数字规范化摘要() {
|
||||
EventEnvelope source = envelope("sha256:placeholder");
|
||||
source.payload().withObject("/snapshot").put("weight", 30.0).put("volume", 0.3150);
|
||||
String legacyHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalizeLegacy(source.payload()));
|
||||
EventEnvelope envelope = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(),
|
||||
source.occurredAt(), source.sourceRegion(), source.targetRegion(), source.tenantId(),
|
||||
source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(),
|
||||
source.traceId(), source.correlationId(), source.causationId(), source.payload(), legacyHash);
|
||||
|
||||
assertThatThrownBy(() -> service.receive(envelope))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("payloadHash");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 相同事件和摘要重复接收不再次写入() {
|
||||
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
||||
BridgeInbox existing = existing(envelope, envelope.payloadHash());
|
||||
when(store.findByEventId(envelope.eventId())).thenReturn(existing);
|
||||
|
||||
assertThat(service.receive(envelope).result()).isEqualTo("重复");
|
||||
assertThat(receiveCount("DUPLICATE")).isEqualTo(1.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 数据库毫秒精度截断后相同事件仍判定为重复() {
|
||||
EventEnvelope source = envelope("sha256:placeholder");
|
||||
EventEnvelope precise = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(),
|
||||
Instant.parse("2026-07-29T08:30:00.123456789Z"), source.sourceRegion(), source.targetRegion(),
|
||||
source.tenantId(), source.aggregateType(), source.aggregateId(), source.aggregateVersion(),
|
||||
source.handoverVersion(), source.traceId(), source.correlationId(), source.causationId(),
|
||||
source.payload(), source.payloadHash());
|
||||
EventEnvelope envelope = withHash(precise);
|
||||
BridgeInbox existing = existing(envelope, envelope.payloadHash());
|
||||
existing.setOccurredAt(Instant.parse("2026-07-29T08:30:00.123Z"));
|
||||
when(store.findByEventId(envelope.eventId())).thenReturn(existing);
|
||||
|
||||
assertThat(service.receive(envelope).result()).isEqualTo("重复");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 相同事件但摘要不同时拒绝覆盖() {
|
||||
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
||||
when(store.findByEventId(envelope.eventId())).thenReturn(existing(envelope, "sha256:" + "0".repeat(64)));
|
||||
|
||||
assertThatThrownBy(() -> service.receive(envelope))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("拒绝覆盖");
|
||||
assertThat(receiveCount("CONFLICT")).isEqualTo(1.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 相同事件和摘要但聚合标识不同时拒绝覆盖() {
|
||||
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
||||
EventEnvelope conflicting = new EventEnvelope(envelope.eventId(), envelope.eventType(), envelope.eventVersion(),
|
||||
envelope.occurredAt(), envelope.sourceRegion(), envelope.targetRegion(), envelope.tenantId(),
|
||||
envelope.aggregateType(), "DIFFERENT", envelope.aggregateVersion(), envelope.handoverVersion(),
|
||||
envelope.traceId(), envelope.correlationId(), envelope.causationId(), envelope.payload(), envelope.payloadHash());
|
||||
when(store.findByEventId(conflicting.eventId())).thenReturn(existing(envelope, envelope.payloadHash()));
|
||||
|
||||
assertThatThrownBy(() -> service.receive(conflicting))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("事件信封");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 相同业务eventKey的新eventId只允许同负载重放并拒绝柜号漂移() {
|
||||
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
||||
BridgeInbox existing = existing(envelope, envelope.payloadHash());
|
||||
when(store.findByEventId(envelope.eventId())).thenReturn(null);
|
||||
when(store.findByIdempotencyKey("CN:flow-1")).thenReturn(existing);
|
||||
EventEnvelope replay = new EventEnvelope("550e8400-e29b-41d4-a716-446655440001",
|
||||
envelope.eventType(), envelope.eventVersion(), Instant.parse("2026-07-29T09:00:00Z"),
|
||||
envelope.sourceRegion(), envelope.targetRegion(), envelope.tenantId(), envelope.aggregateType(),
|
||||
envelope.aggregateId(), envelope.aggregateVersion(), envelope.handoverVersion(), "trace-2",
|
||||
envelope.correlationId(), null, envelope.payload(), envelope.payloadHash());
|
||||
assertThat(service.receive(replay).result()).isEqualTo("重复");
|
||||
|
||||
com.fasterxml.jackson.databind.node.ObjectNode driftPayload =
|
||||
(com.fasterxml.jackson.databind.node.ObjectNode) envelope.payload().deepCopy();
|
||||
driftPayload.put("containerNo", "DIFFERENT");
|
||||
String driftHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(driftPayload));
|
||||
EventEnvelope drift = new EventEnvelope("550e8400-e29b-41d4-a716-446655440002",
|
||||
envelope.eventType(), envelope.eventVersion(), envelope.occurredAt(), envelope.sourceRegion(),
|
||||
envelope.targetRegion(), envelope.tenantId(), envelope.aggregateType(), envelope.aggregateId(),
|
||||
envelope.aggregateVersion(), envelope.handoverVersion(), "trace-3", envelope.correlationId(),
|
||||
null, driftPayload, driftHash);
|
||||
assertThatThrownBy(() -> service.receive(drift))
|
||||
.hasMessageContaining("eventKey").hasMessageContaining("漂移");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 并发插入冲突后按既有记录判定为重复() {
|
||||
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
||||
when(store.findByEventId(envelope.eventId())).thenReturn(null, existing(envelope, envelope.payloadHash()));
|
||||
doThrow(new DuplicateKeyException("并发重复")).when(store).insert(any());
|
||||
|
||||
assertThat(service.receive(envelope).result()).isEqualTo("重复");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 目标区域与当前部署区域不一致时拒绝接收() {
|
||||
EventInboxService cnService = service("CN");
|
||||
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
||||
|
||||
assertThatThrownBy(() -> cnService.receive(envelope))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("目标区域");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 本地单实例模式允许接收不同目标区域事件() {
|
||||
EventInboxService localService = service("CN", true);
|
||||
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
||||
|
||||
assertThat(localService.receive(envelope).result()).isEqualTo("已接收");
|
||||
verify(store).insert(any(BridgeInbox.class));
|
||||
}
|
||||
|
||||
private EventEnvelope envelope(String hash) {
|
||||
return new EventEnvelope("550e8400-e29b-41d4-a716-446655440000", "OrderApproved", 1,
|
||||
Instant.parse("2026-07-29T08:30:00Z"), RegionCode.CN, RegionCode.OVERSEAS,
|
||||
"20001", "ORDER", "90001", 1, null, "trace-1", "flow-1", null,
|
||||
objectMapper.createObjectNode().put("orderId", "90001"), hash);
|
||||
}
|
||||
|
||||
private EventInboxService service(String region) {
|
||||
return service(region, false);
|
||||
}
|
||||
|
||||
private EventInboxService service(String region, boolean acceptAnyRegion) {
|
||||
EventInboxService service = new EventInboxService();
|
||||
ReflectionTestUtils.setField(service, "inboxStore", store);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
|
||||
ReflectionTestUtils.setField(service, "metrics", metrics);
|
||||
ReflectionTestUtils.setField(service, "localRegion", region);
|
||||
ReflectionTestUtils.setField(service, "acceptAnyRegion", acceptAnyRegion);
|
||||
return service;
|
||||
}
|
||||
|
||||
private double receiveCount(String result) {
|
||||
return registry.get(EventBridgeMetrics.RECEIVE_METRIC)
|
||||
.tags("target_region", "OVERSEAS", "event_type", "OrderApproved", "result", result)
|
||||
.counter().count();
|
||||
}
|
||||
|
||||
private EventEnvelope withHash(EventEnvelope source) {
|
||||
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(source.payload()));
|
||||
return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(),
|
||||
source.sourceRegion(), source.targetRegion(), source.tenantId(), source.aggregateType(),
|
||||
source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), source.traceId(),
|
||||
source.correlationId(), source.causationId(), source.payload(), hash);
|
||||
}
|
||||
|
||||
private BridgeInbox existing(EventEnvelope envelope, String hash) {
|
||||
BridgeInbox inbox = new BridgeInbox();
|
||||
inbox.setEventId(envelope.eventId());
|
||||
inbox.setEventType(envelope.eventType());
|
||||
inbox.setEventVersion(envelope.eventVersion());
|
||||
inbox.setSourceRegion(envelope.sourceRegion().name());
|
||||
inbox.setTargetRegion(envelope.targetRegion().name());
|
||||
inbox.setCustomerId(envelope.tenantId());
|
||||
inbox.setAggregateType(envelope.aggregateType());
|
||||
inbox.setAggregateId(envelope.aggregateId());
|
||||
inbox.setAggregateVersion(envelope.aggregateVersion());
|
||||
inbox.setHandoverVersion(envelope.handoverVersion());
|
||||
inbox.setOccurredAt(envelope.occurredAt());
|
||||
inbox.setTraceId(envelope.traceId());
|
||||
inbox.setCorrelationId(envelope.correlationId());
|
||||
inbox.setCausationId(envelope.causationId());
|
||||
inbox.setPayloadHash(hash);
|
||||
inbox.setReceivedAt(LocalDateTime.of(2026, 7, 29, 18, 0));
|
||||
return inbox;
|
||||
}
|
||||
}
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper;
|
||||
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
|
||||
import com.baiyue.eventbridge.metrics.EventBridgeMetrics;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class EventReplayServiceTest {
|
||||
private final BridgeInboxMapper mapper = mock(BridgeInboxMapper.class);
|
||||
private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class);
|
||||
private final SimpleMeterRegistry registry = new SimpleMeterRegistry();
|
||||
private final EventBridgeMetrics metrics = new EventBridgeMetrics(registry);
|
||||
private final EventReplayService service = service();
|
||||
|
||||
@Test
|
||||
void requeuesDeadEventWithAuditReason() {
|
||||
when(mapper.replayDead(eq("event-1"), contains("incident-42"))).thenReturn(1);
|
||||
BridgeInbox event = new BridgeInbox();
|
||||
event.setEventId("event-1"); event.setSourceRegion("CN"); event.setTargetRegion("OVERSEAS");
|
||||
event.setEventType(com.baiyue.common.event.EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED);
|
||||
when(mapper.selectById("event-1")).thenReturn(event);
|
||||
when(mapper.nextAttemptNo("event-1")).thenReturn(4);
|
||||
|
||||
service.replayDead("event-1", "operator-7", "incident-42 approved");
|
||||
|
||||
verify(mapper).replayDead(eq("event-1"), contains("operator-7"));
|
||||
var captor = org.mockito.ArgumentCaptor.forClass(BridgeDeliveryAttempt.class);
|
||||
verify(attemptMapper).insert(captor.capture());
|
||||
assertThat(captor.getValue().getAttemptNo()).isEqualTo(4);
|
||||
assertThat(captor.getValue().getStatus()).isEqualTo("MANUAL_REPLAY");
|
||||
assertThat(captor.getValue().getErrorMessage()).contains("operator-7", "incident-42");
|
||||
assertThat(registry.get(EventBridgeMetrics.TRANSPORT_METRIC)
|
||||
.tags("target_region", "OVERSEAS", "event_type", event.getEventType(),
|
||||
"result", "MANUAL_REPLAY").counter().count()).isEqualTo(1.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsEventThatIsNotDead() {
|
||||
when(mapper.replayDead(anyString(), anyString())).thenReturn(0);
|
||||
|
||||
assertThatThrownBy(() -> service.replayDead("event-1", "operator-7", "retry"))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void replayOnlyRequeuesDeadStateAndDoesNotRewriteFrozenEvent() throws Exception {
|
||||
String sql = String.join(" ", BridgeInboxMapper.class
|
||||
.getMethod("replayDead", String.class, String.class)
|
||||
.getAnnotation(Update.class).value()).replaceAll("\\s+", " ");
|
||||
|
||||
assertThat(sql).contains("status='RETRY'", "retry_count=0", "status='DEAD'")
|
||||
.doesNotContain("payload_json=", "payload_hash=");
|
||||
}
|
||||
|
||||
private EventReplayService service() {
|
||||
EventReplayService service = new EventReplayService();
|
||||
ReflectionTestUtils.setField(service, "inboxMapper", mapper);
|
||||
ReflectionTestUtils.setField(service, "attemptMapper", attemptMapper);
|
||||
ReflectionTestUtils.setField(service, "metrics", metrics);
|
||||
return service;
|
||||
}
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class HttpFinanceEventClientTest {
|
||||
@Test
|
||||
void walletConsumerUrlCanBeConfiguredForContainerDeployment() throws Exception {
|
||||
String application = Files.readString(Path.of("src/main/resources/application.yml"));
|
||||
|
||||
assertThat(application).contains(
|
||||
"finance-wallet-consumer-url: ${FINANCE_WALLET_EVENT_CONSUMER_URL:"
|
||||
+ "http://127.0.0.1:9203/internal/finance/v1/wallet-command-events}");
|
||||
}
|
||||
}
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class HttpFirstlegEventClientTest {
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void businessFailureIsDiagnosableAndMustNotBeTreatedAsAcknowledged() throws Exception {
|
||||
var body = objectMapper.readTree("""
|
||||
{"code":"A0409","message":"事件摘要冲突","requestId":"request-cn-1","traceId":"trace-cn-1"}
|
||||
""");
|
||||
|
||||
assertThat(HttpFirstlegEventClient.failureMessage(200, body))
|
||||
.isEqualTo("头程消费失败: HTTP 200, code=A0409, message=事件摘要冲突, requestId=request-cn-1, traceId=trace-cn-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonJsonFailureIsStillDiagnosableWithoutRawBody() {
|
||||
assertThat(HttpFirstlegEventClient.failureMessage(503, null))
|
||||
.isEqualTo("头程消费失败: HTTP 503, code=missing, message=non-json response, requestId=missing, traceId=missing");
|
||||
}
|
||||
}
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class HttpLastlegEventClientTest {
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void failureIncludesBusinessCodeMessageAndTraceEvidence() throws Exception {
|
||||
var body = objectMapper.readTree("""
|
||||
{"code":"A0400","message":"入仓计划数据无效","requestId":"request-1","traceId":"trace-1"}
|
||||
""");
|
||||
|
||||
assertThat(HttpLastlegEventClient.failureMessage(200, body))
|
||||
.isEqualTo("尾程消费失败: HTTP 200, code=A0400, message=入仓计划数据无效, requestId=request-1, traceId=trace-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonJsonFailureIsStillDiagnosableWithoutLeakingRawBody() {
|
||||
assertThat(HttpLastlegEventClient.failureMessage(502, null))
|
||||
.isEqualTo("尾程消费失败: HTTP 502, code=missing, message=non-json response, requestId=missing, traceId=missing");
|
||||
}
|
||||
}
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class HttpSecurityProjectionEventClientTest {
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void onlyMatchingLastlegAcknowledgementCompletesBridgeDelivery() throws Exception {
|
||||
var acknowledged = objectMapper.readTree("""
|
||||
{"code":"200","data":{"eventId":"event-1","result":"APPLIED"}}
|
||||
""");
|
||||
var businessFailure = objectMapper.readTree("""
|
||||
{"code":"A0409","message":"payloadHash 冲突","data":{"eventId":"event-1"}}
|
||||
""");
|
||||
var wrongEvent = objectMapper.readTree("""
|
||||
{"code":"200","data":{"eventId":"event-2","result":"DUPLICATE"}}
|
||||
""");
|
||||
|
||||
assertThatCode(() -> HttpSecurityProjectionEventClient.requireAcknowledgement(
|
||||
200, acknowledged, "event-1")).doesNotThrowAnyException();
|
||||
assertThatThrownBy(() -> HttpSecurityProjectionEventClient.requireAcknowledgement(
|
||||
200, businessFailure, "event-1")).isInstanceOf(IllegalStateException.class);
|
||||
assertThatThrownBy(() -> HttpSecurityProjectionEventClient.requireAcknowledgement(
|
||||
200, wrongEvent, "event-1")).isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultConsumerUrlTargetsLastlegSecurityEndpoint() throws Exception {
|
||||
String application = Files.readString(Path.of("src/main/resources/application.yml"));
|
||||
|
||||
assertThat(application).contains(
|
||||
"LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/security/v1/events")
|
||||
.doesNotContain("/internal/overseas-security/v1/events");
|
||||
}
|
||||
}
|
||||
-313
@@ -1,313 +0,0 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.eventbridge.config.EventDispatcherProperties;
|
||||
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper;
|
||||
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
|
||||
import com.baiyue.eventbridge.metrics.EventBridgeMetrics;
|
||||
import com.baiyue.common.web.CorrelationContext;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.slf4j.MDC;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class OverseasEventDispatcherTest {
|
||||
private final BridgeInboxMapper inboxMapper = mock(BridgeInboxMapper.class);
|
||||
private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class);
|
||||
private final com.baiyue.eventbridge.service.LastlegEventClient eventClient =
|
||||
mock(com.baiyue.eventbridge.service.LastlegEventClient.class);
|
||||
private final com.baiyue.eventbridge.service.FirstlegEventClient firstlegEventClient =
|
||||
mock(com.baiyue.eventbridge.service.FirstlegEventClient.class);
|
||||
private final FinanceEventClient financeEventClient = mock(FinanceEventClient.class);
|
||||
private final SecurityProjectionEventClient securityProjectionEventClient = mock(SecurityProjectionEventClient.class);
|
||||
private OverseasEventDispatcher dispatcher;
|
||||
private BridgeInbox event;
|
||||
private SimpleMeterRegistry registry;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
EventDispatcherProperties properties = new EventDispatcherProperties(
|
||||
URI.create("https://lastleg.internal/events"), 50, 3,
|
||||
Duration.ofSeconds(30), Duration.ofSeconds(5), Duration.ofMinutes(1),
|
||||
Duration.ofSeconds(3), Duration.ofSeconds(10));
|
||||
dispatcher = new OverseasEventDispatcher();
|
||||
registry = new SimpleMeterRegistry();
|
||||
ReflectionTestUtils.setField(dispatcher, "inboxMapper", inboxMapper);
|
||||
ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper);
|
||||
ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient);
|
||||
ReflectionTestUtils.setField(dispatcher, "firstlegEventClient", firstlegEventClient);
|
||||
ReflectionTestUtils.setField(dispatcher, "financeEventClient", financeEventClient);
|
||||
ReflectionTestUtils.setField(dispatcher, "securityProjectionEventClient", securityProjectionEventClient);
|
||||
ReflectionTestUtils.setField(dispatcher, "properties", properties);
|
||||
ReflectionTestUtils.setField(dispatcher, "metrics", new EventBridgeMetrics(registry));
|
||||
event = new BridgeInbox();
|
||||
event.setEventId("event-1");
|
||||
event.setEventType(EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED);
|
||||
event.setSourceRegion("CN");
|
||||
event.setTargetRegion("OVERSEAS");
|
||||
event.setRetryCount(0);
|
||||
event.setReceivedAt(LocalDateTime.now().minusSeconds(5));
|
||||
when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1);
|
||||
when(inboxMapper.selectById("event-1")).thenReturn(event);
|
||||
when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void successfulDeliveryIsConfirmedAndAudited() throws Exception {
|
||||
event.setTraceId("550e8400-e29b-41d4-a716-446655440000");
|
||||
when(eventClient.deliver(event)).thenAnswer(invocation -> {
|
||||
assertThat(MDC.get(CorrelationContext.TRACE_ID))
|
||||
.isEqualTo("550e8400e29b41d4a716446655440000");
|
||||
return "200";
|
||||
});
|
||||
when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1);
|
||||
|
||||
dispatcher.dispatchOne("event-1");
|
||||
|
||||
verify(inboxMapper).markProcessed(eq("event-1"), anyString());
|
||||
verify(eventClient).deliver(event);
|
||||
verifyNoInteractions(securityProjectionEventClient);
|
||||
assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED");
|
||||
assertThat(transportCount("DELIVERED")).isEqualTo(1.0);
|
||||
assertThat(registry.get(EventBridgeMetrics.DELIVERY_LATENCY_METRIC).timer().count()).isEqualTo(1);
|
||||
assertThat(MDC.get(CorrelationContext.TRACE_ID)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedDeliveryIsScheduledForRetry() throws Exception {
|
||||
when(eventClient.deliver(event)).thenThrow(new IllegalStateException("network down"));
|
||||
when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down")))
|
||||
.thenReturn(1);
|
||||
|
||||
dispatcher.dispatchOne("event-1");
|
||||
|
||||
verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), eq(5L), eq("network down"));
|
||||
assertThat(updatedAttempt().getStatus()).isEqualTo("RETRY");
|
||||
assertThat(transportCount("RETRY")).isEqualTo(1.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void finalFailureIsMovedToDeadState() throws Exception {
|
||||
event.setRetryCount(2);
|
||||
when(eventClient.deliver(event)).thenThrow(new IllegalStateException("still down"));
|
||||
when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("still down")))
|
||||
.thenReturn(1);
|
||||
|
||||
dispatcher.dispatchOne("event-1");
|
||||
|
||||
verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), eq(20L), eq("still down"));
|
||||
assertThat(updatedAttempt().getStatus()).isEqualTo("DEAD");
|
||||
assertThat(transportCount("DEAD")).isEqualTo(1.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void exponentialBackoffIsCappedAtConfiguredMaximum() throws Exception {
|
||||
event.setRetryCount(4);
|
||||
when(eventClient.deliver(event)).thenThrow(new IllegalStateException("still down"));
|
||||
when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("still down")))
|
||||
.thenReturn(1);
|
||||
|
||||
dispatcher.dispatchOne("event-1");
|
||||
|
||||
verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), eq(60L), eq("still down"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void eventClaimedByAnotherInstanceIsSkipped() {
|
||||
when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(0);
|
||||
|
||||
dispatcher.dispatchOne("event-1");
|
||||
|
||||
verifyNoInteractions(eventClient);
|
||||
verifyNoInteractions(firstlegEventClient);
|
||||
verify(attemptMapper, never()).insert(any(BridgeDeliveryAttempt.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cnTargetIsDeliveredToFirstleg() throws Exception {
|
||||
event.setEventType(EventTypes.OVERSEAS_CONTAINER_UNLOADED);
|
||||
event.setSourceRegion("OVERSEAS");
|
||||
event.setTargetRegion("CN");
|
||||
when(firstlegEventClient.deliver(event)).thenReturn("200");
|
||||
when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1);
|
||||
|
||||
dispatcher.dispatchOne("event-1");
|
||||
|
||||
verify(firstlegEventClient).deliver(event);
|
||||
verifyNoInteractions(eventClient);
|
||||
verifyNoInteractions(securityProjectionEventClient);
|
||||
assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void overseasFeeSourceIsDeliveredToFinanceAndNeverFirstleg() throws Exception {
|
||||
event.setEventType(EventTypes.BUSINESS_FEE_SOURCE_CHANGED);
|
||||
event.setSourceRegion("OVERSEAS");
|
||||
event.setTargetRegion("CN");
|
||||
when(financeEventClient.deliver(event)).thenReturn("200");
|
||||
when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1);
|
||||
|
||||
dispatcher.dispatchOne("event-1");
|
||||
|
||||
verify(financeEventClient).deliver(event);
|
||||
verifyNoInteractions(firstlegEventClient);
|
||||
verifyNoInteractions(eventClient);
|
||||
assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void iamSecurityEventsAreDeliveredToSecurityProjectionAndNeverLastleg() throws Exception {
|
||||
for (String eventType : List.of(EventTypes.IAM_USER_STATUS_CHANGED,
|
||||
EventTypes.IAM_TENANT_STATUS_CHANGED, EventTypes.IAM_PERMISSION_VERSION_CHANGED)) {
|
||||
reset(inboxMapper, attemptMapper, eventClient, firstlegEventClient, securityProjectionEventClient);
|
||||
event.setEventType(eventType);
|
||||
when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1);
|
||||
when(inboxMapper.selectById("event-1")).thenReturn(event);
|
||||
when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1);
|
||||
when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1);
|
||||
when(securityProjectionEventClient.deliver(event)).thenReturn("200");
|
||||
|
||||
dispatcher.dispatchOne("event-1");
|
||||
|
||||
verify(securityProjectionEventClient).deliver(event);
|
||||
verifyNoInteractions(eventClient);
|
||||
verifyNoInteractions(firstlegEventClient);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsupportedTargetRegionIsNeverMisroutedToLastleg() {
|
||||
event.setTargetRegion("UNKNOWN");
|
||||
when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), contains("不支持")))
|
||||
.thenReturn(1);
|
||||
|
||||
dispatcher.dispatchOne("event-1");
|
||||
|
||||
verifyNoInteractions(eventClient);
|
||||
verifyNoInteractions(firstlegEventClient);
|
||||
verifyNoInteractions(securityProjectionEventClient);
|
||||
assertThat(updatedAttempt().getStatus()).isEqualTo("RETRY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingEventTypeFailsWithExplicitDiagnosticAndIsNeverDelivered() {
|
||||
event.setEventType(null);
|
||||
when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(),
|
||||
eq("事件类型不能为空,无法选择下游消费者"))).thenReturn(1);
|
||||
|
||||
dispatcher.dispatchOne("event-1");
|
||||
|
||||
verifyNoInteractions(eventClient);
|
||||
verifyNoInteractions(firstlegEventClient);
|
||||
verifyNoInteractions(securityProjectionEventClient);
|
||||
BridgeDeliveryAttempt attempt = updatedAttempt();
|
||||
assertThat(attempt.getStatus()).isEqualTo("RETRY");
|
||||
assertThat(attempt.getErrorMessage()).isEqualTo("事件类型不能为空,无法选择下游消费者");
|
||||
}
|
||||
|
||||
@Test
|
||||
void acknowledgedDeliveryWithLostLeaseIsNotRecordedAsRetryOrDead() throws Exception {
|
||||
when(eventClient.deliver(event)).thenReturn("200");
|
||||
when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(0);
|
||||
|
||||
dispatcher.dispatchOne("event-1");
|
||||
|
||||
verify(inboxMapper, never()).markFailed(anyString(), anyString(), anyInt(), anyLong(), anyString());
|
||||
BridgeDeliveryAttempt attempt = updatedAttempt();
|
||||
assertThat(attempt.getStatus()).isEqualTo("ACKNOWLEDGED_LEASE_LOST");
|
||||
assertThat(attempt.getResponseCode()).isEqualTo("200");
|
||||
assertThat(attempt.getErrorMessage()).contains("租约已失效");
|
||||
assertThat(transportCount("ACKNOWLEDGED_LEASE_LOST")).isEqualTo(1.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedDeliveryAfterLeaseTakeoverDoesNotOverwriteNewOwnerState() throws Exception {
|
||||
when(eventClient.deliver(event)).thenThrow(new IllegalStateException("network down"));
|
||||
when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down")))
|
||||
.thenReturn(0);
|
||||
|
||||
dispatcher.dispatchOne("event-1");
|
||||
|
||||
assertThat(updatedAttempt().getStatus()).isEqualTo("FAILED_LEASE_LOST");
|
||||
assertThat(transportCount("FAILED_LEASE_LOST")).isEqualTo(1.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void downstream503RetriesThenDeliversWithDiagnosticAttemptsInBothDirections() throws Exception {
|
||||
for (String target : List.of("OVERSEAS", "CN")) {
|
||||
reset(inboxMapper, attemptMapper, eventClient, firstlegEventClient);
|
||||
event.setSourceRegion("CN".equals(target) ? "OVERSEAS" : "CN");
|
||||
event.setTargetRegion(target);
|
||||
event.setRetryCount(0);
|
||||
when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1);
|
||||
when(inboxMapper.selectById("event-1")).thenReturn(event);
|
||||
when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1, 2);
|
||||
doAnswer(invocation -> {
|
||||
event.setRetryCount(1);
|
||||
event.setStatus("RETRY");
|
||||
event.setLastError(invocation.getArgument(4));
|
||||
event.setNextRetryAt(java.time.LocalDateTime.now().plusSeconds(
|
||||
((Number) invocation.getArgument(3)).longValue()));
|
||||
return 1;
|
||||
}).when(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), anyLong(), anyString());
|
||||
when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenAnswer(invocation -> {
|
||||
event.setStatus("PROCESSED");
|
||||
event.setLastError(null);
|
||||
event.setNextRetryAt(null);
|
||||
return 1;
|
||||
});
|
||||
if ("CN".equals(target)) {
|
||||
when(firstlegEventClient.deliver(event))
|
||||
.thenThrow(new IllegalStateException("HTTP 503 code=S0503 requestId=req-cn traceId=trace-cn"))
|
||||
.thenReturn("200");
|
||||
} else {
|
||||
when(eventClient.deliver(event))
|
||||
.thenThrow(new IllegalStateException("HTTP 503 code=S0503 requestId=req-overseas traceId=trace-overseas"))
|
||||
.thenReturn("200");
|
||||
}
|
||||
|
||||
dispatcher.dispatchOne("event-1");
|
||||
assertThat(event.getStatus()).isEqualTo("RETRY");
|
||||
assertThat(event.getNextRetryAt()).isNotNull();
|
||||
assertThat(event.getLastError()).contains("HTTP 503", "requestId=", "traceId=");
|
||||
dispatcher.dispatchOne("event-1");
|
||||
|
||||
ArgumentCaptor<BridgeDeliveryAttempt> attempts = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class);
|
||||
verify(attemptMapper, times(2)).updateById(attempts.capture());
|
||||
assertThat(attempts.getAllValues()).extracting(BridgeDeliveryAttempt::getAttemptNo)
|
||||
.containsExactly(1, 2);
|
||||
assertThat(attempts.getAllValues()).extracting(BridgeDeliveryAttempt::getStatus)
|
||||
.containsExactly("RETRY", "DELIVERED");
|
||||
assertThat(attempts.getAllValues().get(0).getErrorMessage())
|
||||
.contains("HTTP 503", "requestId=", "traceId=");
|
||||
assertThat(event.getStatus()).isEqualTo("PROCESSED");
|
||||
assertThat(event.getLastError()).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
private BridgeDeliveryAttempt updatedAttempt() {
|
||||
ArgumentCaptor<BridgeDeliveryAttempt> captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class);
|
||||
verify(attemptMapper).updateById(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
private double transportCount(String result) {
|
||||
return registry.get(EventBridgeMetrics.TRANSPORT_METRIC)
|
||||
.tags("target_region", event.getTargetRegion(),
|
||||
"event_type", event.getEventType(), "result", result)
|
||||
.counter().count();
|
||||
}
|
||||
}
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
package com.baiyue.eventbridge.trace;
|
||||
|
||||
import com.baiyue.common.web.CorrelationContext;
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.MDC;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpRequest;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class EventBridgeTracePropagationTest {
|
||||
|
||||
@AfterEach
|
||||
void clearMdc() {
|
||||
MDC.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void envelopeUuidFlowsThroughMdcAndRawHttpAsAChildSpan() {
|
||||
BridgeInbox event = new BridgeInbox();
|
||||
event.setTraceId("550e8400-e29b-41d4-a716-446655440000");
|
||||
event.setCorrelationId("ORDER:customer-99:booking-123");
|
||||
event.setEventId("event-business-id");
|
||||
|
||||
try (EventBridgeTraceScope ignored = EventBridgeTraceScope.open(event)) {
|
||||
String localSpan = MDC.get(CorrelationContext.SPAN_ID);
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create("http://127.0.0.1/internal"))
|
||||
.header(CorrelationContext.REQUEST_ID_HEADER, event.getEventId())
|
||||
.header(CorrelationContext.TRACE_ID_HEADER, event.getCorrelationId())
|
||||
.header(CorrelationContext.TRACE_PARENT_HEADER,
|
||||
"00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00");
|
||||
HttpRequest request = new EventBridgeHttpTracePropagation().apply(builder).GET().build();
|
||||
|
||||
assertThat(MDC.get(CorrelationContext.TRACE_ID))
|
||||
.isEqualTo("550e8400e29b41d4a716446655440000");
|
||||
assertThat(request.headers().firstValue(CorrelationContext.TRACE_ID_HEADER))
|
||||
.contains("550e8400e29b41d4a716446655440000");
|
||||
assertThat(request.headers().firstValue(CorrelationContext.REQUEST_ID_HEADER).orElseThrow())
|
||||
.matches("[0-9a-f-]{36}").isNotEqualTo(event.getEventId());
|
||||
assertThat(request.headers().firstValue(CorrelationContext.TRACE_PARENT_HEADER).orElseThrow())
|
||||
.matches("00-550e8400e29b41d4a716446655440000-[0-9a-f]{16}-01")
|
||||
.doesNotContain(localSpan);
|
||||
}
|
||||
|
||||
assertThat(MDC.get(CorrelationContext.TRACE_ID)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidLegacyEnvelopeTraceIsSafelyRebuiltAndPreviousMdcIsRestored() {
|
||||
MDC.put("existing", "preserved");
|
||||
BridgeInbox event = new BridgeInbox();
|
||||
event.setTraceId("JWT-or-business-id-value");
|
||||
|
||||
try (EventBridgeTraceScope ignored = EventBridgeTraceScope.open(event)) {
|
||||
assertThat(MDC.get(CorrelationContext.TRACE_ID)).matches("[0-9a-f]{32}")
|
||||
.isNotEqualTo(event.getTraceId());
|
||||
}
|
||||
|
||||
assertThat(MDC.get("existing")).isEqualTo("preserved");
|
||||
assertThat(MDC.get(CorrelationContext.TRACE_ID)).isNull();
|
||||
}
|
||||
}
|
||||
@@ -21,13 +21,14 @@ public class ExportProviderClient {
|
||||
@Resource private WarehouseExportProviderClient warehouse;
|
||||
@Resource private FirstlegExportProviderClient firstleg;
|
||||
@Resource private FinanceExportProviderClient finance;
|
||||
@Resource private LastlegExportProviderClient lastleg;
|
||||
@Resource
|
||||
private MeterRegistry meters;
|
||||
@PostConstruct
|
||||
void initializeClients() {
|
||||
this.clients = Map.of("iam-service", iam, "order-service", order,
|
||||
"warehouse-service", warehouse, "firstleg-service", firstleg,
|
||||
"finance-service", finance);
|
||||
"finance-service", finance, "lastleg-service", lastleg);
|
||||
}
|
||||
|
||||
public ApiResponse<ExportBatchResponse> batch(String internalToken, String exportType,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.baiyue.file.client;
|
||||
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
|
||||
@FeignClient(name="lastleg-service", contextId="lastlegExportProviderClient",
|
||||
url="${baiyue.clients.lastleg.url:}", configuration=ExportProviderFeignConfiguration.class)
|
||||
interface LastlegExportProviderClient extends ExportProviderFeignClient { }
|
||||
@@ -11,7 +11,7 @@ import java.util.regex.Pattern;
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "baiyue.file.export")
|
||||
public class ExportDefinitionRegistry {
|
||||
private static final Set<String> SERVICES = Set.of("iam-service", "order-service", "warehouse-service", "firstleg-service", "finance-service");
|
||||
private static final Set<String> SERVICES = Set.of("iam-service", "order-service", "warehouse-service", "firstleg-service", "finance-service", "lastleg-service");
|
||||
private static final Pattern TYPE = Pattern.compile("[A-Z0-9_]+");
|
||||
private static final Pattern PATH = Pattern.compile("/internal/exports/v1/([A-Z0-9_]+)/batches");
|
||||
private static final Pattern PERMISSION = Pattern.compile("[a-z]+(?::[a-z-]+){2,}");
|
||||
|
||||
@@ -46,7 +46,11 @@ public class ExportTaskService {
|
||||
}
|
||||
validateParameters(type, request.parameters());
|
||||
validateTypeParameters(type, request.parameters());
|
||||
String parametersJson = parametersJson(request.parameters());
|
||||
Map<String, Object> parameters = new TreeMap<>(request.parameters() == null ? Map.of() : request.parameters());
|
||||
if ("TRIP_OPERATIONS_LIST".equals(type)) {
|
||||
parameters.put("costVisible", user.permissions().contains("platform:fleet:trip:list"));
|
||||
}
|
||||
String parametersJson = parametersJson(parameters);
|
||||
long id = IdWorker.getId();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
FileExportTask task = new FileExportTask();
|
||||
@@ -92,6 +96,7 @@ public class ExportTaskService {
|
||||
|
||||
public DownloadResource download(AuthenticatedUser user, long id) {
|
||||
FileExportTask task = ownedTask(user, id);
|
||||
requireExportCostAccess(user, task);
|
||||
if (!"SUCCEEDED".equals(task.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "导出任务尚未完成或文件已过期");
|
||||
}
|
||||
@@ -103,6 +108,7 @@ public class ExportTaskService {
|
||||
|
||||
public ExportTaskResponse retry(AuthenticatedUser user, long id) {
|
||||
FileExportTask task = ownedTask(user, id);
|
||||
requireExportCostAccess(user, task);
|
||||
var definition = registry.require(task.getExportType());
|
||||
if (!definition.isEnabled()) throw new BizException(ResultCode.CONFLICT, "该导出类型已停用");
|
||||
String permission = definition.getRequiredPermission();
|
||||
@@ -128,6 +134,18 @@ public class ExportTaskService {
|
||||
return response(repository.find(id));
|
||||
}
|
||||
|
||||
private void requireExportCostAccess(AuthenticatedUser user, FileExportTask task) {
|
||||
if (!"TRIP_OPERATIONS_LIST".equals(task.getExportType())) return;
|
||||
try {
|
||||
if (objectMapper.readTree(task.getParametersJson()).path("costVisible").asBoolean(false)
|
||||
&& !user.permissions().contains("platform:fleet:trip:list")) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "缺少出车成本读取权限,请重新创建不含成本的导出任务");
|
||||
}
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new BizException(ResultCode.CONFLICT, "导出筛选快照无效,请重新创建任务");
|
||||
}
|
||||
}
|
||||
|
||||
private FileExportTask ownedTask(AuthenticatedUser user, long id) {
|
||||
requirePlatform(user);
|
||||
FileExportTask task = repository.find(id);
|
||||
|
||||
@@ -35,6 +35,9 @@ spring:
|
||||
firstlegExportProviderClient:
|
||||
connect-timeout: 3000
|
||||
read-timeout: 15000
|
||||
lastlegExportProviderClient:
|
||||
connect-timeout: 3000
|
||||
read-timeout: 15000
|
||||
financeExportProviderClient:
|
||||
connect-timeout: 3000
|
||||
read-timeout: 15000
|
||||
@@ -203,4 +206,76 @@ baiyue:
|
||||
max-rows: 200000
|
||||
max-attempts: 3
|
||||
batch-size: 500
|
||||
LAST_MILE_ORDER_LIST:
|
||||
provider-service: lastleg-service
|
||||
provider-path: /internal/exports/v1/LAST_MILE_ORDER_LIST/batches
|
||||
required-permission: platform:overseas-warehouse:last-mile-order:export
|
||||
app-scope: PLATFORM
|
||||
enabled: true
|
||||
max-rows: 200000
|
||||
max-attempts: 3
|
||||
batch-size: 200
|
||||
OVERSEAS_INVENTORY_BALANCE:
|
||||
provider-service: lastleg-service
|
||||
provider-path: /internal/exports/v1/OVERSEAS_INVENTORY_BALANCE/batches
|
||||
required-permission: platform:overseas-warehouse:inventory:export
|
||||
app-scope: PLATFORM
|
||||
enabled: true
|
||||
max-rows: 200000
|
||||
max-attempts: 3
|
||||
batch-size: 200
|
||||
OVERSEAS_INVENTORY_LEDGER:
|
||||
provider-service: lastleg-service
|
||||
provider-path: /internal/exports/v1/OVERSEAS_INVENTORY_LEDGER/batches
|
||||
required-permission: platform:overseas-warehouse:inventory:export
|
||||
app-scope: PLATFORM
|
||||
enabled: true
|
||||
max-rows: 200000
|
||||
max-attempts: 3
|
||||
batch-size: 200
|
||||
OVERSEAS_QUOTE_LIST:
|
||||
provider-service: lastleg-service
|
||||
provider-path: /internal/exports/v1/OVERSEAS_QUOTE_LIST/batches
|
||||
required-permission: platform:overseas-master-data:quote:export
|
||||
app-scope: PLATFORM
|
||||
enabled: true
|
||||
max-rows: 200000
|
||||
max-attempts: 3
|
||||
batch-size: 200
|
||||
MONTHLY_EXCHANGE_RATE_LIST:
|
||||
provider-service: lastleg-service
|
||||
provider-path: /internal/exports/v1/MONTHLY_EXCHANGE_RATE_LIST/batches
|
||||
required-permission: platform:overseas-master-data:exchange-rate:export
|
||||
app-scope: PLATFORM
|
||||
enabled: true
|
||||
max-rows: 200000
|
||||
max-attempts: 3
|
||||
batch-size: 200
|
||||
VEHICLE_LIST:
|
||||
provider-service: lastleg-service
|
||||
provider-path: /internal/exports/v1/VEHICLE_LIST/batches
|
||||
required-permission: platform:overseas-master-data:vehicle:export
|
||||
app-scope: PLATFORM
|
||||
enabled: true
|
||||
max-rows: 200000
|
||||
max-attempts: 3
|
||||
batch-size: 200
|
||||
FLEET_QUOTE_LIST:
|
||||
provider-service: lastleg-service
|
||||
provider-path: /internal/exports/v1/FLEET_QUOTE_LIST/batches
|
||||
required-permission: platform:fleet:quote:export
|
||||
app-scope: PLATFORM
|
||||
enabled: true
|
||||
max-rows: 200000
|
||||
max-attempts: 3
|
||||
batch-size: 200
|
||||
TRIP_OPERATIONS_LIST:
|
||||
provider-service: lastleg-service
|
||||
provider-path: /internal/exports/v1/TRIP_OPERATIONS_LIST/batches
|
||||
required-permission: platform:fleet:operations:export
|
||||
app-scope: PLATFORM
|
||||
enabled: true
|
||||
max-rows: 200000
|
||||
max-attempts: 3
|
||||
batch-size: 200
|
||||
management.endpoints.web.exposure.include: health,info,prometheus
|
||||
|
||||
@@ -25,6 +25,7 @@ class ExportProviderClientTest {
|
||||
@MockBean private WarehouseExportProviderClient warehouse;
|
||||
@MockBean private FirstlegExportProviderClient firstleg;
|
||||
@MockBean private FinanceExportProviderClient finance;
|
||||
@MockBean private LastlegExportProviderClient lastleg;
|
||||
@Resource private ExportProviderClient client;
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@@ -48,6 +49,16 @@ class ExportProviderClientTest {
|
||||
verifyNoInteractions(iam,warehouse,firstleg,finance);
|
||||
}
|
||||
|
||||
@Test void routesLastlegExportsThroughTheBoundedInternalClient() {
|
||||
var response=ApiResponse.success(new ExportBatchResponse(1,"hash","snapshot",java.util.List.of(),java.util.List.of(),null,false,0L));
|
||||
when(lastleg.batch(anyString(),anyString(),any())).thenReturn(response);
|
||||
var definition=new ExportDefinitionRegistry.Definition();definition.setProviderService("lastleg-service");
|
||||
var request=new ExportBatchRequest("9","0","PLATFORM","7",1,Map.of(),null,null,200);
|
||||
assertSame(response,client.batch("token","LAST_MILE_ORDER_LIST",definition,request));
|
||||
verify(lastleg).batch("token","LAST_MILE_ORDER_LIST",request);
|
||||
verifyNoInteractions(iam,order,warehouse,firstleg,finance);
|
||||
}
|
||||
|
||||
@Test void routesFinanceExportsToFinanceService() {
|
||||
var response=ApiResponse.success(new ExportBatchResponse(1,"hash","snapshot",java.util.List.of(),java.util.List.of(),null,false,0L));
|
||||
when(finance.batch(anyString(),anyString(),any())).thenReturn(response);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.baiyue.file.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class LastlegExportDefinitionContractTest {
|
||||
@Test void localAndNacosDefinitionsKeepTheSameBoundedProviderAndPermissions() throws Exception {
|
||||
Map<?, ?> local = definitions(Path.of("src/main/resources/application.yml"));
|
||||
Map<?, ?> nacos = definitions(Path.of("../config/nacos/test/file-service.yml"));
|
||||
Map<String, String> expected = Map.ofEntries(
|
||||
Map.entry("LAST_MILE_ORDER_LIST", "platform:overseas-warehouse:last-mile-order:export"),
|
||||
Map.entry("OVERSEAS_INVENTORY_BALANCE", "platform:overseas-warehouse:inventory:export"),
|
||||
Map.entry("OVERSEAS_INVENTORY_LEDGER", "platform:overseas-warehouse:inventory:export"),
|
||||
Map.entry("OVERSEAS_QUOTE_LIST", "platform:overseas-master-data:quote:export"),
|
||||
Map.entry("MONTHLY_EXCHANGE_RATE_LIST", "platform:overseas-master-data:exchange-rate:export"),
|
||||
Map.entry("VEHICLE_LIST", "platform:overseas-master-data:vehicle:export"),
|
||||
Map.entry("FLEET_QUOTE_LIST", "platform:fleet:quote:export"),
|
||||
Map.entry("TRIP_OPERATIONS_LIST", "platform:fleet:operations:export"));
|
||||
for (var entry : expected.entrySet()) {
|
||||
var definition = (Map<?, ?>) local.get(entry.getKey());
|
||||
assertNotNull(definition, entry.getKey());
|
||||
assertEquals(definition, nacos.get(entry.getKey()));
|
||||
assertEquals("lastleg-service", definition.get("provider-service"));
|
||||
assertEquals("/internal/exports/v1/" + entry.getKey() + "/batches", definition.get("provider-path"));
|
||||
assertEquals(entry.getValue(), definition.get("required-permission"));
|
||||
assertEquals("PLATFORM", definition.get("app-scope"));
|
||||
assertEquals(200, definition.get("batch-size"));
|
||||
var controlled = new ExportDefinitionRegistry.Definition();
|
||||
controlled.setProviderService("lastleg-service");
|
||||
controlled.setProviderPath(String.valueOf(definition.get("provider-path")));
|
||||
controlled.setRequiredPermission(entry.getValue());
|
||||
controlled.setAppScope("PLATFORM");
|
||||
var registry = new ExportDefinitionRegistry();
|
||||
assertDoesNotThrow(() -> registry.setDefinitions(Map.of(entry.getKey(), controlled)));
|
||||
}
|
||||
}
|
||||
|
||||
private Map<?, ?> definitions(Path path) throws Exception {
|
||||
Object value = new Yaml().load(Files.readString(path));
|
||||
for (String key : new String[]{"baiyue", "file", "export", "definitions"}) value = ((Map<?, ?>) value).get(key);
|
||||
return (Map<?, ?>) value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(classes = {ExportTaskService.class, ExportTaskServiceTest.TestConfig.class})
|
||||
@@ -55,6 +56,32 @@ class ExportTaskServiceTest {
|
||||
assertEquals(ResultCode.FORBIDDEN, denied.getResultCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void operationExportDerivesCostVisibilityFromAuthenticatedPermissions() {
|
||||
var definition = definition();
|
||||
definition.setRequiredPermission("platform:fleet:operations:export");
|
||||
when(registry.require("TRIP_OPERATIONS_LIST")).thenReturn(definition);
|
||||
when(states.createConstrained(any(FileExportTask.class))).thenAnswer(invocation ->
|
||||
new ExportStateService.CreationResult(invocation.getArgument(0), true));
|
||||
service.create(user(1L, Set.of("platform:fleet:operations:export")),
|
||||
new CreateExportTaskRequest("TRIP_OPERATIONS_LIST", "XLSX", "出车记录", Map.of("costVisible", true)));
|
||||
var captured = org.mockito.ArgumentCaptor.forClass(FileExportTask.class);
|
||||
verify(states).createConstrained(captured.capture());
|
||||
assertTrue(captured.getValue().getParametersJson().contains("\"costVisible\":false"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void costPermissionCannotBeBypassedByDownloadAllOrRetry() {
|
||||
FileExportTask task = new FileExportTask();
|
||||
task.setId(9L); task.setTenantId(0L); task.setAppCode("PLATFORM"); task.setRequestedBy(2L);
|
||||
task.setExportType("TRIP_OPERATIONS_LIST"); task.setParametersJson("{\"costVisible\":true}");
|
||||
when(repository.find(9L)).thenReturn(task);
|
||||
var reader = user(1L, Set.of("platform:download:all", "platform:fleet:operations:export"));
|
||||
assertEquals(ResultCode.FORBIDDEN, assertThrows(BizException.class, () -> service.download(reader,9L)).getResultCode());
|
||||
assertEquals(ResultCode.FORBIDDEN, assertThrows(BizException.class, () -> service.retry(reader,9L)).getResultCode());
|
||||
verifyNoInteractions(storage);
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesAnotherUsersTaskUnlessDownloadAllIsGranted() {
|
||||
when(registry.require("CUSTOMER_LIST")).thenReturn(definition());
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
## 模块入口
|
||||
|
||||
表内 `controller/`、`service/`、`mapper/`、`loading/` 等业务包路径相对 `src/main/java/com/baiyue/firstleg/`;`src/`、`docs/`、`scripts/` 和 `../` 开头路径相对本服务目录。裸文件名沿同一条目最近业务包定位,必要时在该包用 `rg --files` 查找,不从全仓猜路径。
|
||||
|
||||
事件人工重试:`controller/EventOperationsController.java` 提供内部 `/internal/firstleg/event-operations/{entryId}` 查询及 `/retry` 写入,只允许装柜完成与船节点事件;独立 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可修改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务提交,脚本见根 `scripts/event-outbox-operations.mjs`。
|
||||
|
||||
柜次号与散货托盘码:`service/FirstlegDocumentNumberGenerator.java` 为新柜生成全局连续且不按日期重置的 `D0001`、`D0002` 柜次号;`service/LoadingPalletLabelService.java`、`mapper/LoadingPalletLabelMapper.java`、`controller/PlatformLoadingListController.java` 负责散货托盘码,契约为 `src/main/resources/openapi/loading-lists-v1.yaml` 的 `/pallet-labels/prepare` 与 `/pallet-labels/print`。仅在计划 `LOADING/COMPLETED` 且真实柜号有效时,为非一件代发的 FBA 明细按平台柜次号、FBA 地址内部编号、三位序号生成冻结标签;托盘码不依赖可跨提单复用的真实柜号,内部编号从 Warehouse 启用主数据读取,缺失或同柜重复时拒绝生成。重复生成须核对已有标签与明细一致,冲突直接拒绝。全部或按仓指定张数打印统一调用 Print `/internal/print/v1/pdfs/fba-pallet-labels`,模板 `FBA_PALLET_A4_LANDSCAPE_V1` 每托一页 297×210 mm,顶部为 SCK8 等 FBA 仓码,条码和底部文字为完整托盘号,与海外补打共用版式;装柜完成通过 `LoadingCompletedOutboxService.java` 传递来源托盘。
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# Overseas Gateway 模块指引
|
||||
|
||||
> 本目录为退役源码,已退出根 POM 和部署映射。下文仅记录旧实现,不是现行规则。当前入口读 `../gateway/AGENTS.md`,认证读 `../auth/AGENTS.md` 与 `../../docs/统一认证与服务通信契约.md`;退役清理按 `../../docs/测试环境单机部署与配置契约.md` 核对实际引用与运行证据。不得恢复双 Token、专用安全投影或重新加入构建。
|
||||
|
||||
## 职责边界
|
||||
|
||||
- 负责海外 API 统一入口、路由、RS256/JWKS 验签、Audience 和必要 Claims 校验。
|
||||
- 不保存签名私钥,不依赖国内 Redis 会话,不在网关实现尾程业务逻辑。
|
||||
- 历史路由与身份实现只用于追溯;现行认证按 `../../docs/统一认证与服务通信契约.md`。
|
||||
|
||||
## 快速索引
|
||||
|
||||
| 功能 | 入口 |
|
||||
|---|---|
|
||||
| 安全链配置 | `src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java` |
|
||||
| Audience 校验 | `config/AudienceJwtValidator.java` |
|
||||
| 必要 Claims 校验 | `config/RequiredClaimsJwtValidator.java` |
|
||||
| PDA 换仓即时失效 | `filter/PdaSessionValidationFilter.java`、`HttpPdaSessionValidationClient.java`、`config/PdaSessionValidationProperties.java`;仅 `/api/pda/**` 本地验签后同步核对 Auth,任何异常 fail-closed |
|
||||
| 海外安全事件即时失效 | `filter/OverseasSecurityProjectionFilter.java`、`HttpOverseasSecurityValidationClient.java`;全部 `/api/**` 本地验签后核对海外安全投影,用户/客户停用与权限版本不一致立即拒绝,并取回当前角色/权限快照 |
|
||||
| 安全配置项 | `config/GatewaySecurityProperties.java` |
|
||||
| 可信身份与授权头传递 | `filter/TrustedIdentityHeaderFilter.java`、`filter/OverseasSecurityProjectionFilter.java`;先清除外部伪造头,再以内部服务令牌保护投影快照 |
|
||||
| 环境配置 | `src/main/resources/application.yml`、`../config/nacos/` |
|
||||
| 浏览器业务路由 | `src/main/resources/application-overseas.yml`;Lastleg 显式覆盖 `/api/merchant/v1/last-leg/**`、`/api/platform/v1/last-leg/**`,Warehouse 仅显式覆盖平台仓库资源路径 |
|
||||
| 海外 PDA 路由 | 设备端 `../../apps/pda-overseas-uniapp/`,服务端入口 `/api/pda/v1/last-leg/**`;必须在 Gateway 路由中显式声明并保持海外 Token/权限校验,不得让设备直连 Lastleg |
|
||||
| 测试 | `src/test/java/com/baiyue/gateway/config/` |
|
||||
|
||||
## 安全规则
|
||||
|
||||
- 生产令牌 `aud` 必须包含 `lastleg-api`,并包含配置要求的身份声明。
|
||||
- 网关必须清除外请求伪造的可信身份与授权头,仅传递验签并核对安全投影后生成的上下文;业务服务必须验证内部服务令牌。
|
||||
- JWKS 地址、Issuer 和 Audience 必须外部配置;不得在源码写死生产密钥。
|
||||
- merchant、platform 与 PDA 路径分别显式维护路由和授权边界;新增 PDA Controller 不会自动成为公网路由,Gateway 未声明前不得把设备链路标记为已联通。
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- 本模块已退出聚合,不能从根使用 `mvn -pl gateway-overseas`;当前路由变更验证 `gateway` 及调用端,执行根 `scripts/gateway-route-contract.test.mjs`。
|
||||
- 安全变更覆盖合法令牌、错误 Audience、缺失 Claims、过期/错误签名和身份头伪造测试。
|
||||
@@ -1,69 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.baiyue.firstleg</groupId>
|
||||
<artifactId>baiyue-firstleg-platform</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
<artifactId>gateway-overseas</artifactId>
|
||||
<name>gateway-overseas</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baiyue.shared</groupId>
|
||||
<artifactId>baiyue-common-core</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-gateway</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.projectreactor</groupId>
|
||||
<artifactId>reactor-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
|
||||
</project>
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
package com.baiyue.gateway;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* API 网关启动入口,承载统一路由、认证校验和可信身份头转发。
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class BaiyueGatewayOverseasApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(BaiyueGatewayOverseasApplication.class, args);
|
||||
}
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
package com.baiyue.gateway.config;
|
||||
|
||||
import org.springframework.security.authorization.AuthorizationDecision;
|
||||
import org.springframework.security.authorization.ReactiveAuthorizationManager;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||
import org.springframework.security.web.server.authorization.AuthorizationContext;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 网关应用范围授权器,根据 JWT 的 {@code app_code} 声明隔离商户端与平台端接口。
|
||||
*/
|
||||
final class AppScopeAuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {
|
||||
private final String expectedAppCode;
|
||||
|
||||
AppScopeAuthorizationManager(String expectedAppCode) {
|
||||
this.expectedAppCode = expectedAppCode;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<AuthorizationDecision> check(Mono<Authentication> authentication,
|
||||
AuthorizationContext context) {
|
||||
return authentication.filter(Authentication::isAuthenticated)
|
||||
.map(this::hasExpectedScope).map(AuthorizationDecision::new)
|
||||
.defaultIfEmpty(new AuthorizationDecision(false));
|
||||
}
|
||||
|
||||
private boolean hasExpectedScope(Authentication authentication) {
|
||||
return authentication instanceof JwtAuthenticationToken jwt
|
||||
&& expectedAppCode.equals(jwt.getToken().getClaimAsString("app_code"));
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package com.baiyue.gateway.config;
|
||||
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
final class AudienceJwtValidator implements OAuth2TokenValidator<Jwt> {
|
||||
private final String requiredAudience;
|
||||
|
||||
AudienceJwtValidator(String requiredAudience) {
|
||||
this.requiredAudience = requiredAudience;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||
if (jwt.getAudience().contains(requiredAudience)) {
|
||||
return OAuth2TokenValidatorResult.success();
|
||||
}
|
||||
return OAuth2TokenValidatorResult.failure(new OAuth2Error(
|
||||
"invalid_token", "JWT audience 不允许访问海外 API", null));
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package com.baiyue.gateway.config;
|
||||
|
||||
import com.baiyue.gateway.filter.CorrelationIdWebFilter;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
|
||||
import org.springframework.security.oauth2.jwt.JwtValidators;
|
||||
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
|
||||
import org.springframework.security.web.server.SecurityWebFilterChain;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 海外网关响应式安全配置,使用国内认证中心公开的 JWKS 验证 JWT。
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties({GatewaySecurityProperties.class, PdaSessionValidationProperties.class,
|
||||
OverseasSecurityProjectionProperties.class})
|
||||
public class GatewaySecurityConfig {
|
||||
@Bean
|
||||
SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
|
||||
GatewaySecurityProperties properties,
|
||||
ObjectMapper objectMapper) {
|
||||
var exchanges = http.authorizeExchange();
|
||||
properties.permitPaths().forEach(path -> exchanges.pathMatchers(path).permitAll());
|
||||
exchanges.pathMatchers("/api/merchant/**")
|
||||
.access(new AppScopeAuthorizationManager("MERCHANT"));
|
||||
exchanges.pathMatchers("/api/platform/**")
|
||||
.access(new AppScopeAuthorizationManager("PLATFORM"));
|
||||
exchanges.anyExchange().authenticated();
|
||||
return http.csrf(ServerHttpSecurity.CsrfSpec::disable)
|
||||
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
|
||||
.formLogin(ServerHttpSecurity.FormLoginSpec::disable)
|
||||
.oauth2ResourceServer(oauth -> oauth.jwt(jwt -> { })
|
||||
.authenticationEntryPoint((exchange, exception) -> writeError(exchange, objectMapper,
|
||||
HttpStatus.UNAUTHORIZED, "A0401", "未登录或令牌已失效")))
|
||||
.exceptionHandling(errors -> errors.accessDeniedHandler((exchange, exception) ->
|
||||
writeError(exchange, objectMapper, HttpStatus.FORBIDDEN, "A0403", "无权访问该资源")))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
ReactiveJwtDecoder reactiveJwtDecoder(GatewaySecurityProperties properties) {
|
||||
NimbusReactiveJwtDecoder decoder = NimbusReactiveJwtDecoder
|
||||
.withJwkSetUri(properties.jwkSetUri())
|
||||
.jwsAlgorithms(algorithms -> {
|
||||
algorithms.add(SignatureAlgorithm.RS256);
|
||||
})
|
||||
.build();
|
||||
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
|
||||
JwtValidators.createDefaultWithIssuer(properties.issuer()),
|
||||
new AudienceJwtValidator(properties.audience()),
|
||||
new RequiredClaimsJwtValidator()));
|
||||
return decoder;
|
||||
}
|
||||
|
||||
public static Mono<Void> writeError(org.springframework.web.server.ServerWebExchange exchange,
|
||||
ObjectMapper mapper, HttpStatus status, String code, String message) {
|
||||
exchange.getResponse().setStatusCode(status);
|
||||
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
|
||||
String requestId = CorrelationIdWebFilter.ensureRequestId(exchange);
|
||||
String traceId = CorrelationIdWebFilter.ensureTraceId(exchange);
|
||||
exchange.getResponse().getHeaders().set(CorrelationIdWebFilter.REQUEST_HEADER, requestId);
|
||||
exchange.getResponse().getHeaders().set(CorrelationIdWebFilter.TRACE_HEADER, traceId);
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("code", code);
|
||||
body.put("message", message);
|
||||
body.put("data", null);
|
||||
body.put("requestId", requestId);
|
||||
body.put("traceId", traceId);
|
||||
body.put("timestamp", Instant.now().toString());
|
||||
try {
|
||||
byte[] bytes = mapper.writeValueAsBytes(body);
|
||||
return exchange.getResponse().writeWith(
|
||||
Mono.just(exchange.getResponse().bufferFactory().wrap(bytes)));
|
||||
} catch (Exception exception) {
|
||||
return exchange.getResponse().setComplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
package com.baiyue.gateway.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 海外网关安全配置,只保存验签所需的签发方和公钥集地址。
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "baiyue.security")
|
||||
public record GatewaySecurityProperties(String jwkSetUri,
|
||||
String issuer,
|
||||
String audience,
|
||||
List<String> permitPaths) {
|
||||
public GatewaySecurityProperties {
|
||||
if (jwkSetUri == null || jwkSetUri.isBlank()) {
|
||||
throw new IllegalArgumentException("必须配置 JWT_JWK_SET_URI");
|
||||
}
|
||||
if (issuer == null || issuer.isBlank()) {
|
||||
throw new IllegalArgumentException("必须配置 JWT_ISSUER");
|
||||
}
|
||||
if (audience == null || audience.isBlank()) {
|
||||
throw new IllegalArgumentException("必须配置 JWT_AUDIENCE");
|
||||
}
|
||||
permitPaths = permitPaths == null ? List.of() : List.copyOf(permitPaths);
|
||||
}
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
package com.baiyue.gateway.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@ConfigurationProperties(prefix = "baiyue.security.overseas-projection")
|
||||
public record OverseasSecurityProjectionProperties(String serviceUrl, String internalServiceToken, Duration timeout) {
|
||||
public OverseasSecurityProjectionProperties {
|
||||
if (serviceUrl == null || serviceUrl.isBlank()) throw new IllegalArgumentException("必须配置 LASTLEG_SECURITY_SERVICE_URL");
|
||||
if (internalServiceToken == null || internalServiceToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) {
|
||||
throw new IllegalArgumentException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节");
|
||||
}
|
||||
timeout = timeout == null || timeout.isNegative() || timeout.isZero() ? Duration.ofSeconds(2) : timeout;
|
||||
serviceUrl = serviceUrl.replaceAll("/+$", "");
|
||||
}
|
||||
}
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
package com.baiyue.gateway.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/** 海外 PDA 实时会话校验配置;URL 与服务令牌只能由环境提供。 */
|
||||
@ConfigurationProperties(prefix = "baiyue.security.pda-session-validation")
|
||||
public record PdaSessionValidationProperties(String authUrl,
|
||||
String internalServiceToken,
|
||||
Duration timeout) {
|
||||
public PdaSessionValidationProperties {
|
||||
if (authUrl == null || authUrl.isBlank()) {
|
||||
throw new IllegalArgumentException("必须配置 AUTH_SESSION_VALIDATION_URL");
|
||||
}
|
||||
if (internalServiceToken == null || internalServiceToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) {
|
||||
throw new IllegalArgumentException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节");
|
||||
}
|
||||
timeout = timeout == null ? Duration.ofSeconds(2) : timeout;
|
||||
if (timeout.isZero() || timeout.isNegative()) {
|
||||
throw new IllegalArgumentException("PDA_SESSION_VALIDATION_TIMEOUT 必须大于 0");
|
||||
}
|
||||
authUrl = authUrl.replaceAll("/+$", "");
|
||||
}
|
||||
}
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
package com.baiyue.gateway.config;
|
||||
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/** Validates the identity and authorization claims required by overseas APIs. */
|
||||
final class RequiredClaimsJwtValidator implements OAuth2TokenValidator<Jwt> {
|
||||
private static final List<String> REQUIRED_CLAIMS = List.of(
|
||||
"sub", "tenant_id", "app_code", "token_version", "permission_version");
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||
for (String claim : REQUIRED_CLAIMS) {
|
||||
Object value = jwt.getClaims().get(claim);
|
||||
if (value == null || value.toString().isBlank()) {
|
||||
return OAuth2TokenValidatorResult.failure(new OAuth2Error(
|
||||
"invalid_token", "JWT 缺少必要声明: " + claim, null));
|
||||
}
|
||||
}
|
||||
Long subject = longValue(jwt.getSubject());
|
||||
Long tenantId = longValue(jwt.getClaims().get("tenant_id"));
|
||||
Long permissionVersion = longValue(jwt.getClaims().get("permission_version"));
|
||||
Long tokenVersion = longValue(jwt.getClaims().get("token_version"));
|
||||
String appCode = String.valueOf(jwt.getClaims().get("app_code")).toUpperCase(Locale.ROOT);
|
||||
if (subject == null || subject <= 0) {
|
||||
return failure("JWT sub 必须是正整数用户 ID");
|
||||
}
|
||||
if (tenantId == null || tenantId < 0) {
|
||||
return failure("JWT tenant_id 必须是非负整数客户范围");
|
||||
}
|
||||
if (permissionVersion == null || permissionVersion < 0) {
|
||||
return failure("JWT permission_version 必须是非负整数");
|
||||
}
|
||||
if (tokenVersion == null || tokenVersion < 0) {
|
||||
return failure("JWT token_version 必须是非负整数");
|
||||
}
|
||||
if (!("PLATFORM".equals(appCode) && tenantId == 0L)
|
||||
&& !("MERCHANT".equals(appCode) && tenantId > 0L)) {
|
||||
return failure("JWT app_code 与 tenant_id 访问域不一致");
|
||||
}
|
||||
return OAuth2TokenValidatorResult.success();
|
||||
}
|
||||
|
||||
private static Long longValue(Object value) {
|
||||
if (value instanceof Number number) return number.longValue();
|
||||
if (value == null) return null;
|
||||
try {
|
||||
return Long.valueOf(value.toString());
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static OAuth2TokenValidatorResult failure(String message) {
|
||||
return OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token", message, null));
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package com.baiyue.gateway.filter;
|
||||
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import org.springframework.web.server.WebFilter;
|
||||
import org.springframework.web.server.WebFilterChain;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/** Ensures overseas security failures carry the same correlation contract as normal API responses. */
|
||||
@Component
|
||||
public class CorrelationIdWebFilter implements WebFilter, Ordered {
|
||||
public static final String REQUEST_HEADER = "X-Request-Id";
|
||||
public static final String TRACE_HEADER = "X-Trace-Id";
|
||||
private static final String REQUEST_ATTRIBUTE = CorrelationIdWebFilter.class.getName() + ".requestId";
|
||||
private static final String TRACE_ATTRIBUTE = CorrelationIdWebFilter.class.getName() + ".traceId";
|
||||
|
||||
@Override
|
||||
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
|
||||
exchange.getResponse().getHeaders().set(REQUEST_HEADER, ensureRequestId(exchange));
|
||||
exchange.getResponse().getHeaders().set(TRACE_HEADER, ensureTraceId(exchange));
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
|
||||
public static String ensureRequestId(ServerWebExchange exchange) {
|
||||
return (String) exchange.getAttributes().computeIfAbsent(REQUEST_ATTRIBUTE,
|
||||
ignored -> accepted(exchange.getRequest().getHeaders(), REQUEST_HEADER));
|
||||
}
|
||||
|
||||
public static String ensureTraceId(ServerWebExchange exchange) {
|
||||
return (String) exchange.getAttributes().computeIfAbsent(TRACE_ATTRIBUTE,
|
||||
ignored -> accepted(exchange.getRequest().getHeaders(), TRACE_HEADER));
|
||||
}
|
||||
|
||||
private static String accepted(HttpHeaders headers, String name) {
|
||||
String incoming = headers.getFirst(name);
|
||||
return valid(incoming) ? incoming : UUID.randomUUID().toString();
|
||||
}
|
||||
|
||||
private static boolean valid(String value) {
|
||||
return value != null && !value.isBlank() && value.length() >= 16 && value.length() <= 128
|
||||
&& value.chars().allMatch(character -> character >= 0x21 && character <= 0x7e);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return Ordered.HIGHEST_PRECEDENCE;
|
||||
}
|
||||
}
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
package com.baiyue.gateway.filter;
|
||||
|
||||
import com.baiyue.gateway.config.OverseasSecurityProjectionProperties;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
@Component
|
||||
public class HttpOverseasSecurityValidationClient implements OverseasSecurityValidationClient {
|
||||
private static final String PATH = "/internal/lastleg/security/v1/validate";
|
||||
private final WebClient client;
|
||||
private final OverseasSecurityProjectionProperties properties;
|
||||
|
||||
public HttpOverseasSecurityValidationClient(WebClient.Builder builder, OverseasSecurityProjectionProperties properties) {
|
||||
this.client = builder.build();
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Result> validate(long userId, long tenantId, long permissionVersion) {
|
||||
return client.post().uri(properties.serviceUrl() + PATH).contentType(MediaType.APPLICATION_JSON)
|
||||
.header("X-Internal-Service-Token", properties.internalServiceToken())
|
||||
.bodyValue(new Request(userId, tenantId, permissionVersion))
|
||||
.retrieve().bodyToMono(JsonNode.class).timeout(properties.timeout()).flatMap(body -> {
|
||||
JsonNode data = body == null ? null : body.path("data");
|
||||
if (data == null || data.isMissingNode() || !data.has("valid")) {
|
||||
return Mono.error(new IllegalStateException("海外安全投影响应无效"));
|
||||
}
|
||||
return Mono.just(new Result(data.path("valid").asBoolean(false),
|
||||
data.path("failureReason").isNull() ? null : data.path("failureReason").asText(null),
|
||||
strings(data.path("roles")), strings(data.path("authorities"))));
|
||||
});
|
||||
}
|
||||
|
||||
private static Set<String> strings(JsonNode values) {
|
||||
if (values == null || !values.isArray()) return Set.of();
|
||||
LinkedHashSet<String> result = new LinkedHashSet<>();
|
||||
values.forEach(value -> {
|
||||
String text = value.asText("");
|
||||
if (!text.isBlank()) result.add(text);
|
||||
});
|
||||
return Set.copyOf(result);
|
||||
}
|
||||
|
||||
private record Request(long userId, long tenantId, long permissionVersion) { }
|
||||
}
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
package com.baiyue.gateway.filter;
|
||||
|
||||
import com.baiyue.gateway.config.PdaSessionValidationProperties;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/** 调用 Auth 内部实时校验端点;任何传输或响应异常均交由 Gateway fail-closed。 */
|
||||
@Component
|
||||
public class HttpPdaSessionValidationClient implements PdaSessionValidationClient {
|
||||
private static final String PATH = "/internal/auth/accounts/overseas-session/validate";
|
||||
private final WebClient webClient;
|
||||
private final PdaSessionValidationProperties properties;
|
||||
|
||||
public HttpPdaSessionValidationClient(WebClient.Builder builder, PdaSessionValidationProperties properties) {
|
||||
this.webClient = builder.build();
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Result> validate(long userId, int tokenVersion, long overseasWarehouseId) {
|
||||
return webClient.post()
|
||||
.uri(properties.authUrl() + PATH)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header("X-Internal-Service-Token", properties.internalServiceToken())
|
||||
.bodyValue(new Request(userId, tokenVersion, overseasWarehouseId))
|
||||
.retrieve()
|
||||
.bodyToMono(JsonNode.class)
|
||||
.timeout(properties.timeout())
|
||||
.flatMap(this::decode);
|
||||
}
|
||||
|
||||
private Mono<Result> decode(JsonNode envelope) {
|
||||
JsonNode data = envelope == null ? null : envelope.path("data");
|
||||
if (data == null || data.isMissingNode() || data.isNull() || !data.has("valid")) {
|
||||
return Mono.error(new IllegalStateException("Auth overseas session validation response is invalid"));
|
||||
}
|
||||
boolean valid = data.path("valid").asBoolean(false);
|
||||
String reason = data.path("failureReason").isNull() ? null : data.path("failureReason").asText(null);
|
||||
return Mono.just(new Result(valid, reason));
|
||||
}
|
||||
|
||||
private record Request(long userId, int tokenVersion, long overseasWarehouseId) { }
|
||||
}
|
||||
-90
@@ -1,90 +0,0 @@
|
||||
package com.baiyue.gateway.filter;
|
||||
|
||||
import com.baiyue.gateway.config.GatewaySecurityConfig;
|
||||
import com.baiyue.gateway.config.OverseasSecurityProjectionProperties;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
|
||||
import org.springframework.cloud.gateway.filter.GlobalFilter;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
import java.util.Collection;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
public class OverseasSecurityProjectionFilter implements GlobalFilter, Ordered {
|
||||
private final OverseasSecurityValidationClient client;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final OverseasSecurityProjectionProperties properties;
|
||||
|
||||
public OverseasSecurityProjectionFilter(OverseasSecurityValidationClient client, ObjectMapper objectMapper,
|
||||
OverseasSecurityProjectionProperties properties) {
|
||||
this.client = client;
|
||||
this.objectMapper = objectMapper;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
|
||||
if (!exchange.getRequest().getURI().getPath().startsWith("/api/")) return chain.filter(exchange);
|
||||
return exchange.getPrincipal().cast(Authentication.class).filter(Authentication::isAuthenticated)
|
||||
.ofType(JwtAuthenticationToken.class)
|
||||
.switchIfEmpty(Mono.error(new SecurityProjectionFailure("A0401", "未登录或令牌已失效")))
|
||||
.flatMap(authentication -> validate(exchange, chain, authentication))
|
||||
.onErrorResume(SecurityProjectionFailure.class,
|
||||
failure -> write(exchange, failure.code, failure.getMessage()))
|
||||
.onErrorResume(failure -> write(exchange, "A0401", "海外安全状态校验不可用"));
|
||||
}
|
||||
|
||||
private Mono<Void> validate(ServerWebExchange exchange, GatewayFilterChain chain,
|
||||
JwtAuthenticationToken authentication) {
|
||||
Long userId = number(authentication.getToken().getSubject());
|
||||
Long tenantId = number(authentication.getToken().getClaim("tenant_id"));
|
||||
Long permissionVersion = number(authentication.getToken().getClaim("permission_version"));
|
||||
if (userId == null || tenantId == null || permissionVersion == null) {
|
||||
return Mono.error(new SecurityProjectionFailure("A0401", "海外令牌安全版本声明缺失"));
|
||||
}
|
||||
return client.validate(userId, tenantId, permissionVersion)
|
||||
.switchIfEmpty(Mono.error(new SecurityProjectionFailure("A0401", "海外安全状态校验不可用")))
|
||||
.flatMap(result -> {
|
||||
if (result.valid()) {
|
||||
var request = exchange.getRequest().mutate()
|
||||
.header("X-Overseas-Roles", join(result.roles()))
|
||||
.header("X-Overseas-Authorities", join(result.authorities()))
|
||||
.header("X-Internal-Service-Token", properties.internalServiceToken())
|
||||
.build();
|
||||
return chain.filter(exchange.mutate().request(request).build());
|
||||
}
|
||||
if ("USER_DISABLED".equals(result.failureReason())) {
|
||||
return Mono.error(new SecurityProjectionFailure("A0401", "账号已停用"));
|
||||
}
|
||||
return Mono.error(new SecurityProjectionFailure("A0403", "客户或权限状态已变化"));
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Void> write(ServerWebExchange exchange, String code, String message) {
|
||||
return GatewaySecurityConfig.writeError(exchange, objectMapper,
|
||||
"A0403".equals(code) ? HttpStatus.FORBIDDEN : HttpStatus.UNAUTHORIZED, code, message);
|
||||
}
|
||||
|
||||
private static Long number(Object value) {
|
||||
if (value instanceof Number number) return number.longValue();
|
||||
try { return value == null ? null : Long.valueOf(value.toString()); }
|
||||
catch (NumberFormatException exception) { return null; }
|
||||
}
|
||||
|
||||
private static String join(Collection<String> values) {
|
||||
return values.stream().sorted().collect(Collectors.joining(","));
|
||||
}
|
||||
|
||||
@Override public int getOrder() { return -95; }
|
||||
|
||||
private static final class SecurityProjectionFailure extends RuntimeException {
|
||||
private final String code;
|
||||
private SecurityProjectionFailure(String code, String message) { super(message); this.code = code; }
|
||||
}
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
package com.baiyue.gateway.filter;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
import java.util.Set;
|
||||
|
||||
public interface OverseasSecurityValidationClient {
|
||||
Mono<Result> validate(long userId, long tenantId, long permissionVersion);
|
||||
record Result(boolean valid, String failureReason, Set<String> roles, Set<String> authorities) {
|
||||
public Result {
|
||||
roles = roles == null ? Set.of() : Set.copyOf(roles);
|
||||
authorities = authorities == null ? Set.of() : Set.copyOf(authorities);
|
||||
}
|
||||
}
|
||||
}
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
package com.baiyue.gateway.filter;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/** 只传递本地验签后提取的稳定 claims,不传递 raw JWT。 */
|
||||
public interface PdaSessionValidationClient {
|
||||
Mono<Result> validate(long userId, int tokenVersion, long overseasWarehouseId);
|
||||
|
||||
record Result(boolean valid, String failureReason) { }
|
||||
}
|
||||
-92
@@ -1,92 +0,0 @@
|
||||
package com.baiyue.gateway.filter;
|
||||
|
||||
import com.baiyue.gateway.config.GatewaySecurityConfig;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
|
||||
import org.springframework.cloud.gateway.filter.GlobalFilter;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/** 仅对海外 PDA API 执行无缓存的账号版本与仓绑定实时校验。 */
|
||||
@Component
|
||||
public class PdaSessionValidationFilter implements GlobalFilter, Ordered {
|
||||
private static final String PDA_PREFIX = "/api/pda/";
|
||||
private final PdaSessionValidationClient client;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public PdaSessionValidationFilter(PdaSessionValidationClient client, ObjectMapper objectMapper) {
|
||||
this.client = client;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
|
||||
if (!exchange.getRequest().getURI().getPath().startsWith(PDA_PREFIX)) {
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
return exchange.getPrincipal().cast(Authentication.class)
|
||||
.filter(Authentication::isAuthenticated)
|
||||
.ofType(JwtAuthenticationToken.class)
|
||||
.switchIfEmpty(Mono.error(new InvalidPdaSession("A0401", "未登录或令牌已失效")))
|
||||
.flatMap(authentication -> validate(exchange, chain, authentication))
|
||||
.onErrorResume(InvalidPdaSession.class, failure -> write(exchange, failure.code, failure.getMessage()))
|
||||
.onErrorResume(failure -> write(exchange, "A0401", "海外PDA会话校验不可用"));
|
||||
}
|
||||
|
||||
private Mono<Void> validate(ServerWebExchange exchange, GatewayFilterChain chain,
|
||||
JwtAuthenticationToken authentication) {
|
||||
Long userId = longValue(authentication.getToken().getSubject());
|
||||
Integer tokenVersion = intClaim(authentication, "token_version");
|
||||
Long warehouseId = longClaim(authentication, "overseas_warehouse_id");
|
||||
if (userId == null || tokenVersion == null) {
|
||||
return Mono.error(new InvalidPdaSession("A0401", "海外PDA令牌版本声明缺失"));
|
||||
}
|
||||
if (warehouseId == null) {
|
||||
return Mono.error(new InvalidPdaSession("A0403", "海外PDA账号未绑定仓库"));
|
||||
}
|
||||
return client.validate(userId, tokenVersion, warehouseId)
|
||||
.switchIfEmpty(Mono.error(new InvalidPdaSession("A0401", "海外PDA会话校验不可用")))
|
||||
.flatMap(result -> {
|
||||
if (result.valid()) return chain.filter(exchange);
|
||||
if ("WAREHOUSE_UNBOUND".equals(result.failureReason())
|
||||
|| "WAREHOUSE_MISMATCH".equals(result.failureReason())) {
|
||||
return Mono.error(new InvalidPdaSession("A0403", "海外PDA仓库绑定已变化"));
|
||||
}
|
||||
return Mono.error(new InvalidPdaSession("A0401", "海外PDA令牌已失效"));
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Void> write(ServerWebExchange exchange, String code, String message) {
|
||||
HttpStatus status = "A0403".equals(code) ? HttpStatus.FORBIDDEN : HttpStatus.UNAUTHORIZED;
|
||||
return GatewaySecurityConfig.writeError(exchange, objectMapper, status, code, message);
|
||||
}
|
||||
|
||||
private static Integer intClaim(JwtAuthenticationToken authentication, String name) {
|
||||
Object value = authentication.getToken().getClaims().get(name);
|
||||
if (value instanceof Number number) return number.intValue();
|
||||
try { return value == null ? null : Integer.valueOf(value.toString()); }
|
||||
catch (NumberFormatException ignored) { return null; }
|
||||
}
|
||||
|
||||
private static Long longClaim(JwtAuthenticationToken authentication, String name) {
|
||||
return longValue(authentication.getToken().getClaims().get(name));
|
||||
}
|
||||
|
||||
private static Long longValue(Object value) {
|
||||
if (value instanceof Number number) return number.longValue();
|
||||
try { return value == null ? null : Long.valueOf(value.toString()); }
|
||||
catch (NumberFormatException ignored) { return null; }
|
||||
}
|
||||
|
||||
@Override public int getOrder() { return -90; }
|
||||
|
||||
private static final class InvalidPdaSession extends RuntimeException {
|
||||
private final String code;
|
||||
private InvalidPdaSession(String code, String message) { super(message); this.code = code; }
|
||||
}
|
||||
}
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
package com.baiyue.gateway.filter;
|
||||
|
||||
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
|
||||
import org.springframework.cloud.gateway.filter.GlobalFilter;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 网关可信身份头过滤器,清除外部伪造身份头,并从已认证 JWT 注入用户、租户和应用范围信息。
|
||||
*/
|
||||
@Component
|
||||
public class TrustedIdentityHeaderFilter implements GlobalFilter, Ordered {
|
||||
private static final List<String> TRUSTED_HEADERS = List.of(
|
||||
"X-User-Id", "X-Membership-Id", "X-Tenant-Id", "X-Operator-Tenant-Id",
|
||||
"X-App-Code", "X-Client-Type", "X-Overseas-Roles", "X-Overseas-Authorities",
|
||||
"X-Internal-Service-Token"
|
||||
);
|
||||
|
||||
@Override
|
||||
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
|
||||
return exchange.getPrincipal()
|
||||
.cast(Authentication.class)
|
||||
.defaultIfEmpty(new AnonymousAuthentication())
|
||||
.flatMap(authentication -> {
|
||||
var requestBuilder = exchange.getRequest().mutate();
|
||||
requestBuilder.headers(headers -> TRUSTED_HEADERS.forEach(headers::remove));
|
||||
|
||||
if (authentication instanceof JwtAuthenticationToken jwt && authentication.isAuthenticated()) {
|
||||
put(requestBuilder, "X-User-Id", jwt.getToken().getSubject());
|
||||
put(requestBuilder, "X-Membership-Id", claim(jwt, "membership_id"));
|
||||
put(requestBuilder, "X-Tenant-Id", claim(jwt, "tenant_id"));
|
||||
put(requestBuilder, "X-Operator-Tenant-Id", claim(jwt, "operator_tenant_id"));
|
||||
put(requestBuilder, "X-App-Code", claim(jwt, "app_code"));
|
||||
put(requestBuilder, "X-Client-Type", claim(jwt, "client_type"));
|
||||
}
|
||||
return chain.filter(exchange.mutate().request(requestBuilder.build()).build());
|
||||
});
|
||||
}
|
||||
|
||||
private static String claim(JwtAuthenticationToken jwt, String name) {
|
||||
Object value = jwt.getToken().getClaims().get(name);
|
||||
return value == null ? null : value.toString();
|
||||
}
|
||||
|
||||
private static void put(org.springframework.http.server.reactive.ServerHttpRequest.Builder builder,
|
||||
String name,
|
||||
String value) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
builder.header(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return -100;
|
||||
}
|
||||
|
||||
private static final class AnonymousAuthentication implements Authentication {
|
||||
@Override public java.util.Collection<? extends org.springframework.security.core.GrantedAuthority> getAuthorities() { return List.of(); }
|
||||
@Override public Object getCredentials() { return ""; }
|
||||
@Override public Object getDetails() { return null; }
|
||||
@Override public Object getPrincipal() { return "anonymous"; }
|
||||
@Override public boolean isAuthenticated() { return false; }
|
||||
@Override public void setAuthenticated(boolean isAuthenticated) { }
|
||||
@Override public String getName() { return "anonymous"; }
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: overseas
|
||||
cloud:
|
||||
gateway:
|
||||
discovery:
|
||||
locator:
|
||||
enabled: false
|
||||
routes:
|
||||
- id: lastleg-service
|
||||
uri: lb://lastleg-service
|
||||
predicates: ["Path=/api/merchant/v1/last-leg/**,/api/platform/v1/last-leg/**,/api/pda/v1/last-leg/**"]
|
||||
- id: warehouse-overseas-service
|
||||
uri: lb://warehouse-overseas-service
|
||||
predicates: ["Path=/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**"]
|
||||
- id: file-service
|
||||
uri: lb://file-service
|
||||
predicates: ["Path=/api/merchant/v1/files/**,/api/platform/v1/files/**"]
|
||||
- id: print-service
|
||||
uri: lb://print-service
|
||||
predicates: ["Path=/api/merchant/v1/print/**,/api/platform/v1/print/**"]
|
||||
- id: lastleg-openapi
|
||||
uri: lb://lastleg-service
|
||||
predicates: [Path=/lastleg-service/v3/api-docs/**]
|
||||
filters: [StripPrefix=1]
|
||||
- id: warehouse-openapi
|
||||
uri: lb://warehouse-overseas-service
|
||||
predicates: [Path=/warehouse-overseas-service/v3/api-docs/**]
|
||||
filters: [StripPrefix=1]
|
||||
- id: file-openapi
|
||||
uri: lb://file-service
|
||||
predicates: [Path=/file-service/v3/api-docs/**]
|
||||
filters: [StripPrefix=1]
|
||||
- id: print-openapi
|
||||
uri: lb://print-service
|
||||
predicates: [Path=/print-service/v3/api-docs/**]
|
||||
filters: [StripPrefix=1]
|
||||
- id: audit-openapi
|
||||
uri: lb://audit-service
|
||||
predicates: [Path=/audit-service/v3/api-docs/**]
|
||||
filters: [StripPrefix=1]
|
||||
- id: integration-openapi
|
||||
uri: lb://integration-service
|
||||
predicates: [Path=/integration-service/v3/api-docs/**]
|
||||
filters: [StripPrefix=1]
|
||||
- id: event-bridge-openapi
|
||||
uri: lb://event-bridge-service
|
||||
predicates: [Path=/event-bridge-service/v3/api-docs/**]
|
||||
filters: [StripPrefix=1]
|
||||
|
||||
baiyue:
|
||||
deployment:
|
||||
region-code: ${REGION_CODE:OVERSEAS}
|
||||
cell: ${DEPLOYMENT_CELL:prod-overseas}
|
||||
data-residency: ${DATA_RESIDENCY:OVERSEAS}
|
||||
|
||||
springdoc:
|
||||
swagger-ui:
|
||||
urls:
|
||||
- { name: lastleg-merchant, url: /lastleg-service/v3/api-docs/merchant }
|
||||
- { name: lastleg-platform, url: /lastleg-service/v3/api-docs/platform }
|
||||
- { name: lastleg-pda, url: /lastleg-service/v3/api-docs/pda }
|
||||
- { name: warehouse-merchant, url: /warehouse-overseas-service/v3/api-docs/merchant }
|
||||
- { name: warehouse-platform, url: /warehouse-overseas-service/v3/api-docs/platform }
|
||||
- { name: file-platform, url: /file-service/v3/api-docs/platform }
|
||||
- { name: file-merchant, url: /file-service/v3/api-docs/merchant }
|
||||
- { name: print-platform, url: /print-service/v3/api-docs/platform }
|
||||
- { name: print-merchant, url: /print-service/v3/api-docs/merchant }
|
||||
- { name: audit, url: /audit-service/v3/api-docs }
|
||||
- { name: integration, url: /integration-service/v3/api-docs }
|
||||
- { name: event-bridge, url: /event-bridge-service/v3/api-docs }
|
||||
@@ -1,78 +0,0 @@
|
||||
server:
|
||||
port: ${GATEWAY_PORT:9300}
|
||||
|
||||
spring:
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:127.0.0.1}
|
||||
port: ${REDIS_PORT:6380}
|
||||
application:
|
||||
name: gateway-overseas
|
||||
profiles:
|
||||
default: overseas
|
||||
autoconfigure:
|
||||
exclude:
|
||||
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
|
||||
config:
|
||||
import:
|
||||
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
|
||||
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
|
||||
cloud:
|
||||
nacos:
|
||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
|
||||
username: ${NACOS_USERNAME:nacos}
|
||||
password: ${NACOS_PASSWORD:nacos}
|
||||
discovery:
|
||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
|
||||
enabled: ${NACOS_DISCOVERY_ENABLED:true}
|
||||
register-enabled: ${NACOS_REGISTER_ENABLED:true}
|
||||
group: ${NACOS_GROUP:BAIYUE}
|
||||
namespace: ${NACOS_NAMESPACE:public}
|
||||
config:
|
||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
|
||||
enabled: ${NACOS_CONFIG_ENABLED:true}
|
||||
namespace: ${NACOS_NAMESPACE:public}
|
||||
gateway:
|
||||
discovery:
|
||||
locator:
|
||||
enabled: false
|
||||
|
||||
baiyue:
|
||||
security:
|
||||
issuer: ${JWT_ISSUER:auth-service}
|
||||
audience: ${JWT_AUDIENCE:lastleg-api}
|
||||
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
|
||||
pda-session-validation:
|
||||
auth-url: ${AUTH_SESSION_VALIDATION_URL}
|
||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
|
||||
timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s}
|
||||
overseas-projection:
|
||||
service-url: ${LASTLEG_SECURITY_SERVICE_URL:http://lastleg-service:9303}
|
||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
|
||||
timeout: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:2s}
|
||||
permit-paths:
|
||||
- /actuator/health
|
||||
- /swagger-ui/**
|
||||
- /webjars/**
|
||||
- /v3/api-docs/**
|
||||
- /lastleg-service/v3/api-docs/**
|
||||
- /warehouse-overseas-service/v3/api-docs/**
|
||||
- /file-service/v3/api-docs/**
|
||||
- /print-service/v3/api-docs/**
|
||||
- /audit-service/v3/api-docs/**
|
||||
- /integration-service/v3/api-docs/**
|
||||
- /event-bridge-service/v3/api-docs/**
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,prometheus
|
||||
endpoint:
|
||||
health:
|
||||
probes:
|
||||
enabled: true
|
||||
|
||||
springdoc:
|
||||
api-docs.enabled: ${SWAGGER_ENABLED:false}
|
||||
swagger-ui.enabled: ${SWAGGER_ENABLED:false}
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
package com.baiyue.gateway.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
class AppScopeAuthorizationManagerTest {
|
||||
@Test
|
||||
void acceptsMatchingApplicationScope() {
|
||||
var manager = new AppScopeAuthorizationManager("MERCHANT");
|
||||
StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT")), null))
|
||||
.expectNextMatches(decision -> decision.isGranted())
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDifferentApplicationScope() {
|
||||
var manager = new AppScopeAuthorizationManager("PLATFORM");
|
||||
StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT")), null))
|
||||
.expectNextMatches(decision -> !decision.isGranted())
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingApplicationScope() {
|
||||
var manager = new AppScopeAuthorizationManager("PLATFORM");
|
||||
Jwt jwt = Jwt.withTokenValue("token")
|
||||
.header("alg", "none")
|
||||
.subject("1")
|
||||
.issuedAt(Instant.now())
|
||||
.expiresAt(Instant.now().plusSeconds(60))
|
||||
.build();
|
||||
StepVerifier.create(manager.check(Mono.just(new JwtAuthenticationToken(
|
||||
jwt, java.util.List.of(new SimpleGrantedAuthority("authenticated")))), null))
|
||||
.expectNextMatches(decision -> !decision.isGranted())
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
private JwtAuthenticationToken authentication(String appCode) {
|
||||
Jwt jwt = Jwt.withTokenValue("token")
|
||||
.header("alg", "none")
|
||||
.subject("1")
|
||||
.issuedAt(Instant.now())
|
||||
.expiresAt(Instant.now().plusSeconds(60))
|
||||
.claim("app_code", appCode)
|
||||
.build();
|
||||
return new JwtAuthenticationToken(jwt,
|
||||
java.util.List.of(new SimpleGrantedAuthority("authenticated")));
|
||||
}
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
package com.baiyue.gateway.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class AudienceJwtValidatorTest {
|
||||
private final AudienceJwtValidator validator = new AudienceJwtValidator("lastleg-api");
|
||||
|
||||
@Test
|
||||
void acceptsTokenForLastlegApi() {
|
||||
assertThat(validator.validate(jwt(List.of("cn-api", "lastleg-api"))).hasErrors()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTokenForAnotherApi() {
|
||||
assertThat(validator.validate(jwt(List.of("cn-api"))).hasErrors()).isTrue();
|
||||
}
|
||||
|
||||
private Jwt jwt(List<String> audiences) {
|
||||
return Jwt.withTokenValue("token")
|
||||
.header("alg", "RS256")
|
||||
.subject("1001")
|
||||
.issuedAt(Instant.now())
|
||||
.expiresAt(Instant.now().plusSeconds(300))
|
||||
.audience(audiences)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
package com.baiyue.gateway.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||
import org.springframework.mock.web.server.MockServerWebExchange;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
class GatewaySecurityErrorContractTest {
|
||||
@Test
|
||||
void audienceAndAuthenticationFailuresExposeRequestAndTraceCorrelation() throws Exception {
|
||||
var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/api/platform/v1/test")
|
||||
.header("X-Request-Id", "request-1234567890")
|
||||
.header("X-Trace-Id", "trace-123456789012")
|
||||
.build());
|
||||
|
||||
GatewaySecurityConfig.writeError(exchange, new ObjectMapper(), HttpStatus.UNAUTHORIZED,
|
||||
"A0401", "未登录或令牌已失效").block();
|
||||
|
||||
var body = new ObjectMapper().readTree(exchange.getResponse().getBodyAsString().block());
|
||||
assertEquals("request-1234567890", body.path("requestId").asText());
|
||||
assertEquals("trace-123456789012", body.path("traceId").asText());
|
||||
assertEquals(body.path("requestId").asText(), exchange.getResponse().getHeaders().getFirst("X-Request-Id"));
|
||||
assertEquals(body.path("traceId").asText(), exchange.getResponse().getHeaders().getFirst("X-Trace-Id"));
|
||||
assertFalse(body.path("timestamp").asText().isBlank());
|
||||
}
|
||||
}
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
package com.baiyue.gateway.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OverseasLastlegRouteContractTest {
|
||||
@Test
|
||||
void browserAndPdaPathsAreForwardedToLastlegWithoutPrefixRewrite() throws IOException {
|
||||
try (InputStream input = getClass().getResourceAsStream("/application-overseas.yml")) {
|
||||
assertThat(input).isNotNull();
|
||||
String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||
int routeStart = yaml.indexOf("- id: lastleg-service");
|
||||
int routeEnd = yaml.indexOf("- id: warehouse-overseas-service", routeStart);
|
||||
assertThat(routeStart).isGreaterThanOrEqualTo(0);
|
||||
assertThat(routeEnd).isGreaterThan(routeStart);
|
||||
String route = yaml.substring(routeStart, routeEnd);
|
||||
|
||||
assertThat(route).contains("uri: lb://lastleg-service")
|
||||
.contains("/api/platform/v1/last-leg/**")
|
||||
.contains("/api/merchant/v1/last-leg/**")
|
||||
.contains("/api/pda/v1/last-leg/**")
|
||||
.doesNotContain("StripPrefix", "RewritePath");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void swaggerPublishesAnIndependentLastlegPdaGroup() throws IOException {
|
||||
try (InputStream input = getClass().getResourceAsStream("/application-overseas.yml")) {
|
||||
assertThat(input).isNotNull();
|
||||
String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||
assertThat(yaml).contains("name: lastleg-pda", "url: /lastleg-service/v3/api-docs/pda")
|
||||
.contains("Path=/lastleg-service/v3/api-docs/**")
|
||||
.doesNotContain("event-bridge-service/v3/api-docs/pda");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void lastlegPdaRouteCoversFrozenDispatchAndOutboundTaskPaths() throws IOException {
|
||||
try (InputStream input = getClass().getResourceAsStream("/application-overseas.yml")) {
|
||||
assertThat(input).isNotNull();
|
||||
String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||
assertThat(yaml).contains("/api/pda/v1/last-leg/**");
|
||||
for (String path : new String[] {
|
||||
"/api/pda/v1/last-leg/dispatches/DSP-1",
|
||||
"/api/pda/v1/last-leg/dispatches/DSP-1/depart",
|
||||
"/api/pda/v1/last-leg/outbound-tasks/PDA-1/complete"
|
||||
}) {
|
||||
assertThat(path).startsWith("/api/pda/v1/last-leg/");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-84
@@ -1,84 +0,0 @@
|
||||
package com.baiyue.gateway.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OverseasSecurityValidationDeploymentContractTest {
|
||||
private static final String HEADER_SIZE =
|
||||
"max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB}";
|
||||
|
||||
@Test
|
||||
void gatewayLocalJwtDefaultsMatchAuthAndLastleg() throws Exception {
|
||||
String gatewayApplication = read("gateway-overseas/src/main/resources/application.yml");
|
||||
String lastlegApplication = read("lastleg-service/src/main/resources/application.yml");
|
||||
String localGateway = read("config/nacos/local/gateway-overseas.yml");
|
||||
String localLastleg = read("config/nacos/local/lastleg-service.yml");
|
||||
String localWarehouse = read("config/nacos/local/warehouse-overseas-service.yml");
|
||||
String remoteGateway = read("config/nacos/overseas/gateway-overseas.yml");
|
||||
String remoteLastleg = read("config/nacos/overseas/lastleg-service.yml");
|
||||
String remoteWarehouse = read("config/nacos/overseas/warehouse-overseas-service.yml");
|
||||
|
||||
for (String source : new String[]{gatewayApplication, lastlegApplication, localGateway, localLastleg, localWarehouse}) {
|
||||
assertThat(source)
|
||||
.contains("${JWT_ISSUER:auth-service}")
|
||||
.contains("${JWT_AUDIENCE:lastleg-api}")
|
||||
.contains("${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}");
|
||||
}
|
||||
assertThat(gatewayApplication).doesNotContain("auth.example.com");
|
||||
assertThat(localGateway)
|
||||
.contains("${AUTH_SESSION_VALIDATION_URL:http://127.0.0.1:9100}")
|
||||
.contains("${LASTLEG_SECURITY_SERVICE_URL:http://127.0.0.1:9303}");
|
||||
assertThat(localLastleg).contains("${WAREHOUSE_OVERSEAS_SERVICE_URL:http://127.0.0.1:9305}");
|
||||
assertThat(localWarehouse).contains("${DOMESTIC_WAREHOUSE_URL:http://127.0.0.1:9205}");
|
||||
assertThat(remoteGateway + remoteLastleg + remoteWarehouse)
|
||||
.doesNotContain("127.0.0.1", "root", "123456");
|
||||
assertThat(remoteGateway).contains("${LASTLEG_SECURITY_SERVICE_URL:http://lastleg-service:9303}");
|
||||
assertThat(remoteLastleg).contains("${LASTLEG_DB_URL}", "${LASTLEG_DB_USERNAME}", "${LASTLEG_DB_PASSWORD}");
|
||||
assertThat(remoteWarehouse).contains("${WAREHOUSE_DB_URL}", "${WAREHOUSE_DB_USERNAME}", "${WAREHOUSE_DB_PASSWORD}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void gatewayAndEventBridgeUseLastlegSecurityEndpointsWithoutTheRetiredService() throws Exception {
|
||||
String client = read("gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java");
|
||||
String gatewayApplication = read("gateway-overseas/src/main/resources/application.yml");
|
||||
String gatewayNacos = read("config/nacos/overseas/gateway-overseas.yml");
|
||||
String bridgeApplication = read("event-bridge-service/src/main/resources/application.yml");
|
||||
String bridgeNacos = read("config/nacos/overseas/event-bridge-service.yml");
|
||||
String compose = read("deploy/docker-compose.overseas.yml");
|
||||
|
||||
assertThat(client).contains("/internal/lastleg/security/v1/validate")
|
||||
.contains("new Request(userId, tenantId, permissionVersion)")
|
||||
.doesNotContain("new Request(userId, tenantId, tokenVersion")
|
||||
.doesNotContain("/internal/overseas-security/");
|
||||
for (String source : new String[]{gatewayApplication, gatewayNacos, compose}) {
|
||||
assertThat(source).contains("LASTLEG_SECURITY_SERVICE_URL", "http://lastleg-service:9303");
|
||||
}
|
||||
for (String source : new String[]{bridgeApplication, bridgeNacos, compose}) {
|
||||
assertThat(source).contains("LASTLEG_SECURITY_EVENT_CONSUMER_URL",
|
||||
"/internal/lastleg/security/v1/events");
|
||||
}
|
||||
assertThat(gatewayApplication + gatewayNacos + bridgeApplication + bridgeNacos + compose)
|
||||
.doesNotContain("overseas-security-service", "9307", "OVERSEAS_SECURITY_DB_URL",
|
||||
"OVERSEAS_SECURITY_SERVICE_URL", "OVERSEAS_SECURITY_EVENT_CONSUMER_URL");
|
||||
}
|
||||
|
||||
@Test
|
||||
void overseasAuthorizationSnapshotHeadersFitEveryServletService() throws Exception {
|
||||
assertThat(read("config/nacos/overseas/baiyue-common.yml")).contains(HEADER_SIZE);
|
||||
assertThat(read("lastleg-service/src/main/resources/application.yml")).contains(HEADER_SIZE);
|
||||
assertThat(read("warehouse-overseas-service/src/main/resources/application.yml")).contains(HEADER_SIZE);
|
||||
assertThat(read("deploy/docker-compose.overseas.yml"))
|
||||
.contains("SERVER_MAX_HTTP_REQUEST_HEADER_SIZE: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:-32KB}");
|
||||
}
|
||||
|
||||
private static String read(String relative) throws Exception {
|
||||
Path path = Path.of(relative);
|
||||
if (!Files.exists(path)) path = Path.of("..").resolve(relative);
|
||||
return Files.readString(path, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
package com.baiyue.gateway.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PdaSessionValidationDeploymentContractTest {
|
||||
@Test
|
||||
void nacosComposeAndJenkinsDeploymentRequireOnlyEnvironmentBackedAuthCoordinates() throws Exception {
|
||||
String application = read("gateway-overseas/src/main/resources/application.yml");
|
||||
String nacos = read("config/nacos/overseas/baiyue-common.yml");
|
||||
String compose = read("deploy/docker-compose.overseas.yml");
|
||||
String deployServices = read("deploy/scripts/deploy-services.sh");
|
||||
String deployBuiltService = read("deploy/scripts/deploy-built-service.sh");
|
||||
String jenkins = read("Jenkinsfile");
|
||||
|
||||
for (String source : new String[]{application, nacos, compose}) {
|
||||
assertThat(source).contains("AUTH_SESSION_VALIDATION_URL", "INTERNAL_SERVICE_TOKEN",
|
||||
"PDA_SESSION_VALIDATION_TIMEOUT");
|
||||
}
|
||||
assertThat(deployServices).contains("gateway-overseas", "AUTH_SESSION_VALIDATION_URL");
|
||||
assertThat(deployBuiltService).contains("gateway-overseas", "AUTH_SESSION_VALIDATION_URL");
|
||||
assertThat(jenkins).contains("'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS'", "'auth': 'SERVICE_AUTH'",
|
||||
"'iam': 'SERVICE_IAM'");
|
||||
assertThat(application).doesNotContain("/internal/auth/accounts/overseas-session/validate");
|
||||
assertThat(read("gateway-overseas/src/main/resources/application-overseas.yml"))
|
||||
.doesNotContain("/internal/auth/**", "/internal/auth/accounts/overseas-session/validate");
|
||||
assertThat(read("gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java"))
|
||||
.contains("new Request(userId, tokenVersion, overseasWarehouseId)",
|
||||
".header(\"X-Internal-Service-Token\"")
|
||||
.doesNotContain("Authorization", "accessToken", "rawToken", "getTokenValue");
|
||||
}
|
||||
|
||||
private static String read(String relative) throws Exception {
|
||||
Path path = Path.of(relative);
|
||||
if (!Files.exists(path)) path = Path.of("..").resolve(relative);
|
||||
return Files.readString(path, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
-94
@@ -1,94 +0,0 @@
|
||||
package com.baiyue.gateway.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class RequiredClaimsJwtValidatorTest {
|
||||
private final RequiredClaimsJwtValidator validator = new RequiredClaimsJwtValidator();
|
||||
|
||||
@Test
|
||||
void acceptsCompleteIdentityClaims() {
|
||||
assertThat(validator.validate(jwt(true)).hasErrors()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingPermissionVersion() {
|
||||
assertThat(validator.validate(jwt(false)).hasErrors()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingAccountTokenVersion() {
|
||||
Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("1001")
|
||||
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
|
||||
.claim("tenant_id", "0").claim("app_code", "PLATFORM")
|
||||
.claim("permission_version", 3L)
|
||||
.build();
|
||||
assertThat(validator.validate(value).hasErrors()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsTokenWithoutAuthoritiesBecauseProjectionOwnsAuthorization() {
|
||||
Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("1001")
|
||||
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
|
||||
.claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("token_version", 1)
|
||||
.claim("permission_version", 3L)
|
||||
.build();
|
||||
assertThat(validator.validate(value).hasErrors()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsEmptyAuthoritySnapshotAndLeavesAuthorizationToControllers() {
|
||||
assertThat(validator.validate(jwt("0", "PLATFORM", List.of(), true)).hasErrors()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsMerchantIdentityOnlyWithPositiveCustomerScope() {
|
||||
assertThat(validator.validate(jwt("2001", "MERCHANT", List.of("merchant:order:list"), true)).hasErrors())
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsApplicationAndCustomerScopeMismatch() {
|
||||
assertThat(validator.validate(jwt("2001", "PLATFORM", List.of("platform:test:list"), true)).hasErrors())
|
||||
.isTrue();
|
||||
assertThat(validator.validate(jwt("0", "MERCHANT", List.of("merchant:test:list"), true)).hasErrors())
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMalformedIdentityAndPermissionVersion() {
|
||||
Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("not-a-user")
|
||||
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
|
||||
.claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("permission_version", -1L)
|
||||
.claim("token_version", 1)
|
||||
.claim("authorities", List.of()).build();
|
||||
assertThat(validator.validate(value).hasErrors()).isTrue();
|
||||
}
|
||||
|
||||
private Jwt jwt(boolean includePermissionVersion) {
|
||||
return jwt("0", "PLATFORM",
|
||||
List.of("platform:overseas-warehouse:inventory:list"), includePermissionVersion);
|
||||
}
|
||||
|
||||
private Jwt jwt(String tenantId, String appCode, List<String> authorities,
|
||||
boolean includePermissionVersion) {
|
||||
Jwt.Builder builder = Jwt.withTokenValue("token")
|
||||
.header("alg", "RS256")
|
||||
.subject("1001")
|
||||
.issuedAt(Instant.now())
|
||||
.expiresAt(Instant.now().plusSeconds(300))
|
||||
.claim("tenant_id", tenantId)
|
||||
.claim("app_code", appCode)
|
||||
.claim("token_version", 1)
|
||||
.claim("authorities", authorities);
|
||||
if (includePermissionVersion) {
|
||||
builder.claim("permission_version", 3L);
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
}
|
||||
-97
@@ -1,97 +0,0 @@
|
||||
package com.baiyue.gateway.filter;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.baiyue.gateway.config.OverseasSecurityProjectionProperties;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||
import org.springframework.mock.web.server.MockServerWebExchange;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class OverseasSecurityProjectionFilterTest {
|
||||
private final OverseasSecurityValidationClient client = mock(OverseasSecurityValidationClient.class);
|
||||
private final OverseasSecurityProjectionProperties properties =
|
||||
new OverseasSecurityProjectionProperties("http://lastleg",
|
||||
"gateway-internal-test-token-32-bytes", Duration.ofSeconds(2));
|
||||
private final OverseasSecurityProjectionFilter filter =
|
||||
new OverseasSecurityProjectionFilter(client, new ObjectMapper(), properties);
|
||||
|
||||
@Test void disabledUserFailsWithA0401() {
|
||||
when(client.validate(42, 0, 8)).thenReturn(Mono.just(
|
||||
new OverseasSecurityValidationClient.Result(false, "USER_DISABLED", Set.of(), Set.of())));
|
||||
var exchange = exchange("/api/platform/v1/last-leg/orders");
|
||||
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
|
||||
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
assertThat(body(exchange)).contains("A0401");
|
||||
}
|
||||
|
||||
@Test void disabledTenantAndStalePermissionFailWithA0403() {
|
||||
for (String reason : new String[]{"TENANT_DISABLED", "PERMISSION_VERSION_STALE"}) {
|
||||
when(client.validate(42, 0, 8)).thenReturn(Mono.just(
|
||||
new OverseasSecurityValidationClient.Result(false, reason, Set.of(), Set.of())));
|
||||
var exchange = exchange("/api/merchant/v1/last-leg/orders");
|
||||
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
|
||||
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
assertThat(body(exchange)).contains("A0403");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void projectionUnavailableFailsClosed() {
|
||||
when(client.validate(42, 0, 8)).thenReturn(Mono.error(new IllegalStateException("down")));
|
||||
var exchange = exchange("/api/platform/v1/last-leg/orders");
|
||||
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
|
||||
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@Test void acceptedSecurityStateContinuesAndNonApiPathsDoNotCallProjection() {
|
||||
when(client.validate(42, 0, 8)).thenReturn(Mono.just(
|
||||
new OverseasSecurityValidationClient.Result(true, null, Set.of("PLATFORM_ADMIN"),
|
||||
Set.of("platform:overseas-warehouse:last-mile-order:list"))));
|
||||
AtomicBoolean routed = new AtomicBoolean();
|
||||
var accepted = exchange("/api/platform/v1/last-leg/orders");
|
||||
StepVerifier.create(filter.filter(accepted, routedExchange -> {
|
||||
routed.set(true);
|
||||
assertThat(routedExchange.getRequest().getHeaders().getFirst("X-Internal-Service-Token"))
|
||||
.isEqualTo("gateway-secret");
|
||||
assertThat(routedExchange.getRequest().getHeaders().getFirst("X-Overseas-Authorities"))
|
||||
.isEqualTo("platform:overseas-warehouse:last-mile-order:list");
|
||||
return Mono.empty();
|
||||
})).verifyComplete();
|
||||
assertThat(routed).isTrue();
|
||||
|
||||
var isolatedClient = mock(OverseasSecurityValidationClient.class);
|
||||
var isolatedFilter = new OverseasSecurityProjectionFilter(isolatedClient, new ObjectMapper(), properties);
|
||||
StepVerifier.create(isolatedFilter.filter(exchange("/actuator/health"), ignored -> Mono.empty())).verifyComplete();
|
||||
verifyNoInteractions(isolatedClient);
|
||||
}
|
||||
|
||||
private static ServerWebExchange exchange(String path) {
|
||||
Jwt jwt = Jwt.withTokenValue("verified").header("alg", "RS256").subject("42")
|
||||
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
|
||||
.claim("tenant_id", 0L).claim("token_version", 3L).claim("permission_version", 8L).build();
|
||||
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get(path).build());
|
||||
return exchange.mutate().principal(Mono.just(new JwtAuthenticationToken(jwt,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_PLATFORM_USER"))))).build();
|
||||
}
|
||||
|
||||
private static String body(ServerWebExchange exchange) {
|
||||
return ((org.springframework.mock.http.server.reactive.MockServerHttpResponse) exchange.getResponse())
|
||||
.getBodyAsString().block();
|
||||
}
|
||||
}
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
package com.baiyue.gateway.filter;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||
import org.springframework.mock.web.server.MockServerWebExchange;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class PdaSessionValidationFilterTest {
|
||||
private final PdaSessionValidationClient client = mock(PdaSessionValidationClient.class);
|
||||
private final PdaSessionValidationFilter filter = new PdaSessionValidationFilter(client, new ObjectMapper());
|
||||
|
||||
@Test
|
||||
void oldTokenIsRejectedImmediatelyAfterWarehouseChange() {
|
||||
when(client.validate(42L, 7, 900L)).thenReturn(Mono.just(
|
||||
new PdaSessionValidationClient.Result(false, "TOKEN_VERSION_MISMATCH")));
|
||||
var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(7, 900L));
|
||||
|
||||
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
|
||||
|
||||
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
assertThat(body(exchange)).contains("A0401", "令牌已失效");
|
||||
}
|
||||
|
||||
@Test
|
||||
void newlyIssuedTokenWithCurrentWarehousePasses() {
|
||||
when(client.validate(42L, 8, 901L)).thenReturn(Mono.just(
|
||||
new PdaSessionValidationClient.Result(true, null)));
|
||||
var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(8, 901L));
|
||||
AtomicBoolean routed = new AtomicBoolean();
|
||||
|
||||
StepVerifier.create(filter.filter(exchange, ignored -> {
|
||||
routed.set(true);
|
||||
return Mono.empty();
|
||||
})).verifyComplete();
|
||||
|
||||
assertThat(routed).isTrue();
|
||||
assertThat(exchange.getResponse().getStatusCode()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void unboundWarehouseIsDeniedWithoutCallingAuth() {
|
||||
var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(8, null));
|
||||
|
||||
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
|
||||
|
||||
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
assertThat(body(exchange)).contains("A0403", "未绑定仓库");
|
||||
verifyNoInteractions(client);
|
||||
}
|
||||
|
||||
@Test
|
||||
void authUnavailableFailsClosed() {
|
||||
when(client.validate(42L, 8, 901L)).thenReturn(Mono.error(new IllegalStateException("timeout")));
|
||||
var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(8, 901L));
|
||||
|
||||
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
|
||||
|
||||
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
assertThat(body(exchange)).contains("A0401", "校验不可用");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ordinaryOverseasAdminAndClientPathsDoNotSynchronouslyValidate() {
|
||||
for (String path : new String[]{"/api/platform/v1/last-leg/orders", "/api/merchant/v1/last-leg/orders"}) {
|
||||
var exchange = exchange(path, claims(7, 900L));
|
||||
AtomicBoolean routed = new AtomicBoolean();
|
||||
StepVerifier.create(filter.filter(exchange, ignored -> {
|
||||
routed.set(true);
|
||||
return Mono.empty();
|
||||
})).verifyComplete();
|
||||
assertThat(routed).isTrue();
|
||||
}
|
||||
verifyNoInteractions(client);
|
||||
}
|
||||
|
||||
private static ServerWebExchange exchange(String path, Map<String, Object> claims) {
|
||||
Jwt.Builder jwt = Jwt.withTokenValue("already-verified-by-resource-server")
|
||||
.header("alg", "RS256")
|
||||
.subject("42")
|
||||
.issuedAt(Instant.now().minusSeconds(5))
|
||||
.expiresAt(Instant.now().plusSeconds(300));
|
||||
claims.forEach(jwt::claim);
|
||||
MockServerWebExchange exchange = MockServerWebExchange.from(
|
||||
MockServerHttpRequest.get(path).build());
|
||||
return exchange.mutate()
|
||||
.principal(Mono.just(new JwtAuthenticationToken(jwt.build(),
|
||||
List.of(new SimpleGrantedAuthority("ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR")))))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static Map<String, Object> claims(int tokenVersion, Long warehouseId) {
|
||||
var claims = new java.util.LinkedHashMap<String, Object>();
|
||||
claims.put("token_version", tokenVersion);
|
||||
if (warehouseId != null) claims.put("overseas_warehouse_id", warehouseId);
|
||||
return claims;
|
||||
}
|
||||
|
||||
private static String body(ServerWebExchange exchange) {
|
||||
return ((org.springframework.mock.http.server.reactive.MockServerHttpResponse) exchange.getResponse())
|
||||
.getBodyAsString().block();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -17,4 +17,4 @@
|
||||
|
||||
- 新增路由同步检查本地/Nacos、App Scope、CORS、公开路径和 Swagger;不得暴露内部令牌接口。
|
||||
- 订单与客户承载登录后 Admin/Client 入口;入库测量与计费接入到仓/测量/计费公开路由但保持内部测量接口隔离;国内货柜履约仅在 Controller、权限和公开访问域契约稳定后新增必要路由,内部 PDA、仓储和服务令牌接口继续不得经 Gateway 暴露。
|
||||
- 国内货柜履约先读 `../../docs/头程到尾程/README.md` 和 `../../docs/国内货柜履约/README.md`;网关变更只服务当前维护入口已确认的模块,不以路由提前暴露未完成能力。开工门禁属于归档资料,只有历史追溯时读取。
|
||||
- 已知路由任务直接核对目标 Controller、公开访问域、权限、Gateway 配置和 OpenAPI;只有货柜业务范围或流程门禁不明时,才从 `../../docs/国内货柜履约/README.md` 或 `../../docs/头程到尾程/README.md` 进入。归档开工门禁仅用于历史追溯,不以新增路由推定业务已完成。
|
||||
|
||||
@@ -172,7 +172,8 @@ baiyue:
|
||||
allow-credentials: ${CORS_ALLOW_CREDENTIALS:false}
|
||||
max-age: ${CORS_MAX_AGE:3600}
|
||||
security:
|
||||
jwk-set-uri: ${JWT_JWKS_URI:http://auth:9100/api/auth/.well-known/jwks.json}
|
||||
jwk-set-uri: ${JWT_JWKS_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
|
||||
session-validation-uri: ${SESSION_VALIDATION_URI:http://127.0.0.1:9100/internal/auth/accounts/sessions/validate}
|
||||
issuer: ${JWT_ISSUER:auth-service}
|
||||
audience: ${JWT_AUDIENCE:lastleg-api}
|
||||
token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth}
|
||||
|
||||
@@ -60,7 +60,7 @@ public class PlatformTenantController {
|
||||
})
|
||||
public ApiResponse<PageResult<TenantResponse>> page(
|
||||
@RequestParam(defaultValue = "1") @Min(1) long page,
|
||||
@RequestParam(defaultValue = "20") @Min(1) @Max(200) long size,
|
||||
@RequestParam(defaultValue = "20") @Min(1) @Max(1000) long size,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false)
|
||||
@Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED") String status,
|
||||
|
||||
@@ -5,6 +5,16 @@ spring:
|
||||
active: ${SPRING_PROFILES_ACTIVE:test}
|
||||
application:
|
||||
name: iam-service
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: ${IAM_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_iam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
|
||||
username: ${IAM_DB_USERNAME:root}
|
||||
password: ${IAM_DB_PASSWORD:123456}
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:127.0.0.1}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
config:
|
||||
import:
|
||||
- nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
|
||||
@@ -36,4 +46,9 @@ baiyue:
|
||||
url: ${AUTH_SERVICE_URL:}
|
||||
security:
|
||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
|
||||
issuer: ${JWT_ISSUER:auth-service}
|
||||
audience: ${JWT_AUDIENCE:lastleg-api}
|
||||
jwk-set-uri: ${JWT_JWKS_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
|
||||
session-mode: ${SESSION_MODE:redis}
|
||||
token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth}
|
||||
management.endpoints.web.exposure.include: health,info,prometheus
|
||||
|
||||
@@ -22,7 +22,7 @@ VALUES
|
||||
(5107001,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:edit','编辑海外尾程订单',NULL,'PUT',701,'ACTIVE',1,0),
|
||||
(5107002,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:export','导出海外尾程订单',NULL,'GET',702,'ACTIVE',1,0),
|
||||
(5107003,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve','审核仓储转运单',NULL,'POST',703,'ACTIVE',1,0),
|
||||
(5107004,0,'PLATFORM','BUTTON',5107000,'lastleg:cargo-discard:approve','审核货物废弃申请',NULL,'POST',704,'ACTIVE',1,0),
|
||||
(5107004,0,'PLATFORM','BUTTON',5107130,'platform:overseas-warehouse:discard:approve','审核货物废弃申请',NULL,'POST',831,'ACTIVE',1,0),
|
||||
(5107005,0,'PLATFORM','BUTTON',5107000,'lastleg:warehouse-transfer:manage','执行仓储转运',NULL,'POST',705,'ACTIVE',1,0),
|
||||
(5107010,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inbound-order:list','海外入仓单','/overseas-warehouse/inbound-orders','GET',710,'ACTIVE',1,0),
|
||||
(5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,0),
|
||||
@@ -41,6 +41,7 @@ VALUES
|
||||
(5107041,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:export','导出海外库存',NULL,'GET',741,'ACTIVE',1,0),
|
||||
(5107042,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:edit','编辑海外库存状态',NULL,'POST',742,'ACTIVE',1,0),
|
||||
(5107043,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:adjust','调整海外库存',NULL,'POST',743,'ACTIVE',1,0),
|
||||
(5107044,0,'PLATFORM','BUTTON',5107040,'lastleg:inventory:manage','后台退货库存管理',NULL,'POST',744,'ACTIVE',1,0),
|
||||
(5107050,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:warehouse:list','海外仓资源','/overseas-warehouse/warehouses','GET',750,'ACTIVE',1,0),
|
||||
(5107051,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:create','创建海外仓资源',NULL,'POST',751,'ACTIVE',1,0),
|
||||
(5107052,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:edit','编辑海外仓资源',NULL,'PUT',752,'ACTIVE',1,0),
|
||||
@@ -65,6 +66,7 @@ VALUES
|
||||
(5107121,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:create','创建本土散板单',NULL,'POST',821,'ACTIVE',1,0),
|
||||
(5107122,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:edit','编辑本土散板单',NULL,'PUT',822,'ACTIVE',1,0),
|
||||
(5107123,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:status','变更本土散板状态',NULL,'PATCH',823,'ACTIVE',1,0),
|
||||
(5107130,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:discard:list','废弃单','/overseas-warehouse/cargo-discards','GET',830,'ACTIVE',1,0),
|
||||
(5107200,0,'PLATFORM','MENU',0,'platform:overseas-master-data:quote:list','海外仓报价','/overseas-master-data/quotes','GET',900,'ACTIVE',1,0),
|
||||
(5107201,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:create','创建海外仓报价',NULL,'POST',901,'ACTIVE',1,0),
|
||||
(5107202,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:edit','编辑海外仓报价',NULL,'PUT',902,'ACTIVE',1,0),
|
||||
@@ -284,6 +286,26 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||
AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN'
|
||||
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
|
||||
|
||||
-- 司机与车队正式写权限仅授予平台超级管理员,供退货换标真实排车使用。
|
||||
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
||||
SELECT 0, role.id, permission.id, NOW(3)
|
||||
FROM iam_role role
|
||||
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
|
||||
AND permission.permission_code IN (
|
||||
'platform:overseas-master-data:driver:list',
|
||||
'platform:overseas-master-data:driver:create',
|
||||
'platform:overseas-master-data:driver:edit',
|
||||
'platform:overseas-master-data:driver:upload',
|
||||
'platform:overseas-master-data:driver:review',
|
||||
'platform:fleet:trip:list',
|
||||
'platform:fleet:trip:create',
|
||||
'platform:fleet:trip:assign',
|
||||
'platform:fleet:trip:edit',
|
||||
'platform:fleet:trip:dispatch-review',
|
||||
'platform:fleet:trip:loading-review')
|
||||
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
|
||||
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
|
||||
|
||||
-- 客户默认管理员获得当前客户海外业务入口;其他客户角色按企业实际分工授权。
|
||||
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
||||
SELECT 0, role.id, permission.id, NOW(3)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user