Merge test fixes into dev dropship flow

This commit is contained in:
2026-09-10 18:18:41 +08:00
131 changed files with 3062 additions and 651 deletions
+4
View File
@@ -150,6 +150,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 提交不会自动触发此脚本,仍须遵守提交前检查规则。
以下命令只用于代码质量和契约回归,不访问测试环境,不代表服务全量可用:
| 检查 | 入口 |
+9 -4
View File
@@ -799,15 +799,20 @@ Assert-Contains $backendAgents '海外业务费用来源跨区入池' '后端海
Assert-Contains $firstlegAgents '单行入口拒绝分摊组' 'Firstleg 分摊组原子边界'
Assert-Contains $adminFinanceAgents '`allocationGroupId` 整组审核、驳回、调整或作废' 'Admin 分摊组操作边界'
Assert-Contains $containerFeeOpenApi '/fee-details/allocation-groups:' '柜费分摊组创建 OpenAPI'
Assert-Contains $containerFeeOpenApi '各行按 HALF_UP 保留两位后必须大于零,落库金额合计必须等于两位小数的总额。' '柜费分摊金额守恒'
Assert-Contains $containerFeeOpenApi '分摊组不可经单行入口修改。' '柜费分摊整组操作契约'
Assert-Matches $containerFeeOpenApi '各行按\s+HALF_UP\s+保留两位后必须大于零,\s*落库金额合计必须等于两位小数的总额' '柜费舍入后正数与金额守恒'
Assert-Contains $containerFeeOpenApi '分摊组不可经单行入口修改' '柜费分摊整组操作契约'
Assert-Contains $containerFeeController '@PostMapping("/allocation-groups/{groupId}/approve")' '柜费分摊整组审核入口'
Assert-Contains $containerFeeController '@PostMapping("/allocation-groups/{groupId}/adjust")' '柜费分摊整组调整入口'
Assert-Contains $containerFeeService '分摊客户、订单及入仓信息与柜内真实归属不一致' '柜费分摊真实归属校验'
Assert-Contains $containerFeeService '调整必须包含分摊组全部费用行' '柜费分摊完整调整校验'
Assert-Contains $lastlegAgents '海外入仓、采购、美国卡车、本土散板和海外仓服务费必须匹配当前客户的真实业务' 'Lastleg 五类海外费用归属'
foreach ($businessType in @('OVERSEAS_INBOUND', 'OVERSEAS_PROCUREMENT', 'US_TRUCK', 'LOCAL_LOOSEBOARD', 'WAREHOUSE_SERVICE')) {
Assert-Contains $overseasFinanceOpenApi $businessType "海外费用 OpenAPI: $businessType"
# 在 BusinessType 枚举内逐项核对,新增类型或调整顺序不应使原有覆盖误报。
$businessTypeEnumMatch = [regex]::Match($overseasFinanceOpenApi, '(?s)BusinessType:\s*\{\s*type:\s*string,\s*enum:\s*\[([^\]]*)\]')
Assert-Matches $overseasFinanceOpenApi 'BusinessType:\s*\{\s*type:\s*string,\s*enum:' '海外业务类型枚举定义'
$businessTypeValues = @($businessTypeEnumMatch.Groups[1].Value.Split(',') | ForEach-Object { $_.Trim() })
foreach ($businessType in @('OVERSEAS_INBOUND', 'OVERSEAS_PROCUREMENT', 'PROCUREMENT_FEE', 'US_TRUCK', 'LOCAL_LOOSEBOARD', 'WAREHOUSE_SERVICE', 'OVERSEAS_CONTAINER', 'LASTLEG_ORDER', 'DELIVERY_ORDER', 'RELABEL_ORDER')) {
$checked++
if ($businessType -notin $businessTypeValues) { Add-Failure "海外 BusinessType 枚举缺少: $businessType" }
}
Assert-Contains $overseasFinanceOpenApi '审核通过与作废均事务性写入Outbox' '海外费用可靠发布契约'
Assert-Contains $commonEventAgents '海外业务费用来源变更 V1' '公共海外费用事件入口'
+3
View File
@@ -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
+136
View File
@@ -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>([^<]+)<\/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/)
})