This commit is contained in:
2026-08-25 14:46:06 +08:00
parent 5abc7543ae
commit f325d57fc1
106 changed files with 3682 additions and 450 deletions
@@ -0,0 +1,199 @@
# Lastleg 内部模块化依赖基线与首切片方案
## 1. 范围与生成方式
本基线只分析 `lastleg-service` 同一部署单元,不拆微服务,不修改业务代码、POM、数据库、OpenAPI、权限、接口或跨服务契约。统计时工作区同时存在 RestClient 超时专项的在途修改;本专项不修改 `client/`、`config/*RestClient*` 或各业务 HTTP Gateway。
可重复命令:
```powershell
node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set main --format markdown
node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set test --format markdown
node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all --format markdown
node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set main --format dot --graph package
node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set test --format dot --graph package
node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all --format dot --graph package
node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set main --format dot --graph slice
```
分析器解析显式内部 `import`、静态 `import` 及同包类型引用,输出类依赖、物理包图、逻辑切片图、Tarjan SCC、Entity/DTO/Model 泄漏与 Mapper 泄漏。`test` 单独输出测试包节点及测试类之间的边;当前测试主要引用生产类型,因此 test-only 图有 67 个包节点但没有测试类之间的内部边。`all` 把 `src/main/java` 和 `src/test/java` 合并解析,是观察测试对生产类型耦合的主图。Spring 扫描字符串、反射、MyBatis SQL 表引用和跨服务运行时调用不属于 Java 类型图。
## 2. 当前规模与 SCC
| 源集 | Java 文件 | 内部类依赖 | 物理包 | 跨切片类依赖 | 模型泄漏 | Mapper 泄漏 |
|---|---:|---:|---:|---:|---:|---:|
| main | 347 | 672 | 157 | 137 | 60 | 38 |
| test only | 98 | 0 | 67 | 0 | 0 | 0 |
| main + test | 445 | 1,195 | 161 | 256 | 108 | 76 |
主源码物理包图没有多节点 SCC。主源码逻辑切片图存在一个四节点 SCC:
```text
dropship
inbound-pda
inventory
tailleg-handoff
```
合并测试源码后,出现一个由测试直接绑定横向实现包形成的物理包 SCC:
```text
com.baiyue.lastleg.looseboard.service
com.baiyue.lastleg.relabel.service
com.baiyue.lastleg.service
com.baiyue.lastleg.transfer.service
```
这不是生产运行时包环。合并图的逻辑 SCC 扩大为 `dispatch-delivery`、`dropship`、`inbound-pda`、`inventory`、`procurement-trucking-looseboard`、`relabel-transfer-discard`、`tailleg-handoff` 七个切片,原因是跨域验收测试引用多个业务实现包。
## 3. 逻辑切片映射
| 逻辑切片 | 当前识别范围 | main 类数 | 跨切片出/入类边 | 当前结论 |
|---|---|---:|---:|---|
| 尾程交接 `tailleg-handoff` | 根 `controller/dto/entity/mapper/model/repository/service` 与 `shipping/` | 33 | 12/48 | 核心尾程单、事件 Inbox/Outbox 和船节点门禁被入仓、库存、换标等直接引用,位于主 SCC |
| 入仓/PDA `inbound-pda` | `inboundplan/`、`palletops/`、根 `pda/` | 100 | 53/14 | 最大切片;与尾程核心、库存和一件代发双向耦合,含大量库存 Entity/Mapper 泄漏 |
| 库存 `inventory` | `inventory/` | 26 | 3/37 | 自身出向较少,但作为多个作业域共享的 Entity/Mapper 实现被直接消费,位于主 SCC |
| 排车/派车 `dispatch-delivery` | `dispatch/`、`delivery/`、未来 `outbound/` | 46 | 20/0 | 主源码无入向 Java 类型边,但涉及明确排除的出库/派送范围,不作为首切片 |
| 换标/转运/废弃 `relabel-transfer-discard` | `relabel/`、`transfer/`、`discard/`,其 PDA 子包保持本域归属 | 61 | 31/0 | 直接使用尾程、库存和一件代发持久化模型;业务事务边界复杂 |
| 一件代发 `dropship` | `dropship/` | 12 | 4/11 | 与尾程消费及废弃库存双向耦合,位于主 SCC |
| 采购/卡车/散板 `procurement-trucking-looseboard` | `procurement/`、`trucking/`、`looseboard/` | 35 | 11/0 | 依赖尾程核心,采购另读取报价目录;仍由四期业务专项维护 |
| 海外主数据 `overseas-master-data` | `masterdata/quote`、`exchange`、`vehicle` | 26 | 3/1 | 不在任何 SCC;仅出向共享权限常量,入向来自采购对报价目录的使用 |
另有 6 个 `shared-technical` 类:应用启动类、Event Bridge Client、RestClient 配置、内部令牌保护和海外权限常量。该分类不授权移动这些共享类。
## 4. 主源码逻辑切片边
| 来源 | 目标 | 类边数 |
|---|---|---:|
| dispatch-delivery | inbound-pda | 6 |
| dispatch-delivery | inventory | 4 |
| dispatch-delivery | shared-technical | 2 |
| dispatch-delivery | tailleg-handoff | 8 |
| dropship | inbound-pda | 1 |
| dropship | shared-technical | 1 |
| dropship | tailleg-handoff | 2 |
| inbound-pda | dropship | 1 |
| inbound-pda | inventory | 25 |
| inbound-pda | shared-technical | 8 |
| inbound-pda | tailleg-handoff | 19 |
| inventory | inbound-pda | 1 |
| inventory | shared-technical | 2 |
| overseas-master-data | shared-technical | 3 |
| procurement-trucking-looseboard | overseas-master-data | 1 |
| procurement-trucking-looseboard | shared-technical | 5 |
| procurement-trucking-looseboard | tailleg-handoff | 5 |
| relabel-transfer-discard | dropship | 8 |
| relabel-transfer-discard | inventory | 6 |
| relabel-transfer-discard | shared-technical | 3 |
| relabel-transfer-discard | tailleg-handoff | 14 |
| tailleg-handoff | dropship | 2 |
| tailleg-handoff | inbound-pda | 6 |
| tailleg-handoff | inventory | 2 |
| tailleg-handoff | shared-technical | 2 |
当前 25 条切片边冻结在 `src/test/resources/architecture/lastleg-slice-dependencies.json`。合同允许删除历史边,不允许增加新边;海外主数据继续锁定为只允许出向 `shared-technical`,只允许采购/卡车/散板切片入向。
## 5. Entity/DTO/Mapper 泄漏基线
主源码共有 60 条跨切片 Entity/DTO/Model 泄漏、38 条跨切片 Mapper 泄漏。高频边如下:
| 来源 → 目标 | 模型 | Mapper | 代表性证据 |
|---|---:|---:|---|
| inbound-pda → inventory | 15 | 9 | 托盘、移库、盘点 Service/Mapper 直接使用 `InventoryBalance`、`InventoryPosition`、`InventoryLedger` 及其 Mapper |
| inbound-pda → tailleg-handoff | 9 | 9 | 入仓实收、卸柜完成直接使用根 `LastlegOrder*`、事件 Outbox Entity/Mapper |
| relabel-transfer-discard → dropship | 7 | 1 | 废弃直接读取一件代发箱/SKU 库存 Entity,调整服务写箱流水 Mapper |
| relabel-transfer-discard → tailleg-handoff | 6 | 6 | 换标、转运、废弃直接使用根尾程订单、明细、状态历史 Entity/Mapper |
| dispatch-delivery → tailleg-handoff | 4 | 4 | 拣货归属和派送转移直接使用根尾程订单与状态 Mapper;该范围不进入本专项首切片 |
| dispatch-delivery → inbound-pda | 4 | 2 | 派送转移直接使用入仓计划行、托盘分配 Entity/Mapper;该范围不进入本专项首切片 |
| relabel-transfer-discard → inventory | 4 | 1 | 转运直接使用库存余额/库位模型及库存命令 Mapper |
当前泄漏体现的是业务核心对其他切片持久化实现和表模型的直接依赖。后续治理应以应用 Port 和切片自有投影替换,不共享领域 Entity;本轮不做替换。
## 6. 首切片选择:车辆档案
选择 `masterdata.vehicle` 作为首个低耦合切片候选,依据当前主源码图:
1. 只有 5 个生产类,已经集中在单一子域;现有测试 3 个。
2. 零跨切片入向类依赖;唯一出向类依赖为 `PlatformVehicleController -> OverseasPermissions`。
3. 海外主数据不在 main 或 main+test 的逻辑 SCC 内,车辆子域也没有 Entity/DTO/Mapper 跨切片泄漏。
4. 不包含跨服务 Client、RestClient 或 HTTP Gateway,不受同步依赖超时专项影响。
5. 车辆档案明确不保存派送履约历史;首切片不触碰 `dispatch/`、`delivery/`、出库或派送代码。
6. 当前业务面固定为平台车辆档案列表、详情、创建、编辑、状态和 CSV 导出,不需要补齐四期业务缺口。
报价子域虽然也不在 SCC,但被采购服务通过 `PartnerWarehouseDirectory` 入向依赖,并包含 `DomesticPartnerWarehouseClient`,因此避让。月度汇率有 8 个生产类,也比车辆档案更大。
## 7. 已实施目标目录与精确文件
首切片已按下列结构实施:
```text
masterdata/vehicle/
adapter/in/web/
PlatformVehicleController.java
application/
VehicleApplicationService.java
model/VehicleModels.java
port/out/VehicleRepository.java
adapter/out/persistence/
VehiclePersistenceAdapter.java
VehicleMapper.java
VehicleRecord.java
```
精确动作草案:
| 原文件 | 已实施动作 |
|---|---|
| `masterdata/vehicle/controller/PlatformVehicleController.java` | 移至 `adapter/in/web/`;URI、权限、HTTP/CSV 响应不变 |
| `masterdata/vehicle/dto/VehicleDtos.java` | 移至 `application/model/VehicleModels.java`;JSON 字段、校验和响应语义不变 |
| `masterdata/vehicle/service/VehicleService.java` | 移至 `application/VehicleApplicationService.java`;业务校验、乐观锁和状态语义不变,只依赖 Out Port |
| 新增 `application/port/out/VehicleRepository.java` | 定义分页、详情、锁定、创建、乐观锁更新、状态更新和导出查询所需的切片内投影;不暴露 MyBatis 类型 |
| `masterdata/vehicle/entity/Vehicle.java` | 移至 `adapter/out/persistence/VehicleRecord.java`;仅作为 `ll_vehicle` 持久化记录 |
| `masterdata/vehicle/mapper/VehicleMapper.java` | 移至 `adapter/out/persistence/VehicleMapper.java`;保持现有 SQL 与 MyBatis 语义 |
| 新增 `adapter/out/persistence/VehiclePersistenceAdapter.java` | 实现 `VehicleRepository`,隔离 MyBatis `Page`、Wrapper、Mapper 和 Record |
允许依赖方向:
```text
adapter.in.web -> application -> application.model + application.port.out
adapter.out.persistence -> application.port.out + application.model
```
Web Adapter 可继续依赖 Spring Web、common-security 和 `OverseasPermissions`。`application` 禁止依赖 Spring MVC、MyBatis/MyBatis-Plus、全局 Mapper/Entity、RestClient、其他业务切片或出库/派送类型。
## 8. 测试与回滚边界
本次迁移只修改车辆子域 5 个原生产文件、新增 2 个车辆子域文件,并定向更新:
- `src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java`:改为 Mock `VehicleRepository`,继续锁定公里码、乐观锁和状态规则。
- `src/test/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleControllerContractTest.java`:继续锁定 URI 与六项权限。
- `src/test/java/com/baiyue/lastleg/masterdata/vehicle/VehicleSqlContractTest.java`:继续锁定 `ll_vehicle` SQL、唯一索引和字段注释。
- `scripts/analyze-lastleg-dependencies.test.mjs`:已锁定目标 7 类与 application 禁止依赖规则。
回滚只涉及上述车辆 Java 包移动、两个新增抽象及对应测试引用。不得修改或回滚共享 POM、`AGENTS.md`、RestClient 配置、数据库 SQL、OpenAPI、权限 SQL、接口路径、字段、状态、CSV 格式、Nacos、部署或其他业务域。若 Mapper 扫描或事务代理合同不满足,整体恢复车辆包内变更,不通过扩大全局扫描、兼容 Bean 或跨切片共享 Entity 绕过。
## 9. 当前只读合同
- `scripts/analyze-lastleg-dependencies.mjs`:生成 JSON、Markdown、逻辑切片 DOT、main/test/all 物理包 DOT 和 Tarjan SCC。
- `src/test/resources/architecture/lastleg-slice-dependencies.json`:冻结 25 条 main 切片边;历史边可减少,不可增加。
- `scripts/analyze-lastleg-dependencies.test.mjs`:校验基线、main/test/all 包图输出,以及车辆候选的 5 类、零入向、唯一权限出向、零 Client/RestClient 和不进入 SCC。
最小验证:
```powershell
node --test lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs
node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set main --format markdown
node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set test --format markdown
node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all --format markdown
```
## 10. 首切片实施结果
- 旧 5 类及旧测试包已删除,没有转发兼容层;目标 7 类已由 Node 合同锁定。
- `VehicleApplicationService` 只依赖 `VehicleModels` 与 `VehicleRepository`,不依赖 MyBatis/MyBatis-Plus、Spring MVC、RestClient、全局 Mapper/Entity 或其他 Lastleg 切片。
- `VehiclePersistenceAdapter` 独占 MyBatis `Page`、Wrapper、Mapper 和 `VehicleRecord`,只向 Port 返回 Java 中立投影。
- 车辆切片保持零入向依赖,唯一出向仍是 Web Adapter 使用 `OverseasPermissions`;全局 25 条切片边、60 条模型泄漏、38 条 Mapper 泄漏及主 SCC 均未增加。
- 接口路径、六项权限、JSON 字段、校验、乐观锁、状态、CSV、SQL、表和索引保持不变。
- 验证结果:Node 合同 4/4;`mvn -pl lastleg-service -am test-compile` 成功;车辆定向测试 5/5;`mvn -pl lastleg-service -am test` 中 lastleg-service 370 个测试,0 失败、0 错误、2 个环境验收跳过,构建成功。
首切片完成后停止,不进入第二切片。
@@ -23,20 +23,22 @@ const sliceRules = [
];
function parseArgs(argv) {
const options = { sourceSet: 'main', format: 'json', output: null, check: null };
const options = { sourceSet: 'main', format: 'json', graph: 'slice', output: null, check: null };
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === '--source-set') options.sourceSet = argv[++index];
else if (argument === '--format') options.format = argv[++index];
else if (argument === '--graph') options.graph = argv[++index];
else if (argument === '--output') options.output = argv[++index];
else if (argument === '--check') options.check = argv[++index];
else if (argument === '--help') {
process.stdout.write('Usage: node scripts/analyze-lastleg-dependencies.mjs [--source-set main|test|all] [--format json|markdown|dot] [--output FILE] [--check CONTRACT.json]\n');
process.stdout.write('Usage: node scripts/analyze-lastleg-dependencies.mjs [--source-set main|test|all] [--format json|markdown|dot] [--graph slice|package] [--output FILE] [--check CONTRACT.json]\n');
process.exit(0);
} else throw new Error(`Unknown argument: ${argument}`);
}
if (!['main', 'test', 'all'].includes(options.sourceSet)) throw new Error(`Invalid source set: ${options.sourceSet}`);
if (!['json', 'markdown', 'dot'].includes(options.format)) throw new Error(`Invalid format: ${options.format}`);
if (!['slice', 'package'].includes(options.graph)) throw new Error(`Invalid graph: ${options.graph}`);
return options;
}
@@ -254,9 +256,14 @@ function renderMarkdown(report) {
].join('\n');
}
function renderDot(report) {
const lines = ['digraph lastleg_slices {', ' rankdir=LR;'];
for (const edge of report.sliceEdges) lines.push(` "${edge.source}" -> "${edge.target}" [label="${edge.count}"];`);
function renderDot(report, graph) {
const edges = graph === 'package' ? report.packageEdges : report.sliceEdges;
const nodes = graph === 'package'
? [...new Set(report.classes.map(entry => entry.packageName))].sort()
: [...new Set(report.classes.map(entry => entry.slice))].sort();
const lines = [`digraph lastleg_${graph}s {`, ' rankdir=LR;'];
for (const node of nodes) lines.push(` "${node}";`);
for (const edge of edges) lines.push(` "${edge.source}" -> "${edge.target}" [label="${edge.count}"];`);
lines.push('}', '');
return lines.join('\n');
}
@@ -267,6 +274,30 @@ function checkContract(report, contractPath) {
const allowed = new Set(contract.allowedSliceEdges ?? []);
const actual = report.sliceEdges.map(edge => `${edge.source}->${edge.target}`);
const violations = actual.filter(edge => !allowed.has(edge)).map(edge => `new slice dependency: ${edge}`);
const classesByName = new Map(report.classes.map(entry => [entry.fqcn, entry]));
for (const [fqcn, expectedFile] of Object.entries(contract.requiredClassLocations ?? {})) {
const actualClass = classesByName.get(fqcn);
if (!actualClass) violations.push(`required class missing: ${fqcn}`);
else if (actualClass.file !== expectedFile) violations.push(`class location changed: ${fqcn} (${actualClass.file})`);
}
const applicationRule = contract.applicationDependencyRule;
if (applicationRule) {
const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(applicationRule.packagePrefix));
for (const entry of applicationClasses) {
for (const imported of entry.imports) {
if ((applicationRule.forbiddenImportPrefixes ?? []).some(prefix => imported.startsWith(prefix))) {
violations.push(`forbidden application import: ${entry.fqcn} -> ${imported}`);
}
}
}
for (const edge of report.classDependencies) {
if (edge.source.startsWith(applicationRule.packagePrefix)
&& edge.target.startsWith(internalPrefix)
&& !edge.target.startsWith(applicationRule.allowedInternalPrefix)) {
violations.push(`forbidden application dependency: ${edge.source} -> ${edge.target}`);
}
}
}
for (const [slice, rule] of Object.entries(contract.sliceSpecificRules ?? {})) {
const outgoing = report.sliceEdges.filter(edge => edge.source === slice).map(edge => edge.target).sort();
const incoming = report.sliceEdges.filter(edge => edge.target === slice).map(edge => edge.source).sort();
@@ -290,6 +321,6 @@ const rendered = options.format === 'json'
? `${JSON.stringify(report, null, 2)}\n`
: options.format === 'markdown'
? renderMarkdown(report)
: renderDot(report);
: renderDot(report, options.graph);
if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8');
else process.stdout.write(rendered);
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import test from 'node:test';
const moduleRoot = path.resolve(import.meta.dirname, '..');
const analyzer = path.join(moduleRoot, 'scripts', 'analyze-lastleg-dependencies.mjs');
const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'lastleg-slice-dependencies.json');
const vehiclePrefix = 'com.baiyue.lastleg.masterdata.vehicle';
function analyze(sourceSet = 'main') {
return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], {
cwd: moduleRoot,
encoding: 'utf8',
}));
}
test('current source does not add slice dependencies beyond the Lastleg baseline', () => {
execFileSync(process.execPath, [analyzer, '--source-set', 'main', '--format', 'dot', '--check', contract], {
cwd: moduleRoot,
encoding: 'utf8',
});
});
test('main, test, and combined source sets expose package dependency graphs', () => {
for (const sourceSet of ['main', 'test', 'all']) {
const dot = execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'dot', '--graph', 'package'], {
cwd: moduleRoot,
encoding: 'utf8',
});
assert.match(dot, /^digraph lastleg_packages \{/);
assert.match(dot, /com\.baiyue\.lastleg/);
}
});
test('vehicle master data remains the reviewed low-coupling first-slice candidate', () => {
const report = analyze();
const classes = report.classes.filter(entry => entry.fqcn.startsWith(vehiclePrefix));
assert.deepEqual(classes.map(entry => entry.fqcn).sort(), [
'com.baiyue.lastleg.masterdata.vehicle.adapter.in.web.PlatformVehicleController',
'com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleMapper',
'com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehiclePersistenceAdapter',
'com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleRecord',
'com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService',
'com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels',
'com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository',
]);
const outgoing = report.classDependencies.filter(edge => edge.source.startsWith(vehiclePrefix)
&& !edge.target.startsWith(vehiclePrefix));
const incoming = report.classDependencies.filter(edge => edge.target.startsWith(vehiclePrefix)
&& !edge.source.startsWith(vehiclePrefix));
assert.deepEqual(outgoing, [{
source: 'com.baiyue.lastleg.masterdata.vehicle.adapter.in.web.PlatformVehicleController',
target: 'com.baiyue.lastleg.security.OverseasPermissions',
}]);
assert.deepEqual(incoming, []);
assert.equal(report.sliceSccs.some(component => component.includes('overseas-master-data')), false);
assert.equal(classes.some(entry => entry.kind === 'cross-service-adapter'), false);
assert.equal(classes.some(entry => entry.imports.some(imported => /RestClient|\.client\./.test(imported))), false);
});
test('vehicle application only depends inward and stays free of adapter frameworks', () => {
const report = analyze();
const prefix = `${vehiclePrefix}.application`;
const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(prefix));
assert.equal(applicationClasses.length, 3);
for (const entry of applicationClasses) {
assert.equal(entry.imports.some(imported => imported.startsWith('org.springframework.web')), false);
assert.equal(entry.imports.some(imported => imported.startsWith('org.apache.ibatis')), false);
assert.equal(entry.imports.some(imported => imported.startsWith('com.baomidou.mybatisplus')), false);
assert.equal(entry.imports.some(imported => /RestClient|\.client\./.test(imported)), false);
}
const outwardDependencies = report.classDependencies.filter(edge => edge.source.startsWith(prefix)
&& edge.target.startsWith('com.baiyue.lastleg.') && !edge.target.startsWith(prefix));
assert.deepEqual(outwardDependencies, []);
});
@@ -10,12 +10,19 @@ import java.net.http.HttpClient;
public class LastlegRestClientFactory {
public RestClient build(RestClient.Builder builder, String baseUrl,
LastlegRestClientProperties.ClientTimeouts timeouts) {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(timeouts.getConnectTimeout())
.build();
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient);
requestFactory.setReadTimeout(timeouts.getReadTimeout());
JdkClientHttpRequestFactory requestFactory = requestFactory(timeouts);
return builder.requestFactory(requestFactory).baseUrl(baseUrl).build();
}
}
HttpClient httpClient(LastlegRestClientProperties.ClientTimeouts timeouts) {
return HttpClient.newBuilder()
.connectTimeout(timeouts.getConnectTimeout())
.build();
}
JdkClientHttpRequestFactory requestFactory(LastlegRestClientProperties.ClientTimeouts timeouts) {
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient(timeouts));
requestFactory.setReadTimeout(timeouts.getReadTimeout());
return requestFactory;
}
}
@@ -50,8 +50,14 @@ public class LastlegOrderController {
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestBody EventEnvelope event) {
tokenGuard.verify(token);
int consumed = EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(event.eventType())
? shippingNodeConsumer.consume(event) : consumer.consume(event);
int consumed;
if (EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(event.eventType())) {
consumed = shippingNodeConsumer.consume(event);
} else if (EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(event.eventType())) {
consumed = consumer.consume(event);
} else {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Lastleg 不消费该事件类型: " + event.eventType());
}
return ApiResponse.success(consumed);
}
@@ -0,0 +1,103 @@
package com.baiyue.lastleg.masterdata.vehicle.adapter.in.web;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService;
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService.VehicleFailure;
import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.util.function.Supplier;
@RestController
@RequestMapping("/api/platform/v1/last-leg/master-data/vehicles")
@Tag(name = "Vehicle master data / 车辆主数据")
public class PlatformVehicleController {
private final VehicleApplicationService service;
public PlatformVehicleController(VehicleApplicationService service) {
this.service = service;
}
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')")
@Operation(summary = "Page vehicle master data", description = "Required authority: platform:overseas-master-data:vehicle:list")
public ApiResponse<PageResult<VehicleModels.Response>> page(@Valid VehicleModels.Query query) {
return execute(() -> ApiResponse.success(service.page(query)));
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')")
@Operation(summary = "Get vehicle master data detail", description = "Required authority: platform:overseas-master-data:vehicle:list; excludes delivery fulfillment history")
public ApiResponse<VehicleModels.Response> detail(@PathVariable long id) {
return execute(() -> ApiResponse.success(service.detail(id)));
}
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_CREATE + "')")
public ApiResponse<VehicleModels.Response> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody VehicleModels.Create request) {
return execute(() -> ApiResponse.success(service.create(operator(user), request)));
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EDIT + "')")
public ApiResponse<VehicleModels.Response> update(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long id,
@Valid @RequestBody VehicleModels.Update request) {
return execute(() -> ApiResponse.success(service.update(id, operator(user), request)));
}
@PatchMapping("/{id}/status")
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_STATUS + "')")
public ApiResponse<VehicleModels.Response> status(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long id,
@Valid @RequestBody VehicleModels.ChangeStatus request) {
return execute(() -> ApiResponse.success(service.changeStatus(id, operator(user), request)));
}
@GetMapping(value = "/export", produces = "text/csv")
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EXPORT + "')")
public ResponseEntity<byte[]> export(@Valid VehicleModels.Query query) {
return execute(() -> ResponseEntity.ok().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=vehicles.csv").body(service.exportCsv(query)));
}
private String operator(AuthenticatedUser user) {
if (user == null) return "UNKNOWN";
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
private <T> T execute(Supplier<T> action) {
try {
return action.get();
} catch (VehicleFailure failure) {
HttpStatus status = switch (failure.kind()) {
case BAD_REQUEST -> HttpStatus.BAD_REQUEST;
case NOT_FOUND -> HttpStatus.NOT_FOUND;
case CONFLICT -> HttpStatus.CONFLICT;
};
throw new ResponseStatusException(status, failure.getMessage(), failure);
}
}
}
@@ -1,13 +1,12 @@
package com.baiyue.lastleg.masterdata.vehicle.mapper;
package com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface VehicleMapper extends BaseMapper<Vehicle> {
public interface VehicleMapper extends BaseMapper<VehicleRecord> {
@Select("SELECT * FROM ll_vehicle WHERE id=#{id} LIMIT 1 FOR UPDATE")
Vehicle lockById(@Param("id") long id);
VehicleRecord lockById(@Param("id") long id);
}
@@ -0,0 +1,121 @@
package com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Optional;
@Repository
public class VehiclePersistenceAdapter implements VehicleRepository {
private final VehicleMapper vehicles;
public VehiclePersistenceAdapter(VehicleMapper vehicles) {
this.vehicles = vehicles;
}
@Override
public PageSlice page(SearchCriteria criteria) {
Page<VehicleRecord> page = vehicles.selectPage(Page.of(criteria.page(), criteria.size()), wrapper(criteria));
return new PageSlice(page.getRecords().stream().map(this::snapshot).toList(), page.getCurrent(),
page.getSize(), page.getTotal(), page.getPages());
}
@Override
public List<VehicleSnapshot> list(SearchCriteria criteria) {
return vehicles.selectList(wrapper(criteria)).stream().map(this::snapshot).toList();
}
@Override
public Optional<VehicleSnapshot> findById(long id) {
return Optional.ofNullable(vehicles.selectById(id)).map(this::snapshot);
}
@Override
public Optional<VehicleSnapshot> lockById(long id) {
return Optional.ofNullable(vehicles.lockById(id)).map(this::snapshot);
}
@Override
public long create(NewVehicle vehicle) {
VehicleRecord record = new VehicleRecord();
record.setId(IdWorker.getId());
record.setPlateNumber(vehicle.plateNumber());
record.setModel(vehicle.model());
record.setFuelConsumptionPer100Km(vehicle.fuelConsumptionPer100Km());
record.setMileageKm(vehicle.mileageKm());
record.setDriverOrDispatchCompany(vehicle.driverOrDispatchCompany());
record.setPhone(vehicle.phone());
record.setRegion(vehicle.region());
record.setOwnership(vehicle.ownership());
record.setStatus(vehicle.status());
record.setRemark(vehicle.remark());
record.setRecordVersion(0L);
record.setCreatedBy(vehicle.operator());
record.setUpdatedBy(vehicle.operator());
record.setCreatedAt(vehicle.createdAt());
record.setUpdatedAt(vehicle.createdAt());
try {
vehicles.insert(record);
} catch (DuplicateKeyException exception) {
throw new DuplicatePlateException(exception);
}
return record.getId();
}
@Override
public boolean update(long id, long expectedVersion, VehicleChanges changes) {
try {
return vehicles.update(null, new LambdaUpdateWrapper<VehicleRecord>()
.eq(VehicleRecord::getId, id).eq(VehicleRecord::getRecordVersion, expectedVersion)
.set(VehicleRecord::getPlateNumber, changes.plateNumber())
.set(VehicleRecord::getModel, changes.model())
.set(VehicleRecord::getFuelConsumptionPer100Km, changes.fuelConsumptionPer100Km())
.set(VehicleRecord::getMileageKm, changes.mileageKm())
.set(VehicleRecord::getDriverOrDispatchCompany, changes.driverOrDispatchCompany())
.set(VehicleRecord::getPhone, changes.phone()).set(VehicleRecord::getRegion, changes.region())
.set(VehicleRecord::getOwnership, changes.ownership()).set(VehicleRecord::getRemark, changes.remark())
.set(VehicleRecord::getUpdatedBy, changes.operator()).set(VehicleRecord::getUpdatedAt, changes.updatedAt())
.setSql("record_version = record_version + 1")) == 1;
} catch (DuplicateKeyException exception) {
throw new DuplicatePlateException(exception);
}
}
@Override
public boolean updateStatus(long id, long expectedVersion, String status, String operator,
java.time.LocalDateTime updatedAt) {
return vehicles.update(null, new LambdaUpdateWrapper<VehicleRecord>()
.eq(VehicleRecord::getId, id).eq(VehicleRecord::getRecordVersion, expectedVersion)
.set(VehicleRecord::getStatus, status).set(VehicleRecord::getUpdatedBy, operator)
.set(VehicleRecord::getUpdatedAt, updatedAt).setSql("record_version = record_version + 1")) == 1;
}
private LambdaQueryWrapper<VehicleRecord> wrapper(SearchCriteria criteria) {
return new LambdaQueryWrapper<VehicleRecord>()
.and(StringUtils.hasText(criteria.keyword()), wrapper -> wrapper.like(VehicleRecord::getPlateNumber, criteria.keyword())
.or().like(VehicleRecord::getModel, criteria.keyword())
.or().like(VehicleRecord::getDriverOrDispatchCompany, criteria.keyword()))
.eq(criteria.ownership() != null, VehicleRecord::getOwnership, criteria.ownership())
.eq(criteria.region() != null, VehicleRecord::getRegion, criteria.region())
.eq(criteria.status() != null, VehicleRecord::getStatus, criteria.status())
.orderByDesc(VehicleRecord::getUpdatedAt).orderByDesc(VehicleRecord::getId);
}
private VehicleSnapshot snapshot(VehicleRecord value) {
return new VehicleSnapshot(value.getId(), value.getPlateNumber(), value.getModel(),
value.getFuelConsumptionPer100Km(), value.getMileageKm(), value.getDriverOrDispatchCompany(), value.getPhone(),
value.getRegion(), value.getOwnership(), value.getStatus(), value.getRemark(), version(value.getRecordVersion()),
value.getUpdatedBy(), value.getUpdatedAt());
}
private static long version(Long value) {
return value == null ? 0L : value;
}
}
@@ -1,4 +1,4 @@
package com.baiyue.lastleg.masterdata.vehicle.entity;
package com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
@@ -10,7 +10,7 @@ import java.time.LocalDateTime;
@Data
@TableName("ll_vehicle")
public class Vehicle {
public class VehicleRecord {
@TableId(type = IdType.INPUT) private Long id;
private String plateNumber;
private String model;
@@ -0,0 +1,165 @@
package com.baiyue.lastleg.masterdata.vehicle.application;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.DuplicatePlateException;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.NewVehicle;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.SearchCriteria;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.VehicleChanges;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.VehicleSnapshot;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.Locale;
import java.util.Set;
@Service
public class VehicleApplicationService {
private static final Set<String> OWNERSHIPS = Set.of("OWNED", "DISPATCHED");
private static final Set<String> REGIONS = Set.of("DOMESTIC", "OVERSEAS");
private static final Set<String> STATUSES = Set.of("IN_SERVICE", "DISPATCHED", "MAINTENANCE", "INACTIVE", "RETURNED");
private final VehicleRepository vehicles;
public VehicleApplicationService(VehicleRepository vehicles) {
this.vehicles = vehicles;
}
public PageResult<VehicleModels.Response> page(VehicleModels.Query query) {
var page = vehicles.page(criteria(query));
return new PageResult<>(page.records().stream().map(this::response).toList(), page.page(),
page.size(), page.total(), page.pages());
}
public VehicleModels.Response detail(long id) {
return response(required(id));
}
@Transactional
public VehicleModels.Response create(String operator, VehicleModels.Create request) {
LocalDateTime now = LocalDateTime.now();
long id;
try {
id = vehicles.create(new NewVehicle(
requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT),
requiredText(request.model(), "车型"), request.fuelConsumptionPer100Km(), request.mileageKm(),
trim(request.driverOrDispatchCompany()), trim(request.phone()), region(request.region()),
ownership(request.ownership()), status(request.status()), trim(request.remark()), operator, now));
} catch (DuplicatePlateException exception) {
throw conflict("车牌号已存在");
}
return detail(id);
}
@Transactional
public VehicleModels.Response update(long id, String operator, VehicleModels.Update request) {
VehicleSnapshot current = locked(id);
requireVersion(current, request.recordVersion());
if (request.mileageKm() < current.mileageKm()) throw badRequest("当前公里码不能小于已有公里码");
boolean updated;
try {
updated = vehicles.update(id, request.recordVersion(), new VehicleChanges(
requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT),
requiredText(request.model(), "车型"), request.fuelConsumptionPer100Km(), request.mileageKm(),
trim(request.driverOrDispatchCompany()), trim(request.phone()), region(request.region()),
ownership(request.ownership()), trim(request.remark()), operator, LocalDateTime.now()));
} catch (DuplicatePlateException exception) {
throw conflict("车牌号已存在");
}
if (!updated) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
return detail(id);
}
@Transactional
public VehicleModels.Response changeStatus(long id, String operator, VehicleModels.ChangeStatus request) {
VehicleSnapshot current = locked(id);
requireVersion(current, request.recordVersion());
String status = status(request.status());
if (status.equals(current.status())) return response(current);
boolean updated = vehicles.updateStatus(id, request.recordVersion(), status, operator, LocalDateTime.now());
if (!updated) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
return detail(id);
}
public byte[] exportCsv(VehicleModels.Query query) {
var rows = vehicles.list(criteria(query)).stream().map(this::response).toList();
StringBuilder csv = new StringBuilder("\uFEFF车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n");
for (var row : rows) csv.append(row.id()).append(',').append(cell(row.plateNumber())).append(',')
.append(cell(row.model())).append(',').append(row.fuelConsumptionPer100Km()).append(',').append(row.mileageKm()).append(',')
.append(cell(row.driverOrDispatchCompany())).append(',').append(cell(row.phone())).append(',')
.append(row.region()).append(',').append(row.ownership()).append(',').append(row.status()).append(',')
.append(row.updatedAt()).append("\r\n");
return csv.toString().getBytes(StandardCharsets.UTF_8);
}
private SearchCriteria criteria(VehicleModels.Query query) {
return new SearchCriteria(query.page(), query.size(), trim(query.keyword()),
optional(query.ownership(), OWNERSHIPS, "车辆归属"),
optional(query.region(), REGIONS, "车辆区域"),
optional(query.status(), STATUSES, "车辆状态"));
}
private VehicleSnapshot required(long id) {
return vehicles.findById(id).orElseThrow(VehicleApplicationService::notFound);
}
private VehicleSnapshot locked(long id) {
return vehicles.lockById(id).orElseThrow(VehicleApplicationService::notFound);
}
private VehicleModels.Response response(VehicleSnapshot value) {
return new VehicleModels.Response(String.valueOf(value.id()), value.plateNumber(), value.model(),
value.fuelConsumptionPer100Km(), value.mileageKm(), value.driverOrDispatchCompany(), value.phone(),
value.region(), value.ownership(), value.status(), value.remark(), value.recordVersion(),
value.updatedBy(), value.updatedAt());
}
private void requireVersion(VehicleSnapshot value, long expected) {
if (value.recordVersion() != expected) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
}
private static String ownership(String value) { return required(value, OWNERSHIPS, "车辆归属"); }
private static String region(String value) { return required(value, REGIONS, "车辆区域"); }
private static String status(String value) { return required(value, STATUSES, "车辆状态"); }
private static String optional(String value, Set<String> allowed, String label) {
return StringUtils.hasText(value) ? required(value, allowed, label) : null;
}
private static String required(String value, Set<String> allowed, String label) {
if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空");
String normalized = value.trim().toUpperCase(Locale.ROOT);
if (!allowed.contains(normalized)) throw badRequest(label + "不合法");
return normalized;
}
private static String requiredText(String value, String label) {
if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空");
return value.trim();
}
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; }
private static VehicleFailure notFound() { return new VehicleFailure(FailureKind.NOT_FOUND, "车辆档案不存在"); }
private static VehicleFailure badRequest(String message) { return new VehicleFailure(FailureKind.BAD_REQUEST, message); }
private static VehicleFailure conflict(String message) { return new VehicleFailure(FailureKind.CONFLICT, message); }
public enum FailureKind { BAD_REQUEST, NOT_FOUND, CONFLICT }
public static final class VehicleFailure extends RuntimeException {
private final FailureKind kind;
public VehicleFailure(FailureKind kind, String message) {
super(message);
this.kind = kind;
}
public FailureKind kind() {
return kind;
}
}
}
@@ -1,4 +1,4 @@
package com.baiyue.lastleg.masterdata.vehicle.dto;
package com.baiyue.lastleg.masterdata.vehicle.application.model;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
@@ -11,8 +11,8 @@ import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public final class VehicleDtos {
private VehicleDtos() { }
public final class VehicleModels {
private VehicleModels() { }
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
@Size(max = 128) String keyword, String ownership, String region, String status) {
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.masterdata.vehicle.application.port.out;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
public interface VehicleRepository {
PageSlice page(SearchCriteria criteria);
List<VehicleSnapshot> list(SearchCriteria criteria);
Optional<VehicleSnapshot> findById(long id);
Optional<VehicleSnapshot> lockById(long id);
long create(NewVehicle vehicle);
boolean update(long id, long expectedVersion, VehicleChanges changes);
boolean updateStatus(long id, long expectedVersion, String status, String operator, LocalDateTime updatedAt);
record SearchCriteria(int page, int size, String keyword, String ownership, String region, String status) { }
record PageSlice(List<VehicleSnapshot> records, long page, long size, long total, long pages) { }
record NewVehicle(String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm,
String driverOrDispatchCompany, String phone, String region, String ownership, String status,
String remark, String operator, LocalDateTime createdAt) { }
record VehicleChanges(String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm,
String driverOrDispatchCompany, String phone, String region, String ownership, String remark,
String operator, LocalDateTime updatedAt) { }
record VehicleSnapshot(long id, String plateNumber, String model, BigDecimal fuelConsumptionPer100Km,
long mileageKm, String driverOrDispatchCompany, String phone, String region,
String ownership, String status, String remark, long recordVersion,
String updatedBy, LocalDateTime updatedAt) { }
final class DuplicatePlateException extends RuntimeException {
public DuplicatePlateException(Throwable cause) {
super(cause);
}
}
}
@@ -1,58 +0,0 @@
package com.baiyue.lastleg.masterdata.vehicle.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.masterdata.vehicle.dto.VehicleDtos;
import com.baiyue.lastleg.masterdata.vehicle.service.VehicleService;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/api/platform/v1/last-leg/master-data/vehicles")
@Tag(name = "Vehicle master data / 车辆主数据")
public class PlatformVehicleController {
@Resource private VehicleService service;
@GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')")
@Operation(summary = "Page vehicle master data", description = "Required authority: platform:overseas-master-data:vehicle:list")
public ApiResponse<PageResult<VehicleDtos.Response>> page(@Valid VehicleDtos.Query query) { return ApiResponse.success(service.page(query)); }
@GetMapping("/{id}") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')")
@Operation(summary = "Get vehicle master data detail", description = "Required authority: platform:overseas-master-data:vehicle:list; excludes delivery fulfillment history")
public ApiResponse<VehicleDtos.Response> detail(@PathVariable long id) { return ApiResponse.success(service.detail(id)); }
@PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_CREATE + "')")
public ApiResponse<VehicleDtos.Response> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody VehicleDtos.Create request) { return ApiResponse.success(service.create(operator(user), request)); }
@PutMapping("/{id}") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EDIT + "')")
public ApiResponse<VehicleDtos.Response> update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id,
@Valid @RequestBody VehicleDtos.Update request) { return ApiResponse.success(service.update(id, operator(user), request)); }
@PatchMapping("/{id}/status") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_STATUS + "')")
public ApiResponse<VehicleDtos.Response> status(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id,
@Valid @RequestBody VehicleDtos.ChangeStatus request) { return ApiResponse.success(service.changeStatus(id, operator(user), request)); }
@GetMapping(value = "/export", produces = "text/csv")
@PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EXPORT + "')")
public ResponseEntity<byte[]> export(@Valid VehicleDtos.Query query) {
return ResponseEntity.ok().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=vehicles.csv").body(service.exportCsv(query));
}
private String operator(AuthenticatedUser user) {
if (user == null) return "UNKNOWN"; return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
}
@@ -1,143 +0,0 @@
package com.baiyue.lastleg.masterdata.vehicle.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.masterdata.vehicle.dto.VehicleDtos;
import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle;
import com.baiyue.lastleg.masterdata.vehicle.mapper.VehicleMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.Locale;
import java.util.Set;
@Service
public class VehicleService {
private static final Set<String> OWNERSHIPS = Set.of("OWNED", "DISPATCHED");
private static final Set<String> REGIONS = Set.of("DOMESTIC", "OVERSEAS");
private static final Set<String> STATUSES = Set.of("IN_SERVICE", "DISPATCHED", "MAINTENANCE", "INACTIVE", "RETURNED");
@Resource private VehicleMapper vehicles;
public PageResult<VehicleDtos.Response> page(VehicleDtos.Query query) {
Page<Vehicle> page = vehicles.selectPage(Page.of(query.page(), query.size()), wrapper(query));
return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(),
page.getSize(), page.getTotal(), page.getPages());
}
public VehicleDtos.Response detail(long id) { return response(required(id)); }
@Transactional
public VehicleDtos.Response create(String operator, VehicleDtos.Create request) {
LocalDateTime now = LocalDateTime.now();
Vehicle value = new Vehicle(); value.setId(IdWorker.getId());
value.setPlateNumber(requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT));
value.setModel(requiredText(request.model(), "车型")); value.setFuelConsumptionPer100Km(request.fuelConsumptionPer100Km());
value.setMileageKm(request.mileageKm()); value.setDriverOrDispatchCompany(trim(request.driverOrDispatchCompany()));
value.setPhone(trim(request.phone())); value.setRegion(region(request.region())); value.setOwnership(ownership(request.ownership()));
value.setStatus(status(request.status())); value.setRemark(trim(request.remark())); value.setRecordVersion(0L);
value.setCreatedBy(operator); value.setUpdatedBy(operator); value.setCreatedAt(now); value.setUpdatedAt(now);
try { vehicles.insert(value); } catch (DuplicateKeyException exception) { throw conflict("车牌号已存在"); }
return detail(value.getId());
}
@Transactional
public VehicleDtos.Response update(long id, String operator, VehicleDtos.Update request) {
Vehicle current = locked(id); requireVersion(current, request.recordVersion());
if (request.mileageKm() < current.getMileageKm()) throw badRequest("当前公里码不能小于已有公里码");
int updated;
try {
updated = vehicles.update(null, new LambdaUpdateWrapper<Vehicle>()
.eq(Vehicle::getId, id).eq(Vehicle::getRecordVersion, request.recordVersion())
.set(Vehicle::getPlateNumber, requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT))
.set(Vehicle::getModel, requiredText(request.model(), "车型"))
.set(Vehicle::getFuelConsumptionPer100Km, request.fuelConsumptionPer100Km())
.set(Vehicle::getMileageKm, request.mileageKm())
.set(Vehicle::getDriverOrDispatchCompany, trim(request.driverOrDispatchCompany()))
.set(Vehicle::getPhone, trim(request.phone())).set(Vehicle::getRegion, region(request.region()))
.set(Vehicle::getOwnership, ownership(request.ownership())).set(Vehicle::getRemark, trim(request.remark()))
.set(Vehicle::getUpdatedBy, operator).set(Vehicle::getUpdatedAt, LocalDateTime.now())
.setSql("record_version = record_version + 1"));
} catch (DuplicateKeyException exception) { throw conflict("车牌号已存在"); }
if (updated != 1) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
return detail(id);
}
@Transactional
public VehicleDtos.Response changeStatus(long id, String operator, VehicleDtos.ChangeStatus request) {
Vehicle current = locked(id); requireVersion(current, request.recordVersion());
String status = status(request.status());
if (status.equals(current.getStatus())) return response(current);
int updated = vehicles.update(null, new LambdaUpdateWrapper<Vehicle>()
.eq(Vehicle::getId, id).eq(Vehicle::getRecordVersion, request.recordVersion())
.set(Vehicle::getStatus, status).set(Vehicle::getUpdatedBy, operator)
.set(Vehicle::getUpdatedAt, LocalDateTime.now()).setSql("record_version = record_version + 1"));
if (updated != 1) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
return detail(id);
}
public byte[] exportCsv(VehicleDtos.Query query) {
var rows = vehicles.selectList(wrapper(query)).stream().map(this::response).toList();
StringBuilder csv = new StringBuilder("\uFEFF车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n");
for (var row : rows) csv.append(row.id()).append(',').append(cell(row.plateNumber())).append(',')
.append(cell(row.model())).append(',').append(row.fuelConsumptionPer100Km()).append(',').append(row.mileageKm()).append(',')
.append(cell(row.driverOrDispatchCompany())).append(',').append(cell(row.phone())).append(',')
.append(row.region()).append(',').append(row.ownership()).append(',').append(row.status()).append(',')
.append(row.updatedAt()).append("\r\n");
return csv.toString().getBytes(StandardCharsets.UTF_8);
}
private LambdaQueryWrapper<Vehicle> wrapper(VehicleDtos.Query query) {
String keyword = trim(query.keyword()); String ownership = optional(query.ownership(), OWNERSHIPS, "车辆归属");
String region = optional(query.region(), REGIONS, "车辆区域"); String status = optional(query.status(), STATUSES, "车辆状态");
return new LambdaQueryWrapper<Vehicle>()
.and(StringUtils.hasText(keyword), w -> w.like(Vehicle::getPlateNumber, keyword)
.or().like(Vehicle::getModel, keyword).or().like(Vehicle::getDriverOrDispatchCompany, keyword))
.eq(ownership != null, Vehicle::getOwnership, ownership).eq(region != null, Vehicle::getRegion, region)
.eq(status != null, Vehicle::getStatus, status).orderByDesc(Vehicle::getUpdatedAt).orderByDesc(Vehicle::getId);
}
private Vehicle required(long id) {
Vehicle value = vehicles.selectById(id); if (value == null) throw notFound(); return value;
}
private Vehicle locked(long id) {
Vehicle value = vehicles.lockById(id); if (value == null) throw notFound(); return value;
}
private VehicleDtos.Response response(Vehicle value) {
return new VehicleDtos.Response(String.valueOf(value.getId()), value.getPlateNumber(), value.getModel(),
value.getFuelConsumptionPer100Km(), value.getMileageKm(), value.getDriverOrDispatchCompany(), value.getPhone(),
value.getRegion(), value.getOwnership(), value.getStatus(), value.getRemark(), version(value.getRecordVersion()),
value.getUpdatedBy(), value.getUpdatedAt());
}
private void requireVersion(Vehicle value, long expected) {
if (version(value.getRecordVersion()) != expected) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
}
private static String ownership(String value) { return required(value, OWNERSHIPS, "车辆归属"); }
private static String region(String value) { return required(value, REGIONS, "车辆区域"); }
private static String status(String value) { return required(value, STATUSES, "车辆状态"); }
private static String optional(String value, Set<String> allowed, String label) {
return StringUtils.hasText(value) ? required(value, allowed, label) : null;
}
private static String required(String value, Set<String> allowed, String label) {
if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空");
String normalized = value.trim().toUpperCase(Locale.ROOT);
if (!allowed.contains(normalized)) throw badRequest(label + "不合法"); return normalized;
}
private static String requiredText(String value, String label) {
if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空"); return value.trim();
}
private static long version(Long value) { return value == null ? 0L : value; }
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; }
private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "车辆档案不存在"); }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -32,6 +32,8 @@ public class UnloadCompletedOutboxRepository {
.eq(LastlegEventOutbox::getId, id)
.eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED)
.and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING")
.and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt)
.or().le(LastlegEventOutbox::getNextRetryAt, now))
.or(stale -> stale.eq(LastlegEventOutbox::getStatus, "PROCESSING")
.le(LastlegEventOutbox::getLockedAt, expiredLeaseBefore)))
.set(LastlegEventOutbox::getStatus, "PROCESSING")
@@ -54,6 +54,8 @@ public class LoadingCompletedConsumer {
throw new IllegalArgumentException("尾程交接事件区域无效");
if (!"LOADING_PLAN".equals(envelope.aggregateType()))
throw new IllegalArgumentException("尾程交接事件聚合类型无效");
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()),
envelope.payloadHash())) throw new IllegalArgumentException("payloadHash 与尾程事件负载不一致");
FirstLegLoadingPlanCompletedV1 payload = parsePayload(envelope);
if(payload.containerNo()==null||payload.containerNo().isBlank())
throw new IllegalArgumentException("事件没有实际集装箱号 containerNo");
@@ -70,6 +72,11 @@ public class LoadingCompletedConsumer {
inbox.setEventVersion(envelope.eventVersion()); inbox.setPayloadHash(envelope.payloadHash());
inbox.setPayloadJson(envelope.payload().toString()); inbox.setStatus("RECEIVED"); inbox.setReceivedAt(LocalDateTime.now());
try { inboxMapper.insert(inbox); } catch(DuplicateKeyException exception) {
LastlegEventInbox raced = inboxMapper.selectById(envelope.eventId());
if (raced == null || raced.getPayloadHash() == null
|| !raced.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) {
throw new IllegalArgumentException("事件摘要冲突", exception);
}
return countOrders(envelope,payload.containerNo());
}
@@ -1,6 +1,8 @@
package com.baiyue.lastleg.shipping.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.FirstLegContainerShippingNodeRecordedV1;
import com.baiyue.common.event.RegionCode;
@@ -91,6 +93,10 @@ public class ContainerShippingNodeConsumer {
private void validate(EventEnvelope envelope) {
envelope.requireValid();
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()),
envelope.payloadHash())) {
throw new IllegalArgumentException("payloadHash 与船节点事件负载不一致");
}
if (!EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(envelope.eventType())
|| envelope.eventVersion() != 1 || envelope.sourceRegion() != RegionCode.CN
|| envelope.targetRegion() != RegionCode.OVERSEAS
@@ -0,0 +1,84 @@
package com.baiyue.lastleg.config;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.source.MapConfigurationPropertySource;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClient;
import java.net.InetSocketAddress;
import java.time.Duration;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class LastlegRestClientFactoryTest {
private final LastlegRestClientFactory factory = new LastlegRestClientFactory();
@Test
void defaultsArePositiveAndSeparateFileUploadsFromWarehouseCalls() {
LastlegRestClientProperties properties = new LastlegRestClientProperties();
assertThat(properties.getUnloadPhotoFile().getConnectTimeout()).isEqualTo(Duration.ofSeconds(3));
assertThat(properties.getUnloadPhotoFile().getReadTimeout()).isEqualTo(Duration.ofSeconds(30));
assertThat(properties.getRelabelPhotoFile().getReadTimeout()).isEqualTo(Duration.ofSeconds(30));
assertThat(properties.getWarehouseResource().getReadTimeout()).isEqualTo(Duration.ofSeconds(5));
assertThat(properties.getPalletPhysical().getReadTimeout()).isEqualTo(Duration.ofSeconds(5));
}
@Test
void rejectsUnboundedOrNonPositiveTimeouts() {
assertThatThrownBy(() -> new LastlegRestClientProperties.ClientTimeouts(null, Duration.ofSeconds(1)))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new LastlegRestClientProperties.ClientTimeouts(Duration.ZERO, Duration.ofSeconds(1)))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new LastlegRestClientProperties.ClientTimeouts(Duration.ofSeconds(1), Duration.ZERO))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void bindsPerClientTimeoutOverridesFromConfiguration() {
var source = new MapConfigurationPropertySource(Map.of(
"baiyue.http.clients.unload-photo-file.connect-timeout", "125ms",
"baiyue.http.clients.unload-photo-file.read-timeout", "9s",
"baiyue.http.clients.pallet-physical.connect-timeout", "250ms",
"baiyue.http.clients.pallet-physical.read-timeout", "2s"));
LastlegRestClientProperties properties = new Binder(source)
.bind("baiyue.http.clients", Bindable.of(LastlegRestClientProperties.class))
.orElseThrow(() -> new AssertionError("Lastleg HTTP client properties were not bound"));
assertThat(properties.getUnloadPhotoFile().getConnectTimeout()).isEqualTo(Duration.ofMillis(125));
assertThat(properties.getUnloadPhotoFile().getReadTimeout()).isEqualTo(Duration.ofSeconds(9));
assertThat(properties.getPalletPhysical().getConnectTimeout()).isEqualTo(Duration.ofMillis(250));
assertThat(properties.getPalletPhysical().getReadTimeout()).isEqualTo(Duration.ofSeconds(2));
assertThat(properties.getWarehouseResource().getReadTimeout()).isEqualTo(Duration.ofSeconds(5));
}
@Test
void appliesConfiguredConnectAndReadTimeoutsWithoutRetryOrFallback() throws Exception {
var timeouts = new LastlegRestClientProperties.ClientTimeouts(
Duration.ofMillis(250), Duration.ofMillis(75));
assertThat(factory.httpClient(timeouts).connectTimeout()).contains(Duration.ofMillis(250));
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/slow", exchange -> {
try { Thread.sleep(500); }
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
exchange.sendResponseHeaders(200, 0);
exchange.close();
});
server.start();
try {
RestClient client = factory.build(RestClient.builder(),
"http://127.0.0.1:" + server.getAddress().getPort(), timeouts);
assertThatThrownBy(() -> client.get().uri("/slow").retrieve().toBodilessEntity())
.isInstanceOf(ResourceAccessException.class);
} finally {
server.stop(0);
}
}
}
@@ -3,6 +3,8 @@ package com.baiyue.lastleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventTypes;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
@@ -21,6 +23,8 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.when;
class LastlegOrderControllerTest {
private final LastlegOrderService service = mock(LastlegOrderService.class);
@@ -46,6 +50,17 @@ class LastlegOrderControllerTest {
verify(service).page(eq("200"), eq(query));
}
@Test
void iamSecurityEventsAreRejectedBeforeLoadingCompletedConsumer() {
EventEnvelope event = mock(EventEnvelope.class);
when(event.eventType()).thenReturn(EventTypes.IAM_USER_STATUS_CHANGED);
LoadingCompletedConsumer loading = (LoadingCompletedConsumer) ReflectionTestUtils.getField(controller, "consumer");
assertThrows(ResponseStatusException.class, () -> controller.receive("internal-token", event));
verify(loading, never()).consume(event);
}
private static AuthenticatedUser user(long customerId, String appCode) {
return new AuthenticatedUser(1L, customerId, customerId, 1L, "tester", appCode, "WEB", Set.of());
}
@@ -1,4 +1,4 @@
package com.baiyue.lastleg.masterdata.vehicle.controller;
package com.baiyue.lastleg.masterdata.vehicle.adapter.in.web;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -0,0 +1,64 @@
package com.baiyue.lastleg.masterdata.vehicle.application;
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService.FailureKind;
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService.VehicleFailure;
import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.VehicleSnapshot;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class VehicleApplicationServiceTest {
private VehicleRepository repository;
private VehicleApplicationService service;
@BeforeEach
void setUp() {
repository = mock(VehicleRepository.class);
service = new VehicleApplicationService(repository);
}
@Test
void rejectsStaleVersionBeforeUpdating() {
when(repository.lockById(1L)).thenReturn(Optional.of(vehicle(3L, 1000L)));
VehicleModels.Update request = update(2L, 1000L);
assertThatThrownBy(() -> service.update(1L, "9", request))
.isInstanceOfSatisfying(VehicleFailure.class,
failure -> assertThat(failure.kind()).isEqualTo(FailureKind.CONFLICT));
}
@Test
void rejectsDecreasingOdometer() {
when(repository.lockById(1L)).thenReturn(Optional.of(vehicle(3L, 1000L)));
VehicleModels.Update request = update(3L, 999L);
assertThatThrownBy(() -> service.update(1L, "9", request))
.isInstanceOfSatisfying(VehicleFailure.class,
failure -> assertThat(failure.kind()).isEqualTo(FailureKind.BAD_REQUEST));
}
@Test
void rejectsUnknownStatus() {
when(repository.lockById(1L)).thenReturn(Optional.of(vehicle(3L, 1000L)));
assertThatThrownBy(() -> service.changeStatus(1L, "9", new VehicleModels.ChangeStatus("BUSY", 3L)))
.isInstanceOfSatisfying(VehicleFailure.class,
failure -> assertThat(failure.kind()).isEqualTo(FailureKind.BAD_REQUEST));
}
private static VehicleModels.Update update(long version, long mileage) {
return new VehicleModels.Update(version, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage,
"Driver", "123", "OVERSEAS", "OWNED", null);
}
private static VehicleSnapshot vehicle(long version, long mileage) {
return new VehicleSnapshot(1L, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage,
"Driver", "123", "OVERSEAS", "OWNED", "IN_SERVICE", null, version, "9", null);
}
}
@@ -1,63 +0,0 @@
package com.baiyue.lastleg.masterdata.vehicle.service;
import com.baiyue.lastleg.masterdata.vehicle.dto.VehicleDtos;
import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle;
import com.baiyue.lastleg.masterdata.vehicle.mapper.VehicleMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class VehicleServiceTest {
private VehicleMapper mapper;
private VehicleService service;
@BeforeEach
void setUp() {
mapper = mock(VehicleMapper.class);
service = new VehicleService();
ReflectionTestUtils.setField(service, "vehicles", mapper);
}
@Test
void rejectsStaleVersionBeforeUpdating() {
when(mapper.lockById(1L)).thenReturn(vehicle(3L, 1000L));
VehicleDtos.Update request = update(2L, 1000L);
assertThatThrownBy(() -> service.update(1L, "9", request))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("409 CONFLICT");
}
@Test
void rejectsDecreasingOdometer() {
when(mapper.lockById(1L)).thenReturn(vehicle(3L, 1000L));
VehicleDtos.Update request = update(3L, 999L);
assertThatThrownBy(() -> service.update(1L, "9", request))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("400 BAD_REQUEST");
}
@Test
void rejectsUnknownStatus() {
when(mapper.lockById(1L)).thenReturn(vehicle(3L, 1000L));
assertThatThrownBy(() -> service.changeStatus(1L, "9", new VehicleDtos.ChangeStatus("BUSY", 3L)))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("400 BAD_REQUEST");
}
private static VehicleDtos.Update update(long version, long mileage) {
return new VehicleDtos.Update(version, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage,
"Driver", "123", "OVERSEAS", "OWNED", null);
}
private static Vehicle vehicle(long version, long mileage) {
Vehicle value = new Vehicle(); value.setId(1L); value.setRecordVersion(version); value.setMileageKm(mileage);
value.setStatus("IN_SERVICE"); return value;
}
}
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.repository;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class UnloadCompletedOutboxRepositoryTest {
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void claimRechecksRetryWindowAfterCandidateSelection() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"),
LastlegEventOutbox.class);
LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class);
UnloadCompletedOutboxRepository repository = new UnloadCompletedOutboxRepository();
ReflectionTestUtils.setField(repository, "mapper", mapper);
ArgumentCaptor<LambdaUpdateWrapper> wrapper = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
when(mapper.update(isNull(LastlegEventOutbox.class), wrapper.capture())).thenReturn(1);
LocalDateTime now = LocalDateTime.of(2026, 8, 25, 12, 0);
assertThat(repository.claim(1L, "worker", now, now.minusMinutes(1))).isTrue();
assertThat(wrapper.getValue().getSqlSegment())
.contains("next_retry_at", "<=", "status", "locked_at");
}
}
@@ -8,6 +8,8 @@ class LoadingCompletedConsumerTest {
@Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU1",0L,1L); }
@Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); }
@Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); }
@Test void tamperedPayloadHashIsRejectedBeforeInboxMutation(){ EventEnvelope source=event(); EventEnvelope tampered=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(),"sha256:"+"0".repeat(64)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(tampered)); assertTrue(error.getMessage().contains("payloadHash")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); }
@Test void concurrentDuplicateKeyWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox raced=new LastlegEventInbox(); raced.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(null,raced); doThrow(new org.springframework.dao.DuplicateKeyException("race")).when(inbox).insert(any(LastlegEventInbox.class)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(event)); assertTrue(error.getMessage().contains("摘要冲突")); verify(orders,never()).countContainer(anyString(),anyString()); }
@Test void inboundSnapshotUsesCustomerIdAndHasNoPhantomTenantColumn() throws Exception {
assertThrows(NoSuchFieldException.class,()->LastlegInboundOrder.class.getDeclaredField("tenantId"));
String schema=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/db/lastleg.sql"));
@@ -96,6 +96,21 @@ class ContainerShippingNodeConsumerTest {
.hasMessageContaining("摘要冲突");
}
@Test
void tamperedPayloadHashIsRejectedBeforeInboxMutation() {
EventEnvelope source = event("123e4567-e89b-12d3-a456-426614174105", "PICKUP_READY", 80, 5,
Instant.parse("2026-08-21T10:00:00Z"));
EventEnvelope tampered = 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(),
"sha256:" + "0".repeat(64));
assertThatThrownBy(() -> consumer.consume(tampered)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("payloadHash");
verify(inboxes, never()).insert(any(LastlegEventInbox.class));
}
private ContainerShippingGate gate(String status, int sequence, long version, Instant actualAt) {
ContainerShippingGate gate = new ContainerShippingGate(); gate.setContainerBookingId("CB-1");
gate.setCustomerId("100"); gate.setOceanBookingId("OB-1"); gate.setContainerNo("CONT-1");
@@ -0,0 +1,57 @@
{
"schemaVersion": 1,
"description": "Development-stage Lastleg slice dependency baseline. Existing edges may be removed; new edges require architecture review.",
"allowedSliceEdges": [
"dispatch-delivery->inbound-pda",
"dispatch-delivery->inventory",
"dispatch-delivery->shared-technical",
"dispatch-delivery->tailleg-handoff",
"dropship->inbound-pda",
"dropship->shared-technical",
"dropship->tailleg-handoff",
"inbound-pda->dropship",
"inbound-pda->inventory",
"inbound-pda->shared-technical",
"inbound-pda->tailleg-handoff",
"inventory->inbound-pda",
"inventory->shared-technical",
"overseas-master-data->shared-technical",
"procurement-trucking-looseboard->overseas-master-data",
"procurement-trucking-looseboard->shared-technical",
"procurement-trucking-looseboard->tailleg-handoff",
"relabel-transfer-discard->dropship",
"relabel-transfer-discard->inventory",
"relabel-transfer-discard->shared-technical",
"relabel-transfer-discard->tailleg-handoff",
"tailleg-handoff->dropship",
"tailleg-handoff->inbound-pda",
"tailleg-handoff->inventory",
"tailleg-handoff->shared-technical"
],
"requiredClassLocations": {
"com.baiyue.lastleg.masterdata.vehicle.adapter.in.web.PlatformVehicleController": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleController.java",
"com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleMapper": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleMapper.java",
"com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehiclePersistenceAdapter": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java",
"com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleRecord": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java",
"com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java",
"com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java",
"com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java"
},
"applicationDependencyRule": {
"packagePrefix": "com.baiyue.lastleg.masterdata.vehicle.application",
"allowedInternalPrefix": "com.baiyue.lastleg.masterdata.vehicle.application",
"forbiddenImportPrefixes": [
"org.springframework.web",
"org.apache.ibatis",
"com.baomidou.mybatisplus",
"com.baiyue.lastleg.client",
"com.baiyue.lastleg.config"
]
},
"sliceSpecificRules": {
"overseas-master-data": {
"allowedOutgoing": ["shared-technical"],
"allowedIncoming": ["procurement-trucking-looseboard"]
}
}
}