diff --git a/AGENTS.md b/AGENTS.md index c8f8e2f5f..a102da4a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,8 @@ ## 契约、数据库与验证入口 +- 对外 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、契约测试和脚本中的全部调用点。新增可选参数也必须补齐旧测试调用,不能因生产调用已更新就视为完成。 - 所有生产 MyBatis Mapper 接口必须显式标记 `@Mapper`;每个持久化服务只允许在启动类使用一次服务根包 `@MapperScan(..., annotationClass = Mapper.class)`,不得新增子域包白名单或分散的 `*MapperConfiguration`。新增或移动 Mapper 后先运行 `node scripts/mapper-scan-contract.mjs`,全量后端回归也会在 Maven 前执行该门禁。 diff --git a/Jenkinsfile b/Jenkinsfile index 726108088..078ce6bab 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -316,6 +316,16 @@ pipeline { } } + stage('Gateway 路由静态门禁') { + when { + expression { env.NO_SERVICE_CHANGE != 'true' } + } + agent { label 'java-builder' } + steps { + sh 'node --test scripts/gateway-route-contract.test.mjs' + } + } + stage('统一 Maven 测试门禁') { when { expression { env.NO_SERVICE_CHANGE != 'true' && !params.SKIP_TESTS } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java index 704c71017..a55395fd6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java @@ -44,6 +44,9 @@ public class ContainerCustomsService { @Transactional public ContainerView platformView(long planId) { + List rows = actualPlanRows(planId); + PlanRow plan = rows.isEmpty() ? requiredPlan(planId) : plan(rows.get(0)); + if (!"COMPLETED".equals(plan.loadingPlanStatus())) return null; ContainerRow container = ensure(planId); return view(container, null, false); } diff --git a/firstleg/src/main/resources/openapi/customs-v1.yaml b/firstleg/src/main/resources/openapi/customs-v1.yaml index 165bfee34..50b825526 100644 --- a/firstleg/src/main/resources/openapi/customs-v1.yaml +++ b/firstleg/src/main/resources/openapi/customs-v1.yaml @@ -123,7 +123,7 @@ components: schemas: LongId: {type: string, pattern: '^[1-9][0-9]*$', description: Long ID序列化为字符串} ApiEnvelope: {type: object, required: [code, message, timestamp], properties: {code: {type: string}, message: {type: string}, requestId: {type: string, nullable: true}, traceId: {type: string, nullable: true}, timestamp: {type: string, format: date-time}}} - ContainerEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/ContainerView'}}}]} + ContainerEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {allOf: [{$ref: '#/components/schemas/ContainerView'}], nullable: true, description: 装柜计划尚未完成时返回null,表示报关流程尚未生成}}}]} DocumentEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/DocumentView'}}}]} ActionRequest: {type: object, required: [expectedVersion, confirm], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, confirm: {type: boolean}, remark: {type: string, maxLength: 500, nullable: true}}} ScopeRequest: {type: object, required: [inboundIds, expectedVersion], additionalProperties: false, properties: {inboundIds: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/LongId'}}, expectedVersion: {type: integer, minimum: 1}}} diff --git a/scripts/README.md b/scripts/README.md index d00aa01d3..3b9a5394d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -149,6 +149,10 @@ node scripts/create-pending-review-orders.mjs ## 本地质量检查(非全量验收) +Gateway 路由防漏:`node --test scripts/gateway-route-contract.test.mjs`。只读 Maven 模块的 `src/main/java` Controller 与国内/海外源码路由,自动组合类/方法路径并校验目标服务、路由优先顺序和内部路径隔离;不需要凭据、不写数据或报告,失败返回非零,修复后原命令重跑。包含删除船节点路由、错误目标、内部暴露的反向用例。支持当前字面量/类内字符串常量、短格式 Path 配置;复杂映射需同步扩展解析器,不得忽略失败。它不验证 HTTP 方法、权限、外部 Nacos 实际值或运行时可达性。 + +`backend-full-regression.ps1` 在 Maven 前执行该合同;根 `Jenkinsfile` 在服务打包调度前独立执行,不受 `SKIP_TESTS` 跳过 Maven 的参数影响。CI 的 `java-builder` 必须提供支持 `node --test` 的 Node.js(建议 22);缺失时门禁失败,不自动安装或静默跳过。直接单服务 Job、手工 Git 提交不会自动触发此脚本,仍须遵守提交前检查规则。 + 以下命令只用于代码质量和契约回归,不访问测试环境,不代表服务全量可用: | 检查 | 入口 | diff --git a/scripts/backend-full-regression.ps1 b/scripts/backend-full-regression.ps1 index cc13d8553..0fa1ee893 100644 --- a/scripts/backend-full-regression.ps1 +++ b/scripts/backend-full-regression.ps1 @@ -9,6 +9,7 @@ $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path $pom = Join-Path $repoRoot "pom.xml" $mapperScanContract = Join-Path $repoRoot "scripts/mapper-scan-contract.mjs" $requiredTests = @( + "scripts/gateway-route-contract.test.mjs", "scripts/mapper-scan-contract.test.mjs", "firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java", "firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java", @@ -66,6 +67,8 @@ if ($DryRun) { Push-Location $repoRoot try { + & node --test (Join-Path $repoRoot "scripts/gateway-route-contract.test.mjs") + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } & node $mapperScanContract if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } & $MavenCommand @mavenArgs diff --git a/scripts/gateway-route-contract.test.mjs b/scripts/gateway-route-contract.test.mjs new file mode 100644 index 000000000..585001bfb --- /dev/null +++ b/scripts/gateway-route-contract.test.mjs @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict' +import { readFileSync, readdirSync, existsSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const read = path => readFileSync(resolve(root, path), 'utf8') + +// Deliberately limited to the repository's explicit short-form Path routes. +// Unsupported route syntax fails instead of silently skipping coverage. +function routes(source) { + return [...source.matchAll(/^\s*- id:\s*([^\r\n]+)([\s\S]*?)(?=^\s*- id:|^\S|$(?![\s\S]))/gm)].map(([, id, body]) => { + const uri = body.match(/uri:\s*lb:\/\/([\w-]+)/)?.[1] + const paths = body.match(/Path=([^\r\n"\]]+)/)?.[1]?.trim().split(',') + assert.ok(uri && paths?.length, `Unsupported route: ${id}`) + assert.ok(!/\b(?:Method|Host|RewritePath|SetPath|PrefixPath)=/.test(body), `Extend checker for conditional/rewrite route: ${id}`) + assert.ok(!/\border\s*:/.test(body), `Extend checker for explicit route order: ${id}`) + return { id: id.trim(), uri, paths } + }) +} + +function matches(pattern, path) { + const parts = pattern.split('/') + const actual = path.split('/') + for (let i = 0; i < parts.length; i++) { + if (parts[i] === '**') return true + if (actual[i] === undefined) return false + if (parts[i] === '*' || /^\{[^}]+\}$/.test(parts[i])) continue + if (parts[i] !== actual[i]) return false + } + return parts.length === actual.length +} + +function javaFiles(dir) { + if (!existsSync(dir)) return [] + return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const path = resolve(dir, entry.name) + return entry.isDirectory() ? javaFiles(path) : entry.name.endsWith('.java') ? [path] : [] + }) +} + +function controllerPaths(source, file) { + const clean = source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '') + if (!/@(?:RestController|Controller)\b/.test(clean)) return [] + const header = clean.split(/\bclass\s+/)[0] + const mapping = header.match(/@RequestMapping\s*\(([^)]*)\)/) + const extract = args => { + for (const [, name, value] of clean.matchAll(/static\s+final\s+String\s+(\w+)\s*=\s*"([^"\r\n]*)"\s*;/g)) { + args = args.replace(new RegExp(`\\b${name}\\b`, 'g'), `"${value}"`) + } + while (/"[^"\r\n]*"\s*\+\s*"[^"\r\n]*"/.test(args)) { + args = args.replace(/"([^"\r\n]*)"\s*\+\s*"([^"\r\n]*)"/g, '"$1$2"') + } + const paths = [...args.matchAll(/"(\/[^"\r\n]*)"/g)].map(m => m[1]) + assert.ok(paths.length || !args.trim() || /^(?:produces|consumes|method)\s*=/.test(args.trim()), `Non-literal mapping requires checker support: ${file}`) + assert.ok(!args.replace(/"[^"\r\n]*"/g, '').includes('+'), `Concatenated mapping requires checker support: ${file}`) + return paths.length ? paths : [''] + } + const bases = mapping ? extract(mapping[1]) : [''] + const body = clean.slice(header.length) + const methods = [...body.matchAll(/@(?:Get|Post|Put|Delete|Patch|Request)Mapping\b(?:\s*\(([^)]*)\))?/g)] + if (!methods.length) return bases + return methods.flatMap(([, args = '']) => extract(args).flatMap(path => bases.map(base => `${base}${path}`))) +} + +function verify(path, service, table, file) { + if (path.includes('{app}')) { + for (const app of ['platform', 'merchant']) verify(path.replace('{app}', app), service, table, file) + return + } + // Internal service-token endpoints must never be forwarded, regardless of owner. + const hit = table.find(route => route.paths.some(pattern => matches(pattern, path))) + if (path.startsWith('/internal/')) { + assert.equal(hit, undefined, `Internal endpoint exposed: ${file} ${path}`) + } else if (path.startsWith('/api/') || path.startsWith('/openapi/')) { + assert.ok(hit, `Missing Gateway route: ${file} ${path}`) + assert.equal(hit.uri, service, `Wrong destination: ${file} ${path} via ${hit.id}`) + } +} + +const domestic = routes(read('gateway/src/main/resources/application.yml')) +const overseas = routes(read('gateway-overseas/src/main/resources/application-overseas.yml')) +const modules = [...read('pom.xml').matchAll(/([^<]+)<\/module>/g)].map(m => m[1]) + +test('local regression and root CI require route checks before Maven/dispatch', () => { + const local = read('scripts/backend-full-regression.ps1') + assert.ok(local.indexOf('& node --test') < local.indexOf('& $MavenCommand')) + assert.match(local, /gateway-route-contract\.test\.mjs/) + const pipeline = read('Jenkinsfile') + const gate = pipeline.slice(pipeline.indexOf("stage('Gateway 路由静态门禁')"), pipeline.indexOf("stage('统一 Maven 测试门禁')")) + assert.match(gate, /node --test scripts\/gateway-route-contract\.test\.mjs/) + assert.doesNotMatch(gate, /SKIP_TESTS|catchError|returnStatus|\|\| true/) +}) + +test('Controller prefixes route to their owning service; internal prefixes stay private', () => { + let count = 0 + for (const module of modules) { + if (module.startsWith('common-') || module.startsWith('gateway') || module === 'platform-bom') continue + const app = `${module}/src/main/resources/application.yml` + if (!existsSync(resolve(root, app))) continue + const service = read(app).match(/application:\s*\r?\n\s+name:\s*([\w-]+)/)?.[1] + assert.ok(service, `Missing application name: ${module}`) + const tables = ['lastleg-service', 'warehouse-overseas-service'].includes(module) + ? [overseas] : ['file', 'print'].includes(module) ? [domestic, overseas] : [domestic] + for (const file of javaFiles(resolve(root, module, 'src/main/java'))) { + for (const path of controllerPaths(readFileSync(file, 'utf8'), file)) { + for (const table of tables) verify(path, service, table, file) + count++ + } + } + } + assert.ok(count > 100, `Unexpectedly small Controller inventory: ${count}`) +}) + +test('removing container shipping nodes route reproduces the missing registration', () => { + const broken = domestic.map(route => ({ ...route, paths: route.paths.filter(p => p !== '/api/platform/v1/container-shipping-nodes/**') })) + assert.throws(() => verify('/api/platform/v1/container-shipping-nodes', 'firstleg-service', broken, 'fixture'), /Missing Gateway route/) +}) + +test('wrong target and internal exposure are rejected', () => { + assert.throws(() => verify('/api/new', 'firstleg-service', [{ id: 'wrong', uri: 'order-service', paths: ['/api/**'] }], 'fixture'), /Wrong destination/) + assert.throws(() => verify('/internal/private', 'firstleg-service', [{ id: 'broad', uri: 'firstleg-service', paths: ['/**'] }], 'fixture'), /Internal endpoint exposed/) +}) + +test('path variables, exact paths and segment boundaries are respected', () => { + assert.ok(matches('/api/items/{id}/**', '/api/items/123/action')) + assert.ok(matches('/api/items/**', '/api/items')) + assert.ok(!matches('/api/items/**', '/api/items-other')) + assert.ok(!matches('/api/items', '/api/items/action')) + assert.deepEqual(controllerPaths('@RestController @RequestMapping(value = {"/api/a", "/api/b"}) public class Example {}', 'fixture'), ['/api/a', '/api/b']) + assert.throws(() => controllerPaths('@RestController @RequestMapping(API_PATH) public class Example {}', 'fixture'), /Non-literal/) + assert.deepEqual(controllerPaths('@RestController @RequestMapping("/api") public class Example { @GetMapping("/items") void list() {} }', 'fixture'), ['/api/items']) + assert.deepEqual(controllerPaths('@RestController public class Example { static final String PATH = "/api/items"; @GetMapping(PATH) void list() {} }', 'fixture'), ['/api/items']) + assert.throws(() => verify('/api/item', 'firstleg-service', [{ id: 'shadow', uri: 'order-service', paths: ['/api/**'] }, { id: 'correct', uri: 'firstleg-service', paths: ['/api/item'] }], 'fixture'), /Wrong destination/) +})