Merge pull request 'Test' (#6) from test into main

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
2026-09-19 22:59:43 +08:00
38 changed files with 1627 additions and 84 deletions
Vendored
+46 -15
View File
@@ -58,17 +58,22 @@ def commonPathServices = [
def databaseMigrationPaths = ['deploy/sql/']
def downstreamResults = [:]
def downstreamBuildUrls = [:]
def downstreamBuildNumbers = [:]
def sendProductionWeComNotification(String status, String color, String detail) {
def sendProductionWeComNotification(String title, String status, String color, String detail) {
node('java-builder') {
withCredentials([string(credentialsId: 'wecom-prod-webhook', variable: 'WECOM_WEBHOOK')]) {
String sourceVersion = env.IMAGE_TAG ?: env.SOURCE_COMMIT ?: env.GIT_COMMIT ?: '—'
String content = """## 生产部署通知
> 项目:${env.JOB_NAME}
String sourceVersion = env.IMAGE_TAG ?: env.SOURCE_COMMIT ?: env.GIT_COMMIT ?: '未解析'
String duration = currentBuild.durationString?.replace(' and counting', '') ?: '—'
String content = """## ${title}
> 应用:后端服务
> Job:${env.JOB_NAME}
> 构建:[#${env.BUILD_NUMBER}](${env.BUILD_URL})
> 提交:${sourceVersion}
> 状态:<font color="${color}">${status}</font>
> 详情:${detail}
${detail}
> 耗时:${duration}
> 时间:${new Date().format('yyyy-MM-dd HH:mm:ss', TimeZone.getTimeZone('Asia/Shanghai'))}
"""
String payloadFile = 'wecom-production-notification.json'
@@ -228,6 +233,10 @@ pipeline {
env.RESOLVED_ENVIRONMENT = resolvedEnvironment
echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}"
// 先记录当前提交,使后续生产校验失败时通知仍能定位版本。
env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
if (env.RESOLVED_ENVIRONMENT == 'production') {
sh '''
set -eu
@@ -255,8 +264,6 @@ pipeline {
error('生产环境禁止跳过测试')
}
env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
List selected = []
if (params.BUILD_MODE == 'ALL') {
selected = serviceModules
@@ -427,6 +434,8 @@ pipeline {
]
)
downstreamResults[service] = downstreamBuild.result ?: 'UNKNOWN'
downstreamBuildUrls[service] = downstreamBuild.absoluteUrl ?: ''
downstreamBuildNumbers[service] = downstreamBuild.number?.toString() ?: ''
echo "${service} 执行结果: ${downstreamResults[service]} (${downstreamBuild.absoluteUrl})"
}
}
@@ -459,10 +468,18 @@ pipeline {
script {
if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY && env.NO_SERVICE_CHANGE != 'true') {
try {
List deployedServices = env.SELECTED_SERVICES?.split(',')
?.collect { it.trim() }
?.findAll { it } ?: []
String serviceSummary = deployedServices.size() <= 3
? deployedServices.join('、')
: "${deployedServices.size()} 个服务"
sendProductionWeComNotification(
'部署成功',
'✅ 生产部署成功',
'成功',
'info',
"服务:${env.SELECTED_SERVICES ?: '—'}"
"> 结果:${deployedServices.size()} 个服务全部成功\n" +
"> 部署服务:${serviceSummary ?: '—'}"
)
} catch (Exception error) {
echo "企业微信通知失败,不影响原构建结果:${error.message}"
@@ -474,10 +491,22 @@ pipeline {
script {
if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY) {
try {
List failedServices = downstreamResults.findAll { service, result -> result != 'SUCCESS' }
.collect { service, result -> service }
int successCount = downstreamResults.findAll { service, result -> result == 'SUCCESS' }.size()
String failedLinks = failedServices.collect { service ->
String url = downstreamBuildUrls[service]
String number = downstreamBuildNumbers[service]
String label = number ? "${service} #${number}" : service
url ? "[${label}](${url})" : label
}.join('、')
sendProductionWeComNotification(
'部署部分失败',
'⚠️ 生产部署部分失败',
'部分失败',
'warning',
currentBuild.description ?: '部分服务部署未成功,请查看 Jenkins 日志'
"> 结果:成功 ${successCount} / 失败 ${failedServices.size()}\n" +
"> 失败服务:${failedLinks ?: '未解析'}\n" +
'> 说明:点击失败服务可直接查看下游构建日志'
)
} catch (Exception error) {
echo "企业微信通知失败,不影响原构建结果:${error.message}"
@@ -490,9 +519,10 @@ pipeline {
if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY) {
try {
sendProductionWeComNotification(
'部署失败',
'❌ 生产发布失败',
'失败',
'warning',
'生产发布流程失败,请打开 Jenkins 构建日志查看失败阶段'
'> 说明:生产发布流程失败,请打开父构建日志查看失败阶段'
)
} catch (Exception error) {
echo "企业微信通知失败,不影响原构建结果:${error.message}"
@@ -505,9 +535,10 @@ pipeline {
if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY) {
try {
sendProductionWeComNotification(
'部署已取消',
'⏹ 生产部署已取消',
'已取消',
'comment',
'生产部署流程被人工取消或中止'
'> 说明:生产部署流程被人工取消或中止'
)
} catch (Exception error) {
echo "企业微信通知失败,不影响原构建结果:${error.message}"
+1 -2
View File
@@ -5,6 +5,5 @@ spring:
password: ${FINANCE_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.finance.recharge.wechat-qr-code-url: ${FINANCE_RECHARGE_WECHAT_QR_CODE_URL:}
baiyue.finance.recharge.alipay-qr-code-url: ${FINANCE_RECHARGE_ALIPAY_QR_CODE_URL:}
baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:}
baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00}
+1 -2
View File
@@ -5,6 +5,5 @@ spring:
username: ${FINANCE_DB_USERNAME:root}
password: ${FINANCE_DB_PASSWORD:123456}
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.finance.recharge.wechat-qr-code-url: ${FINANCE_RECHARGE_WECHAT_QR_CODE_URL:/customer-recharge-qr.svg}
baiyue.finance.recharge.alipay-qr-code-url: ${FINANCE_RECHARGE_ALIPAY_QR_CODE_URL:/customer-recharge-qr.svg}
baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:/customer-recharge-qr.svg}
baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00}
+24
View File
@@ -60,6 +60,30 @@
- 模块 `src/main/resources/db/` 用作结构来源和开发追溯,不是区域部署前置;不再向历史迁移文件追加发布步骤。修改当前字段时同步模块有效建表定义及其合同。
- 不在 SQL 中写入真实客户数据、环境地址、固定密码或 Token。
## 后续迁移工具(已开始实施)
机制启用前,以上两份统一 SQL 仍是空库初始化的唯一入口;本节工具不会修改已有数据库,也不会把当前可丢弃的开发/测试存量转换成迁移链。
迁移范围映射位于 `migration-scopes.json`,按区域、schema、所属服务和顺序登记。迁移文件位于 `migrations/{cn|overseas}/<schema>/`,一个文件只允许修改一个 schema。目录中的 `.gitkeep` 仅用于保留空目录,不是迁移文件。
完整操作请先读 [数据库迁移操作手册](数据库迁移操作手册.md)。离线入口为 `scripts/db-migrations.mjs`:
```powershell
# 自动取得该 schema 的下一个整数序号并创建草稿;不连接数据库
node deploy/sql/scripts/db-migrations.mjs new --region CN --schema baiyue_auth --description add_login_audit
# 校验范围、文件名、跨 schema 引用及受保护文件;不连接数据库
node deploy/sql/scripts/db-migrations.mjs check
# 由选中服务自动生成逐库目标和 SQL 哈希;不手填发布 JSON
node deploy/sql/scripts/db-migrations.mjs plan --services auth,iam --regions CN --out release-manifest.json
# 当前只提供安全的只读计划;实际 Flyway 执行需后续阶段显式配置并授权
node deploy/sql/scripts/db-migrations.mjs run --manifest release-manifest.json --dry-run
```
`new` 使用独占创建避免并发覆盖;成功历史或已冻结制品不能由普通命令改写。`plan` 只打包选中服务及评审确认的 schema,不会因为同一提交存在其他区域或服务 SQL 就自动扩大范围。`run` 的真实执行还未接入测试数据库和 Jenkins,当前未授权任何部署或 SQL 操作。
## 本地验证
离线合同不连接数据库:
+44
View File
@@ -0,0 +1,44 @@
{
"schemaVersion": 1,
"services": [
"gateway",
"gateway-overseas",
"auth",
"iam",
"order",
"warehouse",
"firstleg",
"file",
"audit-service",
"event-bridge-service",
"finance",
"integration-service",
"print",
"lastleg-service",
"warehouse-overseas-service"
],
"regions": {
"CN": {
"baseline": "baseline/cn/cn-baseline.sql",
"schemas": [
{ "name": "baiyue_auth", "services": ["auth"], "order": 10 },
{ "name": "baiyue_iam", "services": ["iam"], "order": 20 },
{ "name": "baiyue_order", "services": ["order"], "order": 30 },
{ "name": "baiyue_warehouse", "services": ["warehouse"], "order": 40 },
{ "name": "baiyue_firstleg", "services": ["firstleg"], "order": 50 },
{ "name": "baiyue_file", "services": ["file"], "order": 60 },
{ "name": "baiyue_audit", "services": ["audit-service"], "order": 70 },
{ "name": "baiyue_event_bridge", "services": ["event-bridge-service"], "order": 80 },
{ "name": "baiyue_finance", "services": ["finance"], "order": 90 }
]
},
"OVERSEAS": {
"baseline": "baseline/overseas/overseas-baseline.sql",
"schemas": [
{ "name": "baiyue_lastleg", "services": ["lastleg-service"], "order": 10 },
{ "name": "baiyue_lastlag_warehouse", "services": ["warehouse-overseas-service"], "order": 20 },
{ "name": "baiyue_event_bridge_overseas", "services": ["event-bridge-service"], "order": 30 }
]
}
}
}
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+322
View File
@@ -0,0 +1,322 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto'
import { mkdir, open, readFile, readdir, stat, writeFile } from 'node:fs/promises'
import { spawnSync } from 'node:child_process'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
const SQL_ROOT = path.resolve(SCRIPT_DIR, '..')
const SCOPES_PATH = path.join(SQL_ROOT, 'migration-scopes.json')
const MIGRATION_NAME = /^V(\d+)__([a-z0-9][a-z0-9_-]*)\.sql$/
function fail(message) {
throw new Error(message)
}
function parseArgs(argv) {
const [command = 'help', ...rest] = argv
const args = { command, _: [] }
for (let index = 0; index < rest.length; index += 1) {
const item = rest[index]
if (!item.startsWith('--')) {
args._.push(item)
continue
}
const equals = item.indexOf('=')
if (equals >= 0) {
args[item.slice(2, equals)] = item.slice(equals + 1)
continue
}
const key = item.slice(2)
const next = rest[index + 1]
if (next && !next.startsWith('--')) {
args[key] = next
index += 1
} else {
args[key] = true
}
}
return args
}
function absoluteFrom(root, value) {
return path.isAbsolute(value) ? value : path.resolve(root, value)
}
async function loadScopes(scopesPath = SCOPES_PATH) {
const scopes = JSON.parse(await readFile(scopesPath, 'utf8'))
if (scopes.schemaVersion !== 1 || !scopes.regions || typeof scopes.regions !== 'object') {
fail('migration-scopes.json 必须使用 schemaVersion=1 并提供 regions')
}
const seenSchemas = new Set()
for (const [region, config] of Object.entries(scopes.regions)) {
if (!/^[A-Z][A-Z0-9_]*$/.test(region)) fail(`区域名非法:${region}`)
if (!Array.isArray(config.schemas) || config.schemas.length === 0) fail(`${region} 没有 schema 清单`)
const orders = new Set()
for (const schema of config.schemas) {
if (!/^[a-z][a-z0-9_]*$/.test(schema.name)) fail(`schema 名称非法:${schema.name}`)
if (seenSchemas.has(schema.name)) fail(`schema 重复登记:${schema.name}`)
if (!Number.isInteger(schema.order) || schema.order < 1 || orders.has(schema.order)) fail(`${region} 的 schema order 非法或重复:${schema.name}`)
if (!Array.isArray(schema.services) || schema.services.length === 0) fail(`${schema.name} 没有所属服务`)
seenSchemas.add(schema.name)
orders.add(schema.order)
}
}
return scopes
}
function regionConfig(scopes, region) {
const value = scopes.regions[region]
if (!value) fail(`未知区域:${region};允许值:${Object.keys(scopes.regions).join(', ')}`)
return value
}
function schemaConfig(scopes, region, schemaName) {
const config = regionConfig(scopes, region)
const schema = config.schemas.find((item) => item.name === schemaName)
if (!schema) fail(`${region} 未登记 schema:${schemaName}`)
return schema
}
async function listMigrations(sqlRoot, region, schemaName) {
const directory = path.join(sqlRoot, 'migrations', region.toLowerCase(), schemaName)
let entries = []
try {
entries = await readdir(directory, { withFileTypes: true })
} catch (error) {
if (error.code === 'ENOENT') return []
throw error
}
const migrations = []
for (const entry of entries) {
if (!entry.isFile() || entry.name.startsWith('.')) continue
const match = entry.name.match(MIGRATION_NAME)
if (!match) fail(`${path.relative(sqlRoot, path.join(directory, entry.name))} 文件名不符合 V<integer>__description.sql`)
const version = Number(match[1])
if (!Number.isSafeInteger(version) || version < 1) fail(`迁移版本非法:${entry.name}`)
migrations.push({
version,
description: match[2],
fileName: entry.name,
relativePath: path.relative(sqlRoot, path.join(directory, entry.name)).replaceAll(path.sep, '/'),
absolutePath: path.join(directory, entry.name),
})
}
migrations.sort((left, right) => left.version - right.version || left.fileName.localeCompare(right.fileName))
for (let index = 1; index < migrations.length; index += 1) {
if (migrations[index - 1].version === migrations[index].version) fail(`${region}/${schemaName} 版本重复:${migrations[index].version}`)
}
return migrations
}
async function sha256(filePath) {
return createHash('sha256').update(await readFile(filePath)).digest('hex')
}
function selectedServices(value) {
if (!value || value === 'ALL') return null
const services = value.split(',').map((item) => item.trim()).filter(Boolean)
if (services.length === 0) fail('--services 不能为空')
return [...new Set(services)]
}
function selectSchemas(scopes, region, services) {
const config = regionConfig(scopes, region)
const selected = services === null
? config.schemas
: config.schemas.filter((schema) => schema.services.some((service) => services.includes(service)))
return [...selected].sort((left, right) => left.order - right.order)
}
async function buildPlan({ sqlRoot = SQL_ROOT, scopes, regions, services, commit = process.env.GIT_COMMIT ?? null }) {
const knownServices = new Set(scopes.services ?? Object.values(scopes.regions).flatMap((config) => config.schemas.flatMap((schema) => schema.services)))
const unknownServices = (services ?? []).filter((service) => !knownServices.has(service))
if (unknownServices.length) fail(`范围映射中未登记服务:${unknownServices.join(', ')}`)
const plan = {
schemaVersion: 1,
sourceCommit: commit,
services: services ?? ['ALL'],
regions: [],
}
for (const region of regions) {
const config = regionConfig(scopes, region)
const schemas = []
for (const schema of selectSchemas(scopes, region, services)) {
const migrations = await listMigrations(sqlRoot, region, schema.name)
schemas.push({
schema: schema.name,
services: schema.services,
order: schema.order,
migrationDirectory: `migrations/${region.toLowerCase()}/${schema.name}`,
baseline: config.baseline,
target: migrations.length ? String(migrations.at(-1).version) : '0.0.0',
migrations: await Promise.all(migrations.map(async (migration) => ({
version: String(migration.version),
description: migration.description,
path: migration.relativePath,
sha256: await sha256(migration.absolutePath),
}))),
})
}
plan.regions.push({ region, schemas })
}
return plan
}
async function createMigration({ sqlRoot = SQL_ROOT, scopes, region, schemaName, description }) {
const schema = schemaConfig(scopes, region, schemaName)
const directory = path.join(sqlRoot, 'migrations', region.toLowerCase(), schema.name)
await mkdir(directory, { recursive: true })
const normalizedDescription = description.trim()
if (!normalizedDescription) fail('必须填写变更说明')
const safeDescription = normalizedDescription.toLowerCase().replaceAll(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'change'
for (let attempt = 0; attempt < 20; attempt += 1) {
const migrations = await listMigrations(sqlRoot, region, schema.name)
const version = (migrations.at(-1)?.version ?? 0) + 1
const fileName = `V${version}__${safeDescription}.sql`
const filePath = path.join(directory, fileName)
try {
const handle = await open(filePath, 'wx')
await handle.writeFile([
`-- Region: ${region}`,
`-- Schema: ${schema.name}`,
`-- Purpose: ${description.trim()}`,
'-- Preconditions:',
'-- Postconditions:',
'',
].join('\n'), 'utf8')
await handle.close()
return { region, schema: schema.name, version, fileName, path: path.relative(sqlRoot, filePath).replaceAll(path.sep, '/') }
} catch (error) {
if (error.code !== 'EEXIST') throw error
}
}
fail(`${region}/${schema.name} 并发生成迁移失败:重试次数已耗尽`)
}
async function checkProtected(sqlRoot, protectedPath) {
if (!protectedPath) return []
const document = JSON.parse(await readFile(protectedPath, 'utf8'))
const entries = document.files ?? document.migrations ?? []
if (!Array.isArray(entries)) fail('保护清单的 files/migrations 必须是数组')
const errors = []
for (const entry of entries) {
if (!['success', 'frozen'].includes(entry.status)) continue
if (!entry.path || !/^[a-f0-9]{64}$/.test(entry.sha256 ?? '')) fail('保护清单条目必须包含 path 和 64 位 sha256')
const current = await sha256(absoluteFrom(sqlRoot, entry.path))
if (current !== entry.sha256) errors.push(`受保护迁移已修改:${entry.path}`)
}
return errors
}
async function checkSqlFiles(sqlRoot, scopes, regions) {
const errors = []
const schemasByName = new Map()
for (const region of regions) {
for (const schema of regionConfig(scopes, region).schemas) schemasByName.set(schema.name, { region, schema })
}
for (const [schemaName, { region }] of schemasByName) {
const migrations = await listMigrations(sqlRoot, region, schemaName)
for (const migration of migrations) {
const sql = await readFile(migration.absolutePath, 'utf8')
for (const candidate of schemasByName.keys()) {
if (candidate !== schemaName && new RegExp(`(?:\\b|\`)${candidate}(?:\\b|\`)`, 'i').test(sql)) {
errors.push(`${migration.relativePath} 引用了其他 schema:${candidate}`)
}
}
if (/\b(?:DROP\s+DATABASE|CREATE\s+DATABASE|USE\s+)/i.test(sql)) errors.push(`${migration.relativePath} 不得包含跨库控制语句`)
}
}
return errors
}
async function check({ sqlRoot = SQL_ROOT, scopes, regions = Object.keys(scopes.regions), protectedPath }) {
const errors = await checkSqlFiles(sqlRoot, scopes, regions)
errors.push(...await checkProtected(sqlRoot, protectedPath))
if (errors.length) fail(errors.join('\n'))
return { ok: true, regions, checkedAt: new Date().toISOString() }
}
async function runManifest({ sqlRoot = SQL_ROOT, manifestPath, dryRun = false }) {
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.regions)) fail('发布清单格式不受支持')
const commands = []
for (const region of manifest.regions) {
for (const schema of region.schemas ?? []) {
if (schema.target === '0.0.0' && (schema.migrations ?? []).length === 0) continue
const location = path.join(sqlRoot, schema.migrationDirectory)
await stat(location).catch(() => fail(`迁移目录不存在:${schema.migrationDirectory}`))
commands.push({ region: region.region, schema: schema.schema, target: schema.target, location })
}
}
if (dryRun) return { mode: 'dry-run', commands }
if (process.env.DB_MIGRATION_ALLOW !== '1') fail('实际数据库执行被保护:先设置 DB_MIGRATION_ALLOW=1,并显式提供测试环境凭据')
if (!process.env.FLYWAY_BIN) fail('实际数据库执行需要 FLYWAY_BIN,阶段 B 只支持 --dry-run')
const results = []
for (const command of commands) {
const result = spawnSync(process.env.FLYWAY_BIN, [
'-locations=filesystem:' + command.location,
'-schemas=' + command.schema,
'-defaultSchema=' + command.schema,
'-table=flyway_schema_history',
'-baselineOnMigrate=false',
'-cleanDisabled=true',
'-outOfOrder=false',
'-validateOnMigrate=true',
'-target=' + command.target,
'migrate',
], { stdio: 'inherit', env: process.env })
if (result.status !== 0) fail(`${command.region}/${command.schema} Flyway 执行失败,退出码 ${result.status}`)
results.push(command)
}
return { mode: 'execute', commands: results }
}
function usage() {
console.log(`用法:\n node deploy/sql/scripts/db-migrations.mjs new --region CN --schema baiyue_auth --description add_login_audit\n node deploy/sql/scripts/db-migrations.mjs check [--protected <manifest>]\n node deploy/sql/scripts/db-migrations.mjs plan --services auth,iam --regions CN,OVERSEAS --out <file>\n node deploy/sql/scripts/db-migrations.mjs run --manifest <file> --dry-run`)
}
async function main(argv = process.argv.slice(2)) {
const args = parseArgs(argv)
const scopesPath = args.scopes ? absoluteFrom(process.cwd(), args.scopes) : SCOPES_PATH
const scopes = await loadScopes(scopesPath)
const sqlRoot = path.dirname(scopesPath)
if (args.command === 'help') return usage()
if (args.command === 'new') {
const result = await createMigration({ sqlRoot, scopes, region: String(args.region ?? ''), schemaName: String(args.schema ?? ''), description: String(args.description ?? '') })
console.log(JSON.stringify(result, null, 2))
return
}
if (args.command === 'check') {
const result = await check({ sqlRoot, scopes, protectedPath: args.protected ? absoluteFrom(process.cwd(), args.protected) : undefined })
console.log(JSON.stringify(result, null, 2))
return
}
if (args.command === 'plan') {
const regions = String(args.regions ?? Object.keys(scopes.regions).join(',')).split(',').map((item) => item.trim()).filter(Boolean)
const plan = await buildPlan({ sqlRoot, scopes, regions, services: selectedServices(args.services) })
const output = JSON.stringify(plan, null, 2) + '\n'
if (args.out) await writeFile(absoluteFrom(process.cwd(), args.out), output, 'utf8')
else process.stdout.write(output)
return
}
if (args.command === 'run') {
const result = await runManifest({ sqlRoot, manifestPath: absoluteFrom(process.cwd(), String(args.manifest ?? '')), dryRun: Boolean(args['dry-run']) })
console.log(JSON.stringify(result, null, 2))
return
}
usage()
fail(`未知命令:${args.command}`)
}
export { buildPlan, check, createMigration, listMigrations as discoverMigrations, loadScopes, parseArgs, runManifest }
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(`ERROR: ${error.message}`)
process.exitCode = 1
})
}
+77
View File
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import {
buildPlan,
check,
createMigration,
discoverMigrations,
loadScopes,
runManifest,
} from './db-migrations.mjs'
async function fixture() {
const root = await mkdtemp(path.join(os.tmpdir(), 'db-migrations-'))
const sqlRoot = path.join(root, 'sql')
await mkdir(path.join(sqlRoot, 'migrations', 'cn', 'baiyue_auth'), { recursive: true })
await writeFile(path.join(sqlRoot, 'scopes.json'), JSON.stringify({
schemaVersion: 1,
regions: {
CN: {
baseline: 'baseline/cn.sql',
schemas: [
{ name: 'baiyue_auth', services: ['auth'], order: 10 },
{ name: 'baiyue_order', services: ['order'], order: 20 },
],
},
},
}), 'utf8')
return { root, sqlRoot, scopesPath: path.join(sqlRoot, 'scopes.json') }
}
test('new 自动分配 schema 内下一个版本,并且可重复执行检查', async (t) => {
const context = await fixture()
t.after(() => rm(context.root, { recursive: true, force: true }))
const scopes = await loadScopes(context.scopesPath)
const first = await createMigration({ sqlRoot: context.sqlRoot, scopes, region: 'CN', schemaName: 'baiyue_auth', description: 'Add login audit' })
assert.equal(first.fileName, 'V1__add_login_audit.sql')
const second = await createMigration({ sqlRoot: context.sqlRoot, scopes, region: 'CN', schemaName: 'baiyue_auth', description: 'add_session_index' })
assert.equal(second.fileName, 'V2__add_session_index.sql')
assert.match(await readFile(path.join(context.sqlRoot, first.path), 'utf8'), /Schema: baiyue_auth/)
assert.equal((await discoverMigrations(context.sqlRoot, 'CN', 'baiyue_auth')).length, 2)
})
test('plan 只包含选中服务,不把同提交其他 schema 的 SQL 带入制品', async (t) => {
const context = await fixture()
t.after(() => rm(context.root, { recursive: true, force: true }))
const scopes = await loadScopes(context.scopesPath)
const created = await createMigration({ sqlRoot: context.sqlRoot, scopes, region: 'CN', schemaName: 'baiyue_auth', description: 'add_login_audit' })
const plan = await buildPlan({ sqlRoot: context.sqlRoot, scopes, regions: ['CN'], services: ['auth'], commit: 'abc1234' })
assert.equal(plan.sourceCommit, 'abc1234')
assert.deepEqual(plan.regions[0].schemas.map((schema) => schema.schema), ['baiyue_auth'])
assert.equal(plan.regions[0].schemas[0].target, '1')
assert.equal(plan.regions[0].schemas[0].migrations[0].path, created.path)
})
test('check 拒绝跨 schema 引用和跨库控制语句', async (t) => {
const context = await fixture()
t.after(() => rm(context.root, { recursive: true, force: true }))
const scopes = await loadScopes(context.scopesPath)
await writeFile(path.join(context.sqlRoot, 'migrations', 'cn', 'baiyue_auth', 'V1__bad.sql'), 'ALTER TABLE baiyue_order.t ADD c INT;\nUSE baiyue_order;\n', 'utf8')
await assert.rejects(() => check({ sqlRoot: context.sqlRoot, scopes, regions: ['CN'] }), /其他 schema|跨库控制语句/)
})
test('run 默认只能 dry-run,不能无意连接数据库', async (t) => {
const context = await fixture()
t.after(() => rm(context.root, { recursive: true, force: true }))
const scopes = await loadScopes(context.scopesPath)
await createMigration({ sqlRoot: context.sqlRoot, scopes, region: 'CN', schemaName: 'baiyue_auth', description: 'add_login_audit' })
const plan = await buildPlan({ sqlRoot: context.sqlRoot, scopes, regions: ['CN'], services: ['auth'] })
const manifestPath = path.join(context.root, 'release-manifest.json')
await writeFile(manifestPath, JSON.stringify(plan), 'utf8')
const dryRun = await runManifest({ sqlRoot: context.sqlRoot, manifestPath, dryRun: true })
assert.equal(dryRun.mode, 'dry-run')
await assert.rejects(() => runManifest({ sqlRoot: context.sqlRoot, manifestPath }), /DB_MIGRATION_ALLOW/)
})
@@ -4908,7 +4908,6 @@ CREATE TABLE fin_recharge_request (
customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键',
request_no varchar(32) NOT NULL COMMENT '充值申请业务编号RC-yyMMdd-####',
amount decimal(20,2) NOT NULL COMMENT '申请充值金额',
payment_method varchar(16) NOT NULL COMMENT 'WECHAT或ALIPAY',
voucher_file_id bigint unsigned NOT NULL COMMENT '付款截图File ID',
voucher_file_name varchar(255) NOT NULL COMMENT '付款截图文件名快照',
status varchar(16) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回',
@@ -4926,7 +4925,6 @@ CREATE TABLE fin_recharge_request (
KEY idx_fin_recharge_customer_status (customer_id,status,created_at,id),
CONSTRAINT chk_fin_recharge_amount CHECK ((amount > 0)),
CONSTRAINT chk_fin_recharge_customer CHECK ((customer_id > 0)),
CONSTRAINT chk_fin_recharge_method CHECK ((payment_method in (_utf8mb4'WECHAT',_utf8mb4'ALIPAY'))),
CONSTRAINT chk_fin_recharge_status CHECK ((status in (_utf8mb4'PENDING',_utf8mb4'APPROVED',_utf8mb4'REJECTED')))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户付款截图充值申请;平台审核通过后入账';
+141
View File
@@ -0,0 +1,141 @@
# 数据库迁移操作手册
本文是开发、测试和发布人员的日常操作入口。设计原则和生产边界见仓库文档 [数据库版本迁移与 Jenkins 自动发布方案](../../../docs/数据库版本迁移与Jenkins自动发布方案.md);当前阶段的初始化事实见本目录 [README.md](README.md)。
## 先分清两类 SQL
| 类型 | 位置 | 用途 | 能否在已有业务库重复执行 |
|---|---|---|---|
| 空库初始化 | `deploy/sql/头程数据库统一增量-MySQL8.sql`、`海外区域统一增量-MySQL8.sql` | 初始化一个不存在或获授权清空的区域数据库 | 不能 |
| 版本迁移 | `deploy/sql/migrations/{cn|overseas}/<schema>/V<数字>__<说明>.sql` | 机制启用后对已有数据库逐步升级 | 由 Flyway 按历史自动跳过已成功版本 |
当前测试分支只完成了版本迁移的离线工具和目录骨架。没有冻结基线、没有具体业务变更时,不要复制初始化 SQL 生成 `V1`,也不要连接测试或生产数据库试跑。
## 日常开发:新建和修改迁移
以下命令均从 `baiyue-firstleg` 根目录执行,PowerShell 和 Bash 都可以使用 Node 命令。
### 1. 确认所属区域和 schema
打开 [migration-scopes.json](migration-scopes.json),只选择已登记的区域和 schema。服务没有数据库时不创建空迁移;例如 `gateway` 不对应 schema。
```powershell
node deploy/sql/scripts/db-migrations.mjs check
```
### 2. 自动创建迁移文件
```powershell
node deploy/sql/scripts/db-migrations.mjs new `
--region CN `
--schema baiyue_auth `
--description add_login_audit
```
工具会读取该 schema 当前最大编号并创建下一个文件,例如:
```text
deploy/sql/migrations/cn/baiyue_auth/V1__add_login_audit.sql
```
开发者只输入区域、schema 和说明,不输入版本号。说明中的英文字母、数字和下划线会用于文件名;只有中文说明时文件名使用 `change`,完整中文说明仍保留在 SQL 头部。并发创建使用独占文件创建,不覆盖已有文件。
### 3. 填写 SQL 和断言
在生成的文件中完成实际 SQL,并补充文件头的前置、后置检查。一个文件只修改一个 schema,不写 `USE`、`CREATE DATABASE`、跨 schema 限定名或真实业务数据。
同一文件在开发阶段可以反复编辑,但一旦在保留历史的环境成功执行或进入冻结候选,就不能再改。没有成功记录但测试候选已失败时,只有方案第 13.2.1 节的受控修订流程可以保留原编号修正。
### 4. 提交前离线检查
```powershell
node deploy/sql/scripts/db-migrations.mjs check
node --test deploy/sql/scripts/db-migrations.test.mjs scripts/script-documentation-contract.test.mjs
```
`check` 会验证范围映射、文件名、重复版本、跨 schema 引用、跨库控制语句,以及可选的受保护文件清单。它不连接数据库,也不证明 SQL 语义正确。
## 生成测试候选
### 1. 只选择本次要发布的服务
```powershell
node deploy/sql/scripts/db-migrations.mjs plan `
--services auth,iam `
--regions CN `
--out .codex-tmp-release-manifest.json
```
`plan` 自动生成逐库目标、完整迁移文件列表和 SHA-256。它只包含选中服务及评审确认的 schema;同一提交中其他服务的 SQL 不会被自动带入。没有迁移文件的允许 schema 目标为 `0.0.0`,范围外 schema 不生成目标。
生成的 manifest 是制品,不是开发者长期维护的源码文件。不要手改、提交或在测试与生产之间重新生成;测试通过后应复用同一个 manifest、SQL 包和镜像 digest。
### 2. 查看只读执行计划
```powershell
node deploy/sql/scripts/db-migrations.mjs run `
--manifest .codex-tmp-release-manifest.json `
--dry-run
```
当前阶段 `run` 只支持 `--dry-run`,用于确认要执行哪些库和版本。真实 Flyway 执行、测试数据库凭据、环境锁和 Jenkins 阶段属于后续阶段 C,尚未由本命令启用。不要自行设置开关连接真实数据库。
## 测试环境的目标流程
完成基线登记和阶段 C 接入后,测试部署按以下顺序自动完成:
1. 构建并冻结代码、SQL manifest 和全部目标镜像。
2. 获取测试环境锁,核验实例身份、基线和当前 Flyway 历史。
3. 对允许迁移的 schema 做全量预检;任何库失败都不开始其他库的写入。
4. Flyway 按完整版本链执行待迁移版本,成功版本自动跳过。
5. 严格复验历史、结构和必要种子。
6. 只有数据库阶段全部通过,才部署同一批应用镜像。
7. 同一候选重复执行时只执行数据库尚未成功的版本,不重复执行成功 SQL。
测试环境验证至少包括空库到目标、上一发布版本到目标、带合成数据升级、重复部署、失败阻断应用部署和跨区域部分成功。测试报告必须绑定 manifest、SQL 哈希、镜像 digest 和测试起点。
## 生产晋级
生产不重新生成 SQL,也不重新编号。只选择已经通过测试的原候选制品,执行器只处理生产当前缺少的成功迁移;已成功的版本自动跳过。生产数据库高于候选目标、存在未纳入清单的成功迁移、失败记录、校验和变化或起点与测试演练不一致时,发布直接阻断。
生产迁移成功后才部署同批镜像。应用回滚不执行旧 SQL;只有经过兼容性记录证明的旧镜像才允许回滚。数据库逆向回滚依赖受控备份恢复或新的向前修复迁移。
## 失败时怎么处理
| 情况 | 处理方式 |
|---|---|
| `check` 失败 | 修正范围、文件名或 SQL 引用后重新检查;不能用忽略参数跳过 |
| 测试迁移失败、从未成功且未进生产 | 按方案 13.2.1 撤销旧候选、确认无残留和排队任务后,允许修订同一编号;旧制品只读保留,新候选重新完成空库、升级和数据测试 |
| 任一环境已成功执行该版本 | 不修改原文件、不复用编号修订;创建新的向前修复迁移 |
| 生产迁移失败或部分 DDL 已提交 | 停止应用部署,保留失败历史和证据,进入专项恢复;不能简单加版本号掩盖状态 |
| 国内成功、海外失败 | 保留国内成功结果,停止整批应用部署,先按区域恢复或补齐证据;不重放国内成功版本 |
| Jenkins 超时、取消或重启 | 先确认数据库会话和真实历史,状态未确认前禁止重试或清理制品 |
## 日常维护规则
### 代码和目录
- `migration-scopes.json` 是区域、schema、服务归属和顺序的低频配置;增加服务、改数据库归属或调整依赖时才修改,并由两名开发者一起评审。
- `migrations/` 是上线迁移唯一事实来源;不要把模块 `resources/db/`、历史脚本或临时 SQL 当作发布目录。
- 版本号只在同一 schema 内递增,废弃编号不重新分配给其他业务目的;不同 schema 可以使用相同编号。
- 成功迁移、冻结候选、发布 manifest、失败原文和修订记录都必须长期留档;普通构建清理不能删除它们。
- 不提交生成的 manifest、日志、凭据、连接串或真实数据;Jenkins 制品保存 SQL、manifest 和镜像 digest。
### 什么时候需要新增迁移
新增或修改表、列、索引、约束、默认值、触发器、必要系统种子,以及会改变数据库读写要求的 Mapper/持久化 SQL,都要在评审中确认是否需要迁移。需要时用 `new` 创建;不需要时记录具体理由并补受影响数据库读写测试,不能只因为没有 SQL 文件就默认通过。
### 谁负责什么
变更开发者负责 SQL 语义、前后断言、数据影响和代码关联;另一名开发者检查范围、兼容性和测试证据;发布执行人只选择已通过测试的候选并触发自动流程。任何人都不应直接登录服务器手工执行生产 SQL。
## 当前实现边界
当前测试分支已经可以使用 `new`、`check`、`plan` 和 `run --dry-run`,并有对应离线合同测试。以下事项仍未启用:
- 生产或测试数据库基线登记;
- Flyway 实际执行和历史表校验;
- Jenkins 的数据库阶段、环境锁和构建/部署解耦;
- 真实测试环境部署与生产晋级。
因此现在可以开始编写和评审迁移文件,但不能把离线检查通过当作测试或生产发布通过。
@@ -40,19 +40,18 @@ public class ClientFinanceWalletController {
return FinanceResponses.success(service.rechargeRequests(user.tenantId(), page, size));
}
@GetMapping("/recharge-channels")
@GetMapping("/recharge-channel")
@PreAuthorize("hasAuthority('merchant:finance:recharge:create')")
public ApiResponse<?> channels() { return FinanceResponses.success(service.channels()); }
public ApiResponse<?> channel() { return FinanceResponses.success(service.channel()); }
@PostMapping(value = "/recharge-requests", consumes = "multipart/form-data")
@PreAuthorize("hasAuthority('merchant:finance:recharge:create')")
public ApiResponse<?> submit(@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestParam BigDecimal amount,
@RequestParam String paymentMethod,
@RequestPart("file") MultipartFile file) {
return FinanceResponses.success(service.submitRecharge(user.tenantId(), user.userId(), user.displayName(),
idempotencyKey, amount, paymentMethod, file));
idempotencyKey, amount, file));
}
@GetMapping("/recharge-requests/{requestId}/voucher")
@@ -33,13 +33,12 @@ public final class FinanceWalletDtos {
String remark, LocalDateTime createdAt) { }
public record RechargeRequest(long requestId, String requestNo, BigDecimal amount,
String paymentMethod, long voucherFileId, String voucherFileName,
long voucherFileId, String voucherFileName,
String status, int version, String submittedByName,
String reviewedByName, String reviewRemark,
LocalDateTime createdAt, LocalDateTime reviewedAt) { }
public record RechargeChannel(String paymentMethod, String displayName,
boolean enabled, String qrCodeUrl) { }
public record RechargeChannel(boolean enabled, String qrCodeUrl) { }
public record RechargeReviewRequest(String decision, String remark, int expectedVersion) { }
@@ -42,7 +42,6 @@ import java.util.Set;
@Service
public class FinanceWalletService {
private static final Set<String> PAYMENT_METHODS = Set.of("WECHAT", "ALIPAY");
private static final Set<String> ADJUSTMENT_PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER");
private static final Set<String> IMAGE_TYPES = Set.of("image/jpeg", "image/png");
@@ -51,15 +50,13 @@ public class FinanceWalletService {
private final FinanceFileClient fileClient;
private final FinanceDocumentNumberGenerator numberGenerator;
private final String internalToken;
private final String wechatQrCodeUrl;
private final String alipayQrCodeUrl;
private final String rechargeQrCodeUrl;
public FinanceWalletService(JdbcTemplate jdbc, ObjectMapper objectMapper,
FinanceFileClient fileClient,
FinanceDocumentNumberGenerator numberGenerator,
@Value("${baiyue.security.internal-service-token:}") String internalToken,
@Value("${baiyue.finance.recharge.wechat-qr-code-url:}") String wechatQrCodeUrl,
@Value("${baiyue.finance.recharge.alipay-qr-code-url:}") String alipayQrCodeUrl,
@Value("${baiyue.finance.recharge.qr-code-url:}") String rechargeQrCodeUrl,
@Value("${spring.profiles.active:}") String activeProfiles,
@Value("${DEPLOY_ENV:}") String deployEnvironment) {
this.jdbc = jdbc;
@@ -69,8 +66,7 @@ public class FinanceWalletService {
this.internalToken = internalToken;
boolean development = "development".equalsIgnoreCase(deployEnvironment.trim())
|| List.of(activeProfiles.split(",")).stream().map(String::trim).anyMatch("dev"::equalsIgnoreCase);
this.wechatQrCodeUrl = rechargeQrCode(wechatQrCodeUrl, development);
this.alipayQrCodeUrl = rechargeQrCode(alipayQrCodeUrl, development);
this.rechargeQrCodeUrl = rechargeQrCode(rechargeQrCodeUrl, development);
}
private static String rechargeQrCode(String configured, boolean development) {
@@ -104,22 +100,20 @@ public class FinanceWalletService {
return WalletPage.of(rows, total == null ? 0 : total, page, size);
}
public List<RechargeChannel> channels() {
return List.of(new RechargeChannel("WECHAT", "微信支付", wechatQrCodeUrl != null, wechatQrCodeUrl),
new RechargeChannel("ALIPAY", "支付宝", alipayQrCodeUrl != null, alipayQrCodeUrl));
public RechargeChannel channel() {
return new RechargeChannel(rechargeQrCodeUrl != null, rechargeQrCodeUrl);
}
@Transactional
public RechargeRequest submitRecharge(long customerId, long operatorId, String operatorName,
String idempotencyKey, BigDecimal amount,
String paymentMethod, MultipartFile file) {
MultipartFile file) {
requirePositive(customerId, "客户"); requirePositive(operatorId, "操作人");
BigDecimal normalizedAmount = money(amount);
String method = paymentMethod(paymentMethod);
RechargeChannel channel = channels().stream().filter(value -> value.paymentMethod().equals(method)).findFirst().orElseThrow();
if (!channel.enabled()) throw error("FIN-WALLET-CHANNEL-DISABLED", channel.displayName() + "收款二维码未配置");
RechargeChannel channel = channel();
if (!channel.enabled()) throw error("FIN-WALLET-CHANNEL-DISABLED", "收款二维码未配置");
validateImage(file);
String requestHash = hash(Map.of("amount", normalizedAmount.toPlainString(), "paymentMethod", method,
String requestHash = hash(Map.of("amount", normalizedAmount.toPlainString(),
"fileName", String.valueOf(file.getOriginalFilename()), "contentType", String.valueOf(file.getContentType()),
"size", file.getSize(), "fileHash", fileHash(file)));
Long replay = reserve(customerId, operatorId, "WALLET_RECHARGE_SUBMIT", idempotencyKey, requestHash);
@@ -138,12 +132,12 @@ public class FinanceWalletService {
KeyHolder holder = new GeneratedKeyHolder();
jdbc.update(connection -> {
PreparedStatement statement = connection.prepareStatement(
"INSERT INTO fin_recharge_request(customer_id,request_no,amount,payment_method,voucher_file_id,voucher_file_name,status,version,submitted_by,submitted_by_name) VALUES(?,?,?,?,?,?,'PENDING',0,?,?)",
"INSERT INTO fin_recharge_request(customer_id,request_no,amount,voucher_file_id,voucher_file_name,status,version,submitted_by,submitted_by_name) VALUES(?,?,?,?,?,'PENDING',0,?,?)",
Statement.RETURN_GENERATED_KEYS);
statement.setLong(1, customerId); statement.setString(2, requestNo);
statement.setBigDecimal(3, normalizedAmount); statement.setString(4, method);
statement.setLong(5, upload.data().fileId()); statement.setString(6, upload.data().fileName());
statement.setLong(7, operatorId); statement.setString(8, clean(operatorName, 128));
statement.setBigDecimal(3, normalizedAmount);
statement.setLong(4, upload.data().fileId()); statement.setString(5, upload.data().fileName());
statement.setLong(6, operatorId); statement.setString(7, clean(operatorName, 128));
return statement;
}, holder);
long requestId = generatedId(holder);
@@ -173,7 +167,7 @@ public class FinanceWalletService {
WalletAccount afterAccount = rechargeAccount(account, before.amount());
updateAccount(beforeCustomerId(requestId), afterAccount, account.version(), operatorId);
insertLedger(beforeCustomerId(requestId), "RECHARGE", before.amount(), afterAccount,
"CLIENT_RECHARGE", before.requestNo(), LocalDate.now(), before.paymentMethod(), null,
"CLIENT_RECHARGE", before.requestNo(), LocalDate.now(), null, null,
operatorId, operatorName, request.remark());
}
RechargeRequest after = requiredRecharge(requestId, null);
@@ -587,7 +581,7 @@ public class FinanceWalletService {
private RechargeRequest rechargeRow(java.sql.ResultSet rs) throws java.sql.SQLException {
return new RechargeRequest(rs.getLong("id"), rs.getString("request_no"), rs.getBigDecimal("amount"),
rs.getString("payment_method"), rs.getLong("voucher_file_id"), rs.getString("voucher_file_name"),
rs.getLong("voucher_file_id"), rs.getString("voucher_file_name"),
rs.getString("status"), rs.getInt("version"), rs.getString("submitted_by_name"),
rs.getString("reviewed_by_name"), rs.getString("review_remark"), timestamp(rs.getTimestamp("created_at")),
timestamp(rs.getTimestamp("reviewed_at")));
@@ -633,11 +627,6 @@ public class FinanceWalletService {
if (normalized == null) throw bad("业务单号不能为空");
return normalized;
}
private static String paymentMethod(String value) {
String normalized = upper(value);
if (!PAYMENT_METHODS.contains(normalized)) throw bad("付款方式无效");
return normalized;
}
private static void validateImage(MultipartFile file) {
if (file == null || file.isEmpty()) throw bad("请上传付款截图");
if (!IMAGE_TYPES.contains(String.valueOf(file.getContentType()).toLowerCase(Locale.ROOT))) throw bad("付款截图仅支持 JPG 或 PNG");
@@ -8,5 +8,4 @@ spring:
baiyue:
finance:
recharge:
wechat-qr-code-url: ${FINANCE_RECHARGE_WECHAT_QR_CODE_URL:/customer-recharge-qr.svg}
alipay-qr-code-url: ${FINANCE_RECHARGE_ALIPAY_QR_CODE_URL:/customer-recharge-qr.svg}
qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:/customer-recharge-qr.svg}
@@ -467,7 +467,6 @@ CREATE TABLE IF NOT EXISTS fin_recharge_request (
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
request_no VARCHAR(32) NOT NULL COMMENT '充值申请业务编号RC-yyMMdd-####',
amount DECIMAL(20,2) NOT NULL COMMENT '申请充值金额',
payment_method VARCHAR(16) NOT NULL COMMENT 'WECHAT或ALIPAY',
voucher_file_id BIGINT UNSIGNED NOT NULL COMMENT '付款截图File ID',
voucher_file_name VARCHAR(255) NOT NULL COMMENT '付款截图文件名快照',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回',
@@ -485,7 +484,6 @@ CREATE TABLE IF NOT EXISTS fin_recharge_request (
KEY idx_fin_recharge_customer_status (customer_id, status, created_at, id),
CONSTRAINT chk_fin_recharge_customer CHECK (customer_id > 0),
CONSTRAINT chk_fin_recharge_amount CHECK (amount > 0),
CONSTRAINT chk_fin_recharge_method CHECK (payment_method IN ('WECHAT','ALIPAY')),
CONSTRAINT chk_fin_recharge_status CHECK (status IN ('PENDING','APPROVED','REJECTED'))
) ENGINE=InnoDB COMMENT='客户付款截图充值申请;平台审核通过后入账';
@@ -44,7 +44,7 @@ class FinanceSqlCommentContractTest {
}
}
assertThat(tableCount).isEqualTo(23);
assertThat(columnCount).isEqualTo(353);
assertThat(columnCount).isEqualTo(352);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
}
@@ -22,7 +22,7 @@ class FinanceWalletBehaviorTest {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.query(startsWith("SELECT currency_code"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
var account = service(jdbc, "https://pay.example/wechat.png", "").account(9L);
var account = service(jdbc, "https://pay.example/qr.png").account(9L);
assertThat(account.actualBalance()).isEqualByComparingTo("0.00");
assertThat(account.frozenBalance()).isEqualByComparingTo("0.00");
@@ -34,28 +34,25 @@ class FinanceWalletBehaviorTest {
@Test
void rechargeChannelsAreEnabledOnlyWhenQrCodeIsConfigured() {
var channels = service(mock(JdbcTemplate.class), "https://pay.example/wechat.png", "").channels();
var channel = service(mock(JdbcTemplate.class), "https://pay.example/qr.png").channel();
assertThat(channels).satisfiesExactly(
wechat -> { assertThat(wechat.paymentMethod()).isEqualTo("WECHAT"); assertThat(wechat.enabled()).isTrue(); },
alipay -> { assertThat(alipay.paymentMethod()).isEqualTo("ALIPAY"); assertThat(alipay.enabled()).isFalse(); });
assertThat(channel.enabled()).isTrue();
assertThat(channel.qrCodeUrl()).isEqualTo("https://pay.example/qr.png");
}
@Test
void developmentProfileUsesTheNonPayableSampleQrCode() {
var channels = new FinanceWalletService(mock(JdbcTemplate.class), new ObjectMapper().findAndRegisterModules(),
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", "", "",
"cn", "development").channels();
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", "",
"cn", "development").channel();
assertThat(channels).allSatisfy(channel -> {
assertThat(channel.enabled()).isTrue();
assertThat(channel.qrCodeUrl()).isEqualTo("/customer-recharge-qr.svg");
});
assertThat(channels.enabled()).isTrue();
assertThat(channels.qrCodeUrl()).isEqualTo("/customer-recharge-qr.svg");
}
@Test
void deductionMayMakeActualBalanceNegative() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
FinanceWalletService service = service(mock(JdbcTemplate.class), "x");
WalletAccount initial = service.accountState(new BigDecimal("10.00"), BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO, null, 2);
@@ -71,7 +68,7 @@ class FinanceWalletBehaviorTest {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.query(startsWith("SELECT currency_code"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
var account = service(jdbc, "x", "y").account(9L);
var account = service(jdbc, "x").account(9L);
assertThat(account.creditLimit()).isZero();
assertThat(account.availableCredit()).isZero();
@@ -82,7 +79,7 @@ class FinanceWalletBehaviorTest {
@Test
void deductionUsesCashBeforeCreditAndRechargeRepaysCreditFirst() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
FinanceWalletService service = service(mock(JdbcTemplate.class), "x");
WalletAccount initial = service.accountState(new BigDecimal("100.00"), BigDecimal.ZERO,
new BigDecimal("50.00"), BigDecimal.ZERO, LocalDate.now().plusDays(7), 3);
@@ -100,7 +97,7 @@ class FinanceWalletBehaviorTest {
@Test
void deductionNeverConsumesCashReservedByAnActiveHold() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
FinanceWalletService service = service(mock(JdbcTemplate.class), "x");
WalletAccount initial = service.accountState(new BigDecimal("100.00"), new BigDecimal("50.00"),
new BigDecimal("80.00"), new BigDecimal("20.00"), LocalDate.now().plusDays(7), 4);
@@ -116,7 +113,7 @@ class FinanceWalletBehaviorTest {
@Test
void rechargeRepaysUsedCreditWithoutReleasingFrozenCredit() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
FinanceWalletService service = service(mock(JdbcTemplate.class), "x");
WalletAccount initial = service.accountState(new BigDecimal("5.00"), new BigDecimal("25.00"),
new BigDecimal("100.00"), new BigDecimal("40.00"), new BigDecimal("20.00"),
LocalDate.now().plusDays(7), 5);
@@ -133,7 +130,7 @@ class FinanceWalletBehaviorTest {
@Test
void accountStateRejectsCreditUsedAndFrozenAboveTheLimit() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
FinanceWalletService service = service(mock(JdbcTemplate.class), "x");
assertThatThrownBy(() -> service.accountState(BigDecimal.ZERO, new BigDecimal("20.00"),
new BigDecimal("50.00"), new BigDecimal("31.00"), new BigDecimal("20.00"),
@@ -144,7 +141,7 @@ class FinanceWalletBehaviorTest {
@Test
void accountStateAllowsBalanceToBecomeNegativeWhileFundsRemainFrozen() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
FinanceWalletService service = service(mock(JdbcTemplate.class), "x");
WalletAccount account = service.accountState(new BigDecimal("9.99"), new BigDecimal("10.00"),
new BigDecimal("20.00"), BigDecimal.ZERO, LocalDate.now().plusDays(7), 1);
@@ -155,7 +152,7 @@ class FinanceWalletBehaviorTest {
@Test
void accountStateRejectsCreditFrozenAboveTotalFrozenAmount() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
FinanceWalletService service = service(mock(JdbcTemplate.class), "x");
assertThatThrownBy(() -> service.accountState(new BigDecimal("100.00"), new BigDecimal("10.00"),
new BigDecimal("50.00"), new BigDecimal("10.01"), LocalDate.now().plusDays(7), 1))
@@ -165,7 +162,7 @@ class FinanceWalletBehaviorTest {
@Test
void expiredCreditNoLongerContributesToAvailableBalance() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
FinanceWalletService service = service(mock(JdbcTemplate.class), "x");
WalletAccount account = service.accountState(BigDecimal.ZERO, BigDecimal.ZERO,
new BigDecimal("50.00"), new BigDecimal("20.00"), BigDecimal.ZERO,
@@ -176,9 +173,9 @@ class FinanceWalletBehaviorTest {
assertThat(account.availableBalance()).isEqualByComparingTo("-20.00");
}
private static FinanceWalletService service(JdbcTemplate jdbc, String wechat, String alipay) {
private static FinanceWalletService service(JdbcTemplate jdbc, String qrCodeUrl) {
return new FinanceWalletService(jdbc, new ObjectMapper().findAndRegisterModules(),
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", wechat, alipay,
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", qrCodeUrl,
"test", "test");
}
}
+1
View File
@@ -10,6 +10,7 @@
| 类别 | 正式入口 | 管理边界 |
|---|---|---|
| 生产单票发布验收 | `prod-release-acceptance.mjs`、内部 `prod-release-order-stage.mjs` | 同一票义乌到洛杉矶、客户由外部生产配置精确限定;离线计划、基础只读前置及显式确认的单票创建/提交/审核和本阶段续跑,全链执行未开放;不调用测试造数模块,不创建账号/主档或删除数据;配置和待实现范围见 README |
| Admin 全量 HTTP 验证 | `admin-test-env-acceptance.mjs` | 通过显式环境文件运行开发或测试环境;默认模块覆盖平台全链路,定向验证使用 `--modules`,模块注册和依赖以总入口及 `admin-test-env-modules/README.md` 为准 |
| Client 全量 HTTP 验证 | `client-test-env-acceptance.mjs` | 通过显式环境文件运行开发或测试环境;覆盖客户工作台完整链路,`client-phase2-test-env-acceptance.mjs` 只是入库测量与计费专项,不替代全量入口 |
| Client 快速下单定向验证 | `client-quick-order-test-env-acceptance.mjs` | 使用既有测试客户通过批量草稿接口验证 FBA、海外仓、私人地址、逐行失败和幂等重放;只创建草稿,不提交或审核,不替代 Client 全量入口 |
+31
View File
@@ -12,6 +12,35 @@
## 验收环境变量文件
### 生产单票发布验收(独立入口,建设中)
称重计算内部模块 `prod-release-measurement-plan.mjs` 统一采用每箱含纸箱、不含托盘 7.5 kg、40×30×30 cm(0.036 m³)。`order-stage` 在载荷绑定及报价前自动按箱数统一申报箱规、货重及总体积;不推算商品净重,已填商品净重若超过含箱货重则阻断。改口径后的已有检查点载荷不一致会停止,不能以换批次方式盲目重跑。
只读计算命令:`node --env-file="<外部生产配置.env>" scripts/prod-release-acceptance.mjs measurement-plan 2`(箱数只允许 1 或 2;执行前仍需生产 HTTP 授权)。外部配置新增 `CHONGCHONG_ACCEPTANCE_PALLET_CODES`,逗号分隔明确允许复用的现有托盘编码;检查所有编码唯一存在于义乌仓,稳定选择列表第一个,读取其实际 `capacityCbm` 和 `weightKg`,不写死容量或皮重。托数按总体积/选中托盘容量向上取整,每托分配整数箱,称重毛重=该托箱数×7.5+皮重;容量不足以容纳完整箱或参数异常时阻断。不创建托盘/库位、不上传称重图片、不确认称重,只向终端输出 `MEASUREMENT_PLAN_ONLY` 计算结果。服务端库位槽数还依赖其默认托容量配置,与此处实际称重托数不是同一字段;后续由服务端分配并回查既有普通位或 A0,不在脚本伪造库位数。
离线验证:`node --test scripts/prod-release-measurement-plan.test.mjs`,覆盖小数容量边界、皮重、箱数/重量守恒、错误仓库和托盘白名单;不证明真实称重写入已实现。
`prod-release-acceptance.mjs` 目标固定为同一票从义乌 `CN-YIWU-01` 到洛杉矶 `US-LAX-01`,客户身份由外部配置精确限定,不调用开发/测试全量造数模块。已实现离线计划、基础前置检查及单票下单提交审核阶段(支持本阶段检查点续跑);**测量至洛杉矶签收尚未实现,当前不能一键全链验收**。前置通过仅返回 `BASIC_PREFLIGHT_PASSED`,订单阶段通过返回 `ORDER_APPROVED`,始终保留 `fullFlowVerified=false`。实现仅经离线测试,不代表生产 HTTP 已验证。
- `node scripts/prod-release-acceptance.mjs plan`:不加载凭据,不访问 HTTP,列出主链阶段与不覆盖范围。
- `node --env-file="$env:USERPROFILE\.codex\env\chongchong-global\prod-acceptance.env" scripts/prod-release-acceptance.mjs preflight`:获得当次生产 HTTP 授权后运行;登录既有四个账号并查询客户、仓库、PDA 绑定及国内资源。不创建业务数据、主档或账号,不删除数据;登录本身会创建会话及审计记录。
- 必填 `CHONGCHONG_ENV=prod`;`CHONGCHONG_ADMIN_API_BASE_URL`、`CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL`、`CHONGCHONG_MERCHANT_API_BASE_URL` 分别为同一 HTTPS 前端代理的 `/api`、`/overseas-api`、`/api`。
- 客户锁定配置:`CHONGCHONG_ACCEPTANCE_CUSTOMER_ID`、`CHONGCHONG_ACCEPTANCE_CUSTOMER_CODE`、`CHONGCHONG_ACCEPTANCE_CUSTOMER_NAME`。四组账号分别使用 `CHONGCHONG_ADMIN_*`、`CHONGCHONG_MERCHANT_*`、`CHONGCHONG_CN_PDA_*`、`CHONGCHONG_OVERSEAS_LAX_PDA_*` 的 `USERNAME` / `PASSWORD`;另需 `CHONGCHONG_OVERSEAS_LAX_PDA_WAREHOUSE_CODE=US-LAX-01`。不修改环境文件,不读取其他环境作为回退。
- 报告位于 `reports/prod-release-preflight-<时间戳>.json`,不包含账号、Token、真实客户名称或原始响应。网络异常不自动重试;修正问题后可重新执行只读检查,产生新报告,不清理旧报告或业务数据。
- 单票入口 `order-stage <外部订单输入.json> <本批次检查点.json> --confirm-production-order [--resume]`:须另获当次生产写入授权。自动执行基本前置检查,额外校验指定客户可选的唯一渠道/包税维度与 1~2 箱实际重量、体积报价,然后创建一票、提交、审核;不创建图片、地址、托盘或库位。仅支持 AUTO 入仓码、普通海运 FBA 自送散货、单 SKU 非木制非危险品;目的 FBA 仓显式指定,洛杉矶是后续接柜仓,不是第二票。输入与检查点放仓库外,同一批次始终使用同一检查点文件,禁止并发另开路径或换批次重试。
- `prod-release-order-stage.mjs` 是上述入口使用的内部订单阶段与检查点实现,不可直接执行。先落盘并同步意图,再调用 HTTP;检查点绑定环境、客户、操作者、批次和载荷摘要,不保存密码/Token/完整申报。创建与提交仅在显式续跑时复用原幂等键;审核无幂等键,响应丢失后只回查,若仍不能确认已审核则停止并要求人工核对,不自动再次审核。崩溃残留 `.lock` 时先确认无运行进程、检查业务状态,再人工解除锁;不得删除业务检查点。
- 订单输入字段:`batch`(8~48 位字母数字/连字符)、`origin: {id, code: "CN-YIWU-01"}`、`destination: {id, code, type: "FBA"}`、`payload`(`order` 服务 `OrderRequests.SaveRequest` 完整请求)。客户、环境和操作者由外部 env 强制绑定;`payload.customerName` 必须匹配配置,`customerRemark` 必须为 `RELEASE-ACCEPTANCE:<batch>`。显式填 `fulfillmentType=STANDARD`、`transportMode=SEA`、`pickupMethod=SELF_EXPRESS`、`containerMode=LCL`,`cargoes` 一行 1~2 箱;已上传的产品图片 ID 和商品申报字段由本次验收资料提供,箱规/含箱总重/总体积由上述固定口径统一计算,不生成资质或物流签收证明。箱数×每箱数量必须等于总件数,箱规×箱数必须等于总体积。
- 待实现:供应商及海外资源门禁、测量至签收写入、后续阶段归属核验、跨区等待和检查点、所有阶段真实 HTTP 验证。全链 `execute` / `resume` 命令仍拒绝;订单续跑仅使用上述 `order-stage ... --resume`。不得改用测试全量脚本来代替。
- 离线验证:`node --test scripts/prod-release-acceptance.test.mjs`。一票主链不覆盖多客户混柜、退货异常、真实付款、外部下单、邮件发送或页面交互。
订单阶段与恢复验证:`node --test scripts/prod-release-order-stage.test.mjs`,包括断网响应丢失、原幂等键复用、未知审核结果停机、账号/客户/环境/载荷漂移、并发锁、损坏检查点与入口 HTTP mock。生产运行示例(只到已审核,执行前确认授权,不代表全链完成):
```powershell
node --env-file="<外部生产配置.env>" scripts/prod-release-acceptance.mjs order-stage "<外部订单输入.json>" "<本批次检查点.json>" --confirm-production-order
# 中断后保持相同输入、账号和检查点;不要删除检查点或换批次。
node --env-file="<外部生产配置.env>" scripts/prod-release-acceptance.mjs order-stage "<外部订单输入.json>" "<本批次检查点.json>" --confirm-production-order --resume
```
开发与测试环境的账号、密码和 Gateway 只维护在用户目录的两份外部文件中,不写入仓库:
| 环境 | 文件 |
@@ -107,6 +136,8 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜
部署目录的入口另有:`deploy/scripts/deploy-built-service.sh`(单服务健康检查与失败回滚)、`deploy/scripts/deploy-development-service.sh`(经 SSH 部署开发服务)、`deploy/scripts/deploy-services.sh`(按服务列表编排部署)、`deploy/scripts/initialize-test-databases.sh`(初始化测试基础设施数据库)和 `deploy/rocketmq/render-broker-config.sh`(容器内渲染并启动 RocketMQ broker)。这些脚本都会改变运行环境,不能作为普通本地检查执行。
数据库迁移离线入口为 `deploy/sql/scripts/db-migrations.mjs`:`new` 自动生成单 schema 的下一个版本草稿,`check` 做范围/文件/跨 schema 静态检查,`plan` 生成选定服务的不可变清单,`run --dry-run` 只输出执行计划。当前不连接数据库;真实 Flyway 执行须等迁移阶段完成并获得对应环境授权。用途、参数和边界见 `deploy/sql/README.md`。
服务局部分析入口为 `firstleg/scripts/analyze-firstleg-dependencies.mjs` 与 `lastleg-service/scripts/analyze-lastleg-dependencies.mjs`,相邻的 `*.test.mjs` 负责对基线和违规依赖做离线回归;`warehouse/scripts/warehouse-public-master-data-contract.test.mjs` 只读校验 Warehouse 公共主数据 CSV 与 SQL。SQL 辅助文件 `scripts/db/incremental.sql` 不是当前统一部署入口,正式数据库变更以 `deploy/sql/README.md` 指向的统一增量为准。
新增可直接运行的脚本时,必须同时在本节登记用途、目标环境、是否写数据和密钥边界,并运行 `node --test scripts/script-documentation-contract.test.mjs`。仅新增 `*.test.mjs` 或 `admin-test-env-modules/` 内部模块时,分别更新对应合同范围或模块 README,不把内部文件误写成独立入口。
+15 -3
View File
@@ -72,12 +72,24 @@ test('production deploy uses DEPLOY as the single explicit release switch', () =
})
test('production deploy reports one aggregated result to WeCom', () => {
const sourceCommitIndex = pipeline.indexOf("env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD'")
const productionGuardIndex = pipeline.indexOf("if (env.RESOLVED_ENVIRONMENT == 'production')")
assert.match(pipeline, /credentialsId: 'wecom-prod-webhook'/)
assert.match(pipeline, /node\('java-builder'\) \{[\s\S]*?WECOM_WEBHOOK/)
assert.match(pipeline, /env\.JOB_BASE_NAME == 'backend-java-prod' && params\.DEPLOY/)
assert.match(pipeline, /unstable \{[\s\S]*?'\u90e8\u7f72\u90e8\u5206\u5931\u8d25'/)
assert.match(pipeline, /failure \{[\s\S]*?'\u90e8\u7f72\u5931\u8d25'/)
assert.match(pipeline, /aborted \{[\s\S]*?'\u90e8\u7f72\u5df2\u53d6\u6d88'/)
assert.ok(sourceCommitIndex >= 0, 'source commit must be recorded for notifications')
assert.ok(sourceCommitIndex < productionGuardIndex,
'source commit must be recorded before production validation can fail')
assert.match(pipeline, /def downstreamBuildUrls = \[:\]/)
assert.match(pipeline, /downstreamBuildUrls\[service\] = downstreamBuild\.absoluteUrl/)
assert.match(pipeline, /downstreamBuildNumbers\[service\] = downstreamBuild\.number/)
assert.match(pipeline, /unstable \{[\s\S]*?'\u26a0\ufe0f \u751f\u4ea7\u90e8\u7f72\u90e8\u5206\u5931\u8d25'/)
assert.match(pipeline, /\u7ed3\u679c\uff1a\u6210\u529f \$\{successCount\} \/ \u5931\u8d25 \$\{failedServices\.size\(\)\}/)
assert.match(pipeline, /\u5931\u8d25\u670d\u52a1\uff1a\$\{failedLinks/)
assert.match(pipeline, /failure \{[\s\S]*?'\u274c \u751f\u4ea7\u53d1\u5e03\u5931\u8d25'/)
assert.match(pipeline, /aborted \{[\s\S]*?'\u23f9 \u751f\u4ea7\u90e8\u7f72\u5df2\u53d6\u6d88'/)
assert.match(pipeline, /> \u8017\u65f6\uff1a\$\{duration\}/)
assert.match(pipeline, /\u4f01\u4e1a\u5fae\u4fe1\u901a\u77e5\u5931\u8d25\uff0c\u4e0d\u5f71\u54cd\u539f\u6784\u5efa\u7ed3\u679c/)
assert.doesNotMatch(servicePipeline, /wecom-prod-webhook|sendProductionWeComNotification/)
})
+252
View File
@@ -0,0 +1,252 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { normalizeOrderInput, measurementPlan } from './prod-release-measurement-plan.mjs'
// Production is deliberately not routed through the multi-customer test runner.
export const STAGES = Object.freeze([
'CUSTOMER_ORDER', 'ORDER_REVIEW', 'YIWU_MEASUREMENT', 'CUSTOMER_FEE_CONFIRMATION',
'CONTAINER_ALLOCATION', 'DOMESTIC_PDA_LOADING', 'CUSTOMS_AND_BOOKING',
'CROSS_REGION_SYNC', 'VESSEL_AND_TERMINAL', 'LAX_PDA_RECEIVING',
'LAX_DISPATCH', 'LAX_PDA_OUTBOUND', 'DELIVERY_RECEIPT', 'CN_STATUS_RECONCILIATION',
])
export function plan() {
return {
scope: { orders: 1, origin: 'CN-YIWU-01', receiving: 'US-LAX-01', sameOrder: true },
status: 'FULL_FLOW_NOT_IMPLEMENTED', fullFlowVerified: false,
stages: STAGES.map(name => ({ name, status: ['CUSTOMER_ORDER', 'ORDER_REVIEW'].includes(name)
? 'IMPLEMENTED_NOT_HTTP_VERIFIED' : 'NOT_IMPLEMENTED' })),
excluded: ['其他客户', '混柜及拆单', '退货及异常分支', '真实付款', '第三方下单或发送邮件', '浏览器和设备交互'],
policy: { createMasterData: false, createAccounts: false, deleteData: false },
}
}
const requireValue = (env, key) => {
const value = env[key]?.trim()
if (!value) throw new Error(`缺少配置:${key}`)
return value
}
function baseUrl(value, path) {
let url
try { url = new URL(value) } catch { throw new Error('API 地址格式错误') }
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash
|| url.pathname.replace(/\/$/, '') !== path) throw new Error(`API 必须使用 HTTPS 前端代理 ${path}`)
return url.origin + path
}
export function config(env) {
if (env.CHONGCHONG_ENV !== 'prod') throw new Error('必须显式配置 CHONGCHONG_ENV=prod')
const get = key => requireValue(env, `CHONGCHONG_${key}`)
const cn = baseUrl(get('ADMIN_API_BASE_URL'), '/api')
const overseas = baseUrl(get('ADMIN_OVERSEAS_API_BASE_URL'), '/overseas-api')
const merchant = baseUrl(get('MERCHANT_API_BASE_URL'), '/api')
if (new URL(cn).origin !== new URL(overseas).origin || cn !== merchant) {
throw new Error('当前专属入口要求国内、海外及客户 API 使用同一 Admin 前端代理')
}
const customer = { id: get('ACCEPTANCE_CUSTOMER_ID'), code: get('ACCEPTANCE_CUSTOMER_CODE'), name: get('ACCEPTANCE_CUSTOMER_NAME') }
if (!/^[1-9]\d*$/.test(customer.id)) throw new Error('客户 ID 必须为非零十进制字符串')
const credentials = Object.fromEntries(['ADMIN', 'MERCHANT', 'CN_PDA', 'OVERSEAS_LAX_PDA'].map(role =>
[role, { loginId: get(`${role}_USERNAME`), password: get(`${role}_PASSWORD`) }]))
if (get('OVERSEAS_LAX_PDA_WAREHOUSE_CODE') !== 'US-LAX-01') throw new Error('海外 PDA 必须绑定洛杉矶仓')
return { cn, overseas, customer, credentials }
}
function id(value) {
if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value)) throw new Error('接口 ID 必须为精确十进制字符串')
return value
}
export function assertCustomer(profile, expected) {
if (id(profile?.id) !== expected.id || profile.tenantCode !== expected.code
|| profile.tenantName !== expected.name || profile.status !== 'ACTIVE'
|| profile.dimensionInitializationStatus !== 'READY') throw new Error('客户身份、审核状态或箱规初始化不符合配置')
}
// Exact allowlist: adding a business command requires implementing and reviewing a separate executor.
export function allowedRequest(method, path, region) {
if (method === 'POST') return region === 'cn' && path === '/auth/login'
if (method !== 'GET') return false
if (region === 'overseas') return path === '/pda/v1/last-leg/receiving-containers/warehouse-profile'
if (region !== 'cn') return false
return path === '/merchant/v1/iam/tenant/profile'
|| path === '/platform/v1/first-leg/pda-loading/warehouses'
|| /^\/platform\/v1\/iam\/tenants\/[1-9]\d*$/.test(path)
|| /^\/platform\/v1\/warehouse-admin\?page=[1-9]\d*&size=100&warehouseType=(DOMESTIC|OVERSEAS)&status=ACTIVE$/.test(path)
|| /^\/platform\/v1\/(pallets|locations)\?page=[1-9]\d*&size=100&warehouseId=[1-9]\d*$/.test(path)
}
export function client(settings, fetcher = fetch, policy = allowedRequest) {
let requests = 0
return {
get requests() { return requests },
async request(region, method, path, token, body, idempotencyKey) {
if (!policy(method, path, region)) throw new Error('请求不在生产前置检查白名单内')
if (++requests > 100) throw new Error('超过单次前置检查请求上限')
let response
try {
response = await fetcher((region === 'cn' ? settings.cn : settings.overseas) + path, {
method, redirect: 'error', signal: AbortSignal.timeout(20_000),
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}),
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}) },
...(body ? { body: JSON.stringify(body) } : {}),
})
} catch { throw new Error('HTTP 连接失败或超时;未自动重试') }
let envelope
try { envelope = await response.json() } catch { throw new Error('接口未返回 JSON 响应') }
if (!response.ok || envelope?.code !== '200') {
// Never include response message/body: upstream errors may echo submitted credentials.
const safe = value => typeof value === 'string' && /^[\w-]{1,100}$/.test(value) ? value : 'unavailable'
throw new Error(`HTTP ${response.status}; code=${safe(envelope?.code)}; requestId=${safe(envelope?.requestId)}; traceId=${safe(envelope?.traceId)}`)
}
return envelope.data
},
}
}
export async function listAll(http, path, token) {
const records = []
for (let page = 1; page <= 20; page++) {
const data = await http.request('cn', 'GET', path.replace('{page}', String(page)), token)
// PageResult.total is a Java long; LongIdJacksonConfiguration serializes it as a string.
if (!Array.isArray(data?.records) || typeof data.total !== 'string' || !/^\d+$/.test(data.total)) throw new Error('分页接口结构不符合预期')
records.push(...data.records)
if (BigInt(records.length) > BigInt(data.total)) throw new Error('分页总数发生漂移')
if (BigInt(records.length) === BigInt(data.total)) return records
if (!data.records.length) throw new Error('分页结果提前结束')
}
throw new Error('分页超过安全上限,不将截断结果视为完整')
}
export async function preflight(settings, http, receiveContext = () => {}) {
const logins = {}
for (const [role, credentials] of Object.entries(settings.credentials)) {
logins[role] = await http.request('cn', 'POST', '/auth/login', undefined, credentials)
if (!logins[role]?.accessToken) throw new Error(`${role} 登录缺少国内 Token`)
}
const admin = logins.ADMIN.accessToken
assertCustomer(await http.request('cn', 'GET', `/platform/v1/iam/tenants/${settings.customer.id}`, admin), settings.customer)
assertCustomer(await http.request('cn', 'GET', '/merchant/v1/iam/tenant/profile', logins.MERCHANT.accessToken), settings.customer)
const warehouses = {}
for (const [type, code] of [['DOMESTIC', 'CN-YIWU-01'], ['OVERSEAS', 'US-LAX-01']]) {
const all = await listAll(http, `/platform/v1/warehouse-admin?page={page}&size=100&warehouseType=${type}&status=ACTIVE`, admin)
const matches = all.filter(w => w.warehouseCode === code && w.status === 'ACTIVE')
if (matches.length !== 1) throw new Error(`${code} 必须唯一且启用`)
warehouses[code] = id(matches[0].id)
}
const cnWarehouses = await http.request('cn', 'GET', '/platform/v1/first-leg/pda-loading/warehouses', logins.CN_PDA.accessToken)
if (!Array.isArray(cnWarehouses) || !cnWarehouses.some(w => w.warehouseCode === 'CN-YIWU-01'
&& id(w.id) === warehouses['CN-YIWU-01'])) throw new Error('国内 PDA 不可访问义乌仓')
const overseasToken = logins.OVERSEAS_LAX_PDA.overseasAccessToken
if (!overseasToken) throw new Error('洛杉矶 PDA 登录缺少海外 Token')
const binding = await http.request('overseas', 'GET', '/pda/v1/last-leg/receiving-containers/warehouse-profile', overseasToken)
if (binding?.warehouseCode !== 'US-LAX-01' || id(binding.warehouseId) !== warehouses['US-LAX-01']) throw new Error('洛杉矶 PDA 仓库绑定不符')
const counts = {}
let reusablePallets
for (const resource of ['pallets', 'locations']) {
const rows = await listAll(http, `/platform/v1/${resource}?page={page}&size=100&warehouseId=${warehouses['CN-YIWU-01']}`, admin)
if (rows.some(row => id(row.warehouseId) !== warehouses['CN-YIWU-01'])) throw new Error('资源查询出现其他仓库数据')
counts[resource] = rows.filter(row => resource === 'pallets' || row.status === 'AVAILABLE').length
if (!counts[resource]) throw new Error(`义乌仓缺少可复用的 ${resource},不会自动新增`)
if (resource === 'pallets') reusablePallets = rows
}
receiveContext({ logins, warehouses, reusablePallets })
return { ...plan(), status: 'BASIC_PREFLIGHT_PASSED', requests: http.requests,
checks: ['客户身份及箱规状态', '四个账号登录', '义乌及洛杉矶仓', 'PDA 仓库范围', '国内既有托盘及可用库位'],
resourceCounts: counts,
pending: ['单票执行入口的精确线路及小票报价检查', '供应商与海外资源门禁', '测量至签收的写入执行器和检查点', '所有业务阶段真实 HTTP 验证'],
}
}
export async function executeOrder(settings, input, journalPath, resume, fetcher = fetch) {
const { validateOrderInput, withOrderJournal, runOrderStage } = await import('./prod-release-order-stage.mjs')
// These facts come from the selected external environment, not from user-editable order JSON.
const bound = { ...normalizeOrderInput(input), customer: settings.customer, environment: settings.cn,
operator: { merchant: settings.credentials.MERCHANT.loginId, admin: settings.credentials.ADMIN.loginId } }
validateOrderInput(bound)
return withOrderJournal(journalPath, bound, async (state, save) => {
let context
const base = client(settings, fetcher)
await preflight(settings, base, value => { context = value })
if (context.warehouses['CN-YIWU-01'] !== bound.origin.id) throw new Error('下单始发仓与义乌主档不符')
const root = '/merchant/v1/orders/first-leg'
const policy = (method, path, region) => region === 'cn' && (
method === 'GET' && (path === `${root}/inbound-code-policy`
|| state.orderId && path === `${root}/${state.orderId}`
|| path === `/merchant/v1/warehouses/${bound.destination.id}`
|| path === `/merchant/v1/first-leg/pricing/channels?originWarehouseId=${bound.origin.id}`)
|| method === 'POST' && (path === '/merchant/v1/first-leg/pricing/estimate'
|| path === root && state.phase === 'CREATE_INTENT'
|| state.orderId && path === `${root}/${state.orderId}/submit` && state.phase === 'SUBMIT_INTENT'
|| state.orderId && path === `/platform/v1/orders/first-leg/${state.orderId}/approve?tenantId=${settings.customer.id}` && state.phase === 'APPROVE_INTENT'))
const http = client(settings, fetcher, policy)
const merchant = context.logins.MERCHANT.accessToken
return runOrderStage({ input: bound, state, save, resume,
authorize: async () => {
const detail = await http.request('cn', 'GET', `/merchant/v1/warehouses/${bound.destination.id}`, merchant)
const warehouse = detail?.warehouse
if (warehouse?.id !== bound.destination.id || warehouse.warehouseCode !== bound.destination.code
|| warehouse.warehouseType !== 'FBA' || warehouse.status !== 'ACTIVE') throw new Error('目的仓主档不符或未启用')
const channels = await http.request('cn', 'GET', `/merchant/v1/first-leg/pricing/channels?originWarehouseId=${bound.origin.id}`, merchant)
const matching = Array.isArray(channels) ? channels.filter(c => c.channelCode === bound.payload.channelCode
&& c.pricingTaxModes?.includes(bound.payload.pricingTaxMode)) : []
if (matching.length !== 1) throw new Error('当前客户没有唯一匹配的渠道报价')
const cargo = bound.payload.cargoes[0]
const quote = await http.request('cn', 'POST', '/merchant/v1/first-leg/pricing/estimate', merchant, {
originWarehouseId: bound.origin.id, channelCode: bound.payload.channelCode,
destinationCode: bound.destination.code, destinationType: 'FBA', destinationWarehouseId: bound.destination.id,
destinationName: warehouse.warehouseName, pricingTaxMode: bound.payload.pricingTaxMode,
actualWeightKg: cargo.grossWeightKg, actualVolumeCbm: cargo.volumeCbm,
})
if (!quote || quote.priceBookVersionId !== matching[0].priceBookVersionId
|| quote.channelCode !== bound.payload.channelCode || quote.pricingTaxMode !== bound.payload.pricingTaxMode
|| quote.estimatedAmount == null || !Number.isFinite(Number(quote.estimatedAmount)) || Number(quote.estimatedAmount) < 0) {
throw new Error('小票报价不匹配或金额无效')
}
},
transport: ({ role, method, path, body, idempotencyKey }) => http.request('cn', method, path,
context.logins[role].accessToken, body, idempotencyKey),
})
})
}
export async function main(argv = process.argv.slice(2), env = process.env) {
const [mode = 'plan', ...rest] = argv
if (mode === 'measurement-plan') {
if (rest.length !== 1 || !/^[12]$/.test(rest[0])) throw new Error('measurement-plan 需要箱数 1 或 2')
const allowedCodes = requireValue(env, 'CHONGCHONG_ACCEPTANCE_PALLET_CODES').split(',').map(v => v.trim())
const settings = config(env)
let context
await preflight(settings, client(settings), value => { context = value })
console.log(JSON.stringify(measurementPlan({ packageCount: Number(rest[0]),
warehouseId: context.warehouses['CN-YIWU-01'], pallets: context.reusablePallets, allowedCodes }), null, 2))
return
}
if (mode === 'order-stage') {
if ((rest.length !== 3 && rest.length !== 4) || rest[2] !== '--confirm-production-order'
|| rest.length === 4 && rest[3] !== '--resume') throw new Error('order-stage 需要输入文件、检查点、--confirm-production-order,可选 --resume')
const inputPath = resolve(rest[0]), journalPath = resolve(rest[1])
if (inputPath === journalPath) throw new Error('输入和检查点不能使用同一文件')
const input = JSON.parse(await readFile(inputPath, 'utf8'))
const result = await executeOrder(config(env), input, journalPath, rest[3] === '--resume')
console.log(JSON.stringify(result))
return
}
if (!['plan', 'preflight'].includes(mode) || rest.length) throw new Error('用法:prod-release-acceptance.mjs [plan|preflight];全链路执行尚未开放')
if (mode === 'plan') { console.log(JSON.stringify(plan(), null, 2)); return }
const settings = config(env)
const reportPath = resolve('reports', `prod-release-preflight-${Date.now()}.json`)
const http = client(settings)
let report
try { report = await preflight(settings, http) }
catch (error) { report = { ...plan(), status: 'PREFLIGHT_FAILED', requests: http.requests, error: error.message } }
await mkdir(dirname(reportPath), { recursive: true })
await writeFile(reportPath, JSON.stringify(report, null, 2), { flag: 'wx', mode: 0o600 })
console.log(`${report.status}; fullFlowVerified=false; report=${reportPath}`)
if (report.status === 'PREFLIGHT_FAILED') process.exitCode = 1
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
main().catch(error => { console.error(`生产验收入口失败:${error instanceof SyntaxError ? '输入或检查点 JSON 无效' : error.message}`); process.exitCode = 1 })
}
+112
View File
@@ -0,0 +1,112 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { plan, config, assertCustomer, allowedRequest, client, listAll, preflight, main } from './prod-release-acceptance.mjs'
function environment() {
const env = { CHONGCHONG_ENV: 'prod',
CHONGCHONG_ADMIN_API_BASE_URL: 'https://acceptance.invalid/api',
CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL: 'https://acceptance.invalid/overseas-api',
CHONGCHONG_MERCHANT_API_BASE_URL: 'https://acceptance.invalid/api',
CHONGCHONG_ACCEPTANCE_CUSTOMER_ID: '9007199254740993',
CHONGCHONG_ACCEPTANCE_CUSTOMER_CODE: 'fixture', CHONGCHONG_ACCEPTANCE_CUSTOMER_NAME: 'fixture',
CHONGCHONG_OVERSEAS_LAX_PDA_WAREHOUSE_CODE: 'US-LAX-01' }
for (const role of ['ADMIN', 'MERCHANT', 'CN_PDA', 'OVERSEAS_LAX_PDA']) {
env[`CHONGCHONG_${role}_USERNAME`] = role
env[`CHONGCHONG_${role}_PASSWORD`] = 'fixture-secret'
}
return env
}
const profile = { id: '9007199254740993', tenantCode: 'fixture', tenantName: 'fixture', status: 'ACTIVE', dimensionInitializationStatus: 'READY' }
test('single order scope does not claim implemented or full coverage', () => {
const result = plan()
assert.equal(result.scope.orders, 1)
assert.equal(result.scope.sameOrder, true)
assert.equal(result.scope.receiving, 'US-LAX-01')
assert.equal(result.fullFlowVerified, false)
assert.equal(result.stages.filter(s => s.status === 'IMPLEMENTED_NOT_HTTP_VERIFIED').length, 2)
assert.equal(result.stages.filter(s => s.status === 'NOT_IMPLEMENTED').length, 12)
})
test('configuration has no development/test fallback', () => {
assert.throws(() => config({ ADMIN_TEST_GATEWAY: 'https://acceptance.invalid' }))
const env = environment()
assert.equal(config(env).customer.id, profile.id)
delete env.CHONGCHONG_CN_PDA_PASSWORD
assert.throws(() => config(env), /CN_PDA_PASSWORD/)
})
test('reject wrong proxy, insecure URL, embedded credentials and cross-host routing', () => {
for (const value of ['http://acceptance.invalid/api', 'https://user:password@acceptance.invalid/api',
'https://acceptance.invalid/api?token=x', 'https://acceptance.invalid', 'https://other.invalid/api']) {
assert.throws(() => config({ ...environment(), CHONGCHONG_ADMIN_API_BASE_URL: value }))
}
})
test('customer identity and initialized state are exact', () => {
const expected = config(environment()).customer
assert.doesNotThrow(() => assertCustomer(profile, expected))
for (const patch of [{ id: '2' }, { id: Number(profile.id) }, { tenantCode: 'other' },
{ tenantName: 'other' }, { status: 'PENDING' }, { dimensionInitializationStatus: 'FAILED' }]) {
assert.throws(() => assertCustomer({ ...profile, ...patch }, expected))
}
})
test('business mutations, redirects, arbitrary paths and region confusion are denied before fetch', async () => {
let calls = 0
const http = client(config(environment()), async () => { calls++; throw new Error('unexpected') })
for (const [region, method, path] of [['cn', 'POST', '/platform/v1/pallets'],
['cn', 'DELETE', '/platform/v1/locations/1'], ['cn', 'POST', '/merchant/v1/orders'],
['overseas', 'POST', '/auth/login'], ['cn', 'GET', '//untrusted.invalid'],
['cn', 'GET', '/merchant/v1/iam/tenant/profile?redirect=x']]) {
assert.equal(allowedRequest(method, path, region), false)
await assert.rejects(http.request(region, method, path))
}
assert.equal(calls, 0)
})
test('errors retain diagnostic codes, never echoed credentials or response body', async () => {
const http = client(config(environment()), async (_url, options) => {
assert.equal(options.redirect, 'error')
return { ok: true, status: 200, json: async () => ({ code: 'A0403', requestId: 'r-1', traceId: 't-1', message: 'fixture-secret', data: { password: 'fixture-secret' } }) }
})
await assert.rejects(http.request('cn', 'POST', '/auth/login'), error =>
error.message.includes('A0403') && error.message.includes('r-1') && !error.message.includes('fixture-secret'))
})
test('network failure is not retried', async () => {
let count = 0
const http = client(config(environment()), async () => { count++; throw new Error('fixture-secret') })
await assert.rejects(http.request('cn', 'POST', '/auth/login'), /未自动重试/)
assert.equal(count, 1)
})
test('pagination continues and refuses incomplete or malformed lists', async () => {
const pages = [{ records: [{ id: '1' }], total: '2' }, { records: [{ id: '2' }], total: '2' }]
assert.equal((await listAll({ request: async () => pages.shift() }, '/?page={page}', 'token')).length, 2)
await assert.rejects(listAll({ request: async () => ({ records: [], total: '1' }) }, '/?page={page}'), /提前结束/)
await assert.rejects(listAll({ request: async () => ({}) }, '/?page={page}'), /结构/)
})
function fakeHttp(bindingId = '3') {
return { requests: 0, async request(region, method, path) {
this.requests++
assert.equal(allowedRequest(method, path, region), true)
if (method === 'POST') return { accessToken: 'fixture-token', overseasAccessToken: 'fixture-overseas-token' }
if (path.includes('/iam/')) return profile
if (path.includes('/warehouse-profile')) return { warehouseId: bindingId, warehouseCode: 'US-LAX-01' }
if (path.endsWith('/pda-loading/warehouses')) return [{ id: '2', warehouseCode: 'CN-YIWU-01' }]
if (path.includes('/warehouse-admin?')) {
const domestic = path.includes('warehouseType=DOMESTIC')
return { total: '1', records: [{ id: domestic ? '2' : '3', warehouseCode: domestic ? 'CN-YIWU-01' : 'US-LAX-01', status: 'ACTIVE' }] }
}
return { total: '1', records: [{ warehouseId: '2', status: 'AVAILABLE' }] }
} }
}
test('basic preflight is explicitly not full chain and contains no accounts or tokens', async () => {
const result = await preflight(config(environment()), fakeHttp())
assert.equal(result.status, 'BASIC_PREFLIGHT_PASSED')
assert.equal(result.fullFlowVerified, false)
assert.ok(result.pending.length)
assert.doesNotMatch(JSON.stringify(result), /fixture|9007199254740993/)
})
test('PDA warehouse mismatch blocks preflight', async () => {
await assert.rejects(preflight(config(environment()), fakeHttp('4')), /仓库绑定不符/)
})
test('execute, resume and unrecognized arguments fail closed', async () => {
for (const args of [['execute'], ['resume'], ['preflight', '--execute'], ['plan', '--count', '2']]) {
await assert.rejects(main(args, {}), /尚未开放/)
}
})
+74
View File
@@ -0,0 +1,74 @@
// Shared declaration / measurement arithmetic. Pure functions; no HTTP or master-data writes.
export const CARTON_PROFILE = Object.freeze({ lengthCm: 40, widthCm: 30, heightCm: 30, weightKg: 7.5 })
export function cartonTotals(packageCount) {
if (!Number.isInteger(packageCount) || packageCount < 1 || packageCount > 2) throw new Error('生产冒烟仅允许 1~2 箱')
return { packageCount, lengthCm: 40, widthCm: 30, heightCm: 30,
grossWeightKg: packageCount * 7.5, volumeCbm: packageCount * 36 / 1000 }
}
export function normalizeOrderInput(input) {
if (!Array.isArray(input?.payload?.cargoes) || input.payload.cargoes.length !== 1) throw new Error('生产冒烟仅支持一个 SKU')
const cargo = input.payload.cargoes[0]
const totals = cartonTotals(cargo.packageCount)
if (cargo.netWeightKg != null && (!Number.isFinite(Number(cargo.netWeightKg))
|| Number(cargo.netWeightKg) <= 0 || Number(cargo.netWeightKg) > totals.grossWeightKg)) {
throw new Error('申报商品净重无效或超过含箱货重;不能据此推算商品净重')
}
return { ...input, payload: { ...input.payload, cargoes: [{ ...cargo, ...totals }] } }
}
function scaled(value, places, label, allowZero = false) {
const text = String(value)
if (!/^\d+(?:\.\d+)?$/.test(text)) throw new Error(`${label} 无效`)
const [whole, fraction = ''] = text.split('.')
if (fraction.slice(places).replace(/0/g, '')) throw new Error(`${label} 超出允许精度`)
const result = BigInt(whole) * 10n ** BigInt(places) + BigInt(fraction.slice(0, places).padEnd(places, '0'))
if ((!allowZero && result === 0n) || result > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error(`${label} 超出安全范围`)
return result
}
export function measurementPlan({ packageCount, warehouseId, pallets, allowedCodes }) {
const totals = cartonTotals(packageCount)
if (!Array.isArray(allowedCodes) || !allowedCodes.length || new Set(allowedCodes).size !== allowedCodes.length
|| allowedCodes.some(code => typeof code !== 'string' || !code.trim())) throw new Error('需要明确允许复用的既有托盘编码')
if (typeof warehouseId !== 'string' || !/^[1-9]\d*$/.test(warehouseId)) throw new Error('仓库 ID 无效')
const candidates = pallets.filter(p => p.warehouseId === warehouseId && allowedCodes.includes(p.palletCode))
// Resolve every allowed code exactly; do not silently choose another warehouse or create a replacement.
for (const code of allowedCodes) {
if (candidates.filter(p => p.palletCode === code).length !== 1) throw new Error('允许的托盘缺失或编码重复')
}
const pallet = candidates.find(p => p.palletCode === allowedCodes[0])
if (typeof pallet.id !== 'string' || !/^[1-9]\d*$/.test(pallet.id)) throw new Error('托盘 ID 无效')
const capacity = scaled(pallet.capacityCbm, 6, '托容量')
const tare = scaled(pallet.weightKg, 3, '托盘皮重', true)
const cartonVolume = 36000n // cubic centimetres, i.e. 0.036 m³
const totalVolume = cartonVolume * BigInt(packageCount)
const count = Number((totalVolume + capacity - 1n) / capacity)
const cartonsPerPallet = Number(capacity / cartonVolume)
if (!cartonsPerPallet || count > packageCount || cartonsPerPallet * count < packageCount) {
throw new Error('按体积取整无法容纳完整箱,需人工确认托盘容量或装托规则')
}
let remaining = packageCount
const weights = Array.from({ length: count }, (_, index) => {
const cartons = Math.min(cartonsPerPallet, remaining - (count - index - 1))
remaining -= cartons
const netGrams = BigInt(cartons) * 7500n
if (netGrams + tare > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('连托毛重超出安全范围')
return { sequence: index + 1, palletId: pallet.id, palletCode: pallet.palletCode,
packageCount: cartons, volumeCbm: cartons * 36 / 1000,
palletTareWeightKg: Number(tare) / 1000, netWeightKg: Number(netGrams) / 1000,
grossWeightKg: Number(netGrams + tare) / 1000 }
})
return { status: 'MEASUREMENT_PLAN_ONLY', fullFlowVerified: false, ...totals,
cargoWeightKg: totals.grossWeightKg, palletCount: count, capacityCbm: Number(capacity) / 1_000_000,
weights, weightGrossTotalKg: weights.reduce((sum, w) => sum + Math.round(w.grossWeightKg * 1000), 0) / 1000,
locationPolicy: 'SERVER_ALLOCATES_EXISTING_OR_A0', measurementWritten: false }
}
export function assertConfirmedWeight(actual, expected) {
if (actual?.recognitionStatus !== 'CONFIRMED' || actual.palletId !== expected.palletId) throw new Error('称重确认身份或状态不符')
for (const key of ['grossWeightKg', 'netWeightKg', 'palletTareWeightKg']) {
if (scaled(actual[key], 3, key, true) !== scaled(expected[key], 3, key, true)) throw new Error(`称重回查 ${key} 不守恒`)
}
}
@@ -0,0 +1,57 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { cartonTotals, normalizeOrderInput, measurementPlan, assertConfirmedWeight } from './prod-release-measurement-plan.mjs'
const pallet = { id: '10', warehouseId: '20', palletCode: 'FIXTURE-A', capacityCbm: '1.200000', weightKg: '8.000' }
const plan = (patch = {}) => measurementPlan({ packageCount: 2, warehouseId: '20', pallets: [pallet], allowedCodes: ['FIXTURE-A'], ...patch })
test('agreed carton profile: 7.5kg, 40x30x30cm', () => {
assert.deepEqual(cartonTotals(2), { packageCount: 2, lengthCm: 40, widthCm: 30, heightCm: 30, grossWeightKg: 15, volumeCbm: 0.072 })
assert.equal(cartonTotals(1).volumeCbm, 0.036)
for (const count of [0, -1, 3, 1.5, '2', NaN]) assert.throws(() => cartonTotals(count))
})
test('declaration normalization preserves product facts and never mutates source', () => {
const input = { payload: { cargoes: [{ packageCount: 2, skuNo: 'fixture', grossWeightKg: 500, netWeightKg: 12 }] } }
const normalized = normalizeOrderInput(input)
assert.equal(normalized.payload.cargoes[0].grossWeightKg, 15)
assert.equal(normalized.payload.cargoes[0].skuNo, 'fixture')
assert.equal(normalized.payload.cargoes[0].netWeightKg, 12)
assert.equal(input.payload.cargoes[0].grossWeightKg, 500)
assert.throws(() => normalizeOrderInput({ payload: { cargoes: [{ packageCount: 1, netWeightKg: 8 }] } }), /净重/)
})
test('two cartons on existing 8kg tare pallet: scale 23kg, cargo 15kg', () => {
const result = plan()
assert.equal(result.palletCount, 1)
assert.equal(result.weightGrossTotalKg, 23)
assert.equal(result.weights[0].netWeightKg, 15)
assert.equal(result.measurementWritten, false)
assert.equal(result.fullFlowVerified, false)
})
test('exact volume boundaries use decimal integer arithmetic', () => {
assert.equal(plan({ pallets: [{ ...pallet, capacityCbm: '0.072000' }] }).palletCount, 1)
const result = plan({ pallets: [{ ...pallet, capacityCbm: '0.071999' }] })
assert.equal(result.palletCount, 2)
assert.equal(result.weights.reduce((sum, item) => sum + item.packageCount, 0), 2)
assert.equal(result.weightGrossTotalKg, 31)
assert.ok(result.weights.every(w => w.palletId === pallet.id))
})
test('missing capacity or tare never falls back to a fabricated default', () => {
for (const changes of [{ capacityCbm: 0 }, { capacityCbm: undefined }, { capacityCbm: '0.0000001' },
{ weightKg: null }, { weightKg: -1 }, { warehouseId: '99' }, { id: 10 }]) {
assert.throws(() => plan({ pallets: [{ ...pallet, ...changes }] }))
}
assert.equal(plan({ pallets: [{ ...pallet, weightKg: 0 }] }).weightGrossTotalKg, 15)
})
test('missing/duplicate allowlisted pallet fails; no automatic substitute', () => {
assert.throws(() => plan({ allowedCodes: [] }))
assert.throws(() => plan({ allowedCodes: ['FIXTURE-A', 'MISSING'] }))
assert.throws(() => plan({ pallets: [pallet, pallet] }))
assert.throws(() => plan({ pallets: [{ ...pallet, capacityCbm: '0.035' }] }), /完整箱/)
})
test('confirmed weights must preserve tare and cargo totals', () => {
const expected = plan().weights[0]
assert.doesNotThrow(() => assertConfirmedWeight({ ...expected, recognitionStatus: 'CONFIRMED' }, expected))
for (const patch of [{ palletId: '99' }, { recognitionStatus: 'NEEDS_MANUAL' }, { grossWeightKg: 15 },
{ netWeightKg: 7 }, { palletTareWeightKg: 0 }]) {
assert.throws(() => assertConfirmedWeight({ ...expected, recognitionStatus: 'CONFIRMED', ...patch }, expected))
}
})
+166
View File
@@ -0,0 +1,166 @@
import { createHash } from 'node:crypto'
import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises'
import { dirname } from 'node:path'
// Internal stage only. No credentials, fixture generation, CLI or environment fallback.
const ROOT = '/merchant/v1/orders/first-leg'
const assert = (condition, message) => { if (!condition) throw new Error(message) }
const longId = value => typeof value === 'string' && /^[1-9]\d*$/.test(value)
const canonical = value => Array.isArray(value) ? value.map(canonical)
: value && typeof value === 'object'
? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value
export const fingerprint = value => createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex')
export function validateOrderInput(input) {
const { batch, customer, origin, destination, payload, operator } = input
assert(/^[A-Za-z0-9][A-Za-z0-9_-]{7,47}$/.test(batch || ''), '批次须为 8~48 位字母数字或连字符')
assert(longId(customer?.id) && customer.name && customer.code, '缺少固定客户身份')
assert(operator?.merchant && operator?.admin, '缺少幂等操作者身份')
assert(origin?.code === 'CN-YIWU-01' && longId(origin.id), '仅允许义乌始发')
assert(destination?.type === 'FBA' && longId(destination.id) && destination.code, '当前订单阶段仅支持显式 FBA 目的仓')
assert(payload?.customerName === customer.name && (payload.customerId == null || payload.customerId === customer.id), '下单客户与固定客户不符')
assert(payload.receivingWarehouseId === origin.id && payload.destinationWarehouseId === destination.id
&& payload.amazonWarehouseCode === destination.code, '下单仓库与已核对主档不符')
assert(payload.transportMode === 'SEA' && payload.destinationType === 'FBA'
&& payload.fulfillmentType === 'STANDARD' && payload.containerMode === 'LCL'
&& payload.pickupMethod === 'SELF_EXPRESS', '仅支持标准海运 FBA 自送散货链路')
assert(payload.customerRemark === `RELEASE-ACCEPTANCE:${batch}`, '批次标记必须精确匹配')
assert(typeof payload.channelCode === 'string' && payload.channelCode.length > 0, '缺少明确渠道')
assert(payload.inboundCode == null && payload.senderAddressId == null && payload.privateDestinationAddressId == null, '不允许手工入仓码或地址副作用')
assert(Array.isArray(payload.cargoes) && payload.cargoes.length === 1, '专属冒烟固定一个 SKU')
const cargo = payload.cargoes[0]
assert(Number.isInteger(cargo.packageCount) && cargo.packageCount >= 1 && cargo.packageCount <= 2, '冒烟仅允许 1~2 箱')
assert(cargo.woodenProduct === false && cargo.dangerousGoods === false && !cargo.packingGroupNo, '仅支持非木制非危险单 SKU 货物')
assert(longId(cargo.productImageFileId), '需要客户已上传产品图片,不生成虚假证明文件')
assert(cargo.skuNo && Number.isInteger(cargo.unitsPerPackage) && cargo.unitsPerPackage > 0
&& cargo.totalQuantity === cargo.packageCount * cargo.unitsPerPackage, 'SKU 或件数不守恒')
for (const field of ['lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'volumeCbm']) {
assert(typeof cargo[field] === 'number' && Number.isFinite(cargo[field]) && cargo[field] > 0, `货物 ${field} 无效`)
}
const volume = cargo.lengthCm * cargo.widthCm * cargo.heightCm * cargo.packageCount / 1_000_000
assert(Math.abs(volume - cargo.volumeCbm) < 0.000001, '箱规与总体积不守恒')
return input
}
function binding(input) {
return fingerprint({ batch: input.batch, customer: input.customer, origin: input.origin,
destination: input.destination, payload: input.payload, operator: input.operator, environment: input.environment })
}
// Atomic snapshots plus an exclusive process lock. A crashed process leaves the lock for manual review.
// The caller must retain this file between invocations. Never generate a new batch to retry a failed one.
export async function withOrderJournal(path, input, action) {
validateOrderInput(input)
assert(typeof input.environment === 'string' && input.environment.startsWith('https://'), '缺少环境绑定')
await mkdir(dirname(path), { recursive: true })
const lock = await open(`${path}.lock`, 'wx', 0o600)
const temp = `${path}.next`
let tempOwned = false
try {
let state
try { state = JSON.parse(await readFile(path, 'utf8')) }
catch (error) {
if (error.code !== 'ENOENT') throw new Error('检查点损坏,禁止重新创建订单')
state = { version: 1, binding: binding(input), phase: 'NEW', orderId: null }
}
assert(state.version === 1 && state.binding === binding(input), '批次配置、环境、客户、账号或载荷已变化')
assert(['NEW', 'CREATE_INTENT', 'CREATED', 'SUBMIT_INTENT', 'SUBMITTED', 'APPROVE_INTENT', 'APPROVED'].includes(state.phase), '检查点阶段无效')
assert(['NEW', 'CREATE_INTENT'].includes(state.phase) ? state.orderId === null : longId(state.orderId), '检查点订单身份无效')
const save = async patch => {
const next = { ...state, ...patch }
const file = await open(temp, 'wx', 0o600)
tempOwned = true
try { await file.writeFile(JSON.stringify(next, null, 2)); await file.sync() } finally { await file.close() }
await rename(temp, path)
tempOwned = false
Object.assign(state, next)
}
if (state.phase === 'NEW') await save({})
return await action(state, save)
} finally {
// Only delete files created by this invocation; never remove an existing crash checkpoint.
if (tempOwned) await unlink(temp).catch(() => {})
await lock.close()
await unlink(`${path}.lock`)
}
}
export function assertOrderDetail(detail, input, orderId) {
const order = detail?.order
assert(order?.id === orderId && order.tenantId === input.customer.id && order.customerId === input.customer.id,
'订单归属与本批次不符')
for (const field of ['receivingWarehouseId', 'destinationWarehouseId', 'channelCode', 'destinationType',
'fulfillmentType', 'customerRemark', 'pickupMethod', 'containerMode', 'pricingTaxMode']) {
assert(order[field] === input.payload[field], `订单 ${field} 与批次不符`)
}
assert(typeof order.orderNo === 'string' && order.orderNo.length > 0, '缺少业务订单号')
assert(['DRAFT', 'SUBMITTED', 'APPROVED'].includes(order.status), '订单已偏离当前验收阶段,停止自动推进')
assert(Array.isArray(detail.cargoes) && detail.cargoes.length === 1, '订单货物行发生变化')
const cargo = detail.cargoes[0]
for (const key of ['skuNo', 'packageCount', 'totalQuantity', 'unitsPerPackage', 'productImageFileId']) {
assert(cargo[key] === input.payload.cargoes[0][key], `订单货物 ${key} 与批次不符`)
}
for (const key of ['lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'volumeCbm']) {
assert(Number(cargo[key]) === input.payload.cargoes[0][key], `订单货物 ${key} 与批次不符`)
}
return order
}
/**
* transport is intentionally scoped to this module's five fixed endpoints, not the old test context.
* authorize performs fresh client/admin identity and route/quote checks, including on explicit resume.
* An uncertain non-idempotent approval is never replayed based only on SUBMITTED being observed.
*/
export async function runOrderStage({ input, state, save, transport, authorize, resume = false }) {
validateOrderInput(input)
assert(state.binding === binding(input), '检查点绑定不符')
await authorize(input)
const key = op => `prod-release-${fingerprint({ binding: state.binding, op }).slice(0, 48)}`
const request = (role, method, path, body, idempotencyKey) => transport({ role, method, path, body, idempotencyKey })
if (state.phase === 'CREATE_INTENT') assert(resume, '创建结果待核对;需要显式续跑')
if (['NEW', 'CREATE_INTENT'].includes(state.phase)) {
const policy = await request('MERCHANT', 'GET', `${ROOT}/inbound-code-policy`)
assert(policy?.mode === 'AUTO' && policy.manualEntryRequired === false, '当前专属链只支持自动入仓码')
await save({ phase: 'CREATE_INTENT' })
const orderId = await request('MERCHANT', 'POST', ROOT, input.payload, key('create'))
assert(longId(orderId), '创建返回订单 ID 无效,保留创建意图待核对')
await save({ phase: 'CREATED', orderId })
}
assert(longId(state.orderId), '缺少精确订单 ID')
const path = `${ROOT}/${state.orderId}`
const read = async () => assertOrderDetail(await request('MERCHANT', 'GET', path), input, state.orderId)
let order = await read()
if (state.phase === 'CREATED') {
assert(order.status === 'DRAFT', '创建后状态被外部修改')
await save({ phase: 'SUBMIT_INTENT' })
await request('MERCHANT', 'POST', `${path}/submit`, undefined, key('submit'))
order = await read()
assert(order.status === 'SUBMITTED', '提交后未进入待审核')
await save({ phase: 'SUBMITTED', orderNo: order.orderNo, inboundCode: order.inboundCode })
} else if (state.phase === 'SUBMIT_INTENT') {
assert(resume, '提交结果待核对;需要显式续跑')
if (order.status === 'DRAFT') {
await request('MERCHANT', 'POST', `${path}/submit`, undefined, key('submit'))
order = await read()
}
assert(order.status === 'SUBMITTED', '提交结果无法安全核对')
await save({ phase: 'SUBMITTED', orderNo: order.orderNo, inboundCode: order.inboundCode })
}
if (state.phase === 'SUBMITTED') {
order = await read()
assert(order.status === 'SUBMITTED', '审核前状态被外部修改')
await save({ phase: 'APPROVE_INTENT' })
await request('ADMIN', 'POST', `/platform/v1/orders/first-leg/${state.orderId}/approve?tenantId=${input.customer.id}`)
order = await read()
assert(order.status === 'APPROVED', '审核后未进入已审核,保留意图待核对')
await save({ phase: 'APPROVED' })
} else if (state.phase === 'APPROVE_INTENT') {
assert(resume && order.status === 'APPROVED', '审核结果不确定;禁止重发审核,需人工核对')
await save({ phase: 'APPROVED' })
}
order = await read()
assert(state.phase === 'APPROVED' && order.status === 'APPROVED', '订单阶段没有完成')
assert(typeof order.inboundCode === 'string' && order.inboundCode.length > 0, '审核订单缺少入仓码')
return { status: 'ORDER_APPROVED', orderId: state.orderId, orderNo: order.orderNo,
inboundCode: order.inboundCode, fullFlowVerified: false, next: 'YIWU_MEASUREMENT' }
}
+208
View File
@@ -0,0 +1,208 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fingerprint, validateOrderInput, withOrderJournal, runOrderStage } from './prod-release-order-stage.mjs'
import { executeOrder } from './prod-release-acceptance.mjs'
function fixture() {
return { batch: 'release-fixture-001', environment: 'https://fixture.invalid/api',
operator: { merchant: 'fixture-client', admin: 'fixture-admin' },
customer: { id: '1', name: 'fixture', code: 'fixture' }, origin: { id: '2', code: 'CN-YIWU-01' },
destination: { id: '3', code: 'FIXTURE', type: 'FBA' },
payload: { customerName: 'fixture', receivingWarehouseId: '2', destinationWarehouseId: '3',
amazonWarehouseCode: 'FIXTURE', transportMode: 'SEA', destinationType: 'FBA', fulfillmentType: 'STANDARD',
containerMode: 'LCL', pickupMethod: 'SELF_EXPRESS', channelCode: 'FIXTURE', pricingTaxMode: 'TAX_INCLUDED',
customerRemark: 'RELEASE-ACCEPTANCE:release-fixture-001',
cargoes: [{ skuNo: 'fixture-sku', packageCount: 2, unitsPerPackage: 1, totalQuantity: 2,
lengthCm: 40, widthCm: 30, heightCm: 30, grossWeightKg: 15, volumeCbm: 0.072,
productImageFileId: '4', woodenProduct: false, dangerousGoods: false }] } }
}
async function sandbox(t) {
const path = await mkdtemp(join(tmpdir(), 'prod-order-contract-'))
t.after(() => rm(path, { recursive: true, force: true }))
return join(path, 'journal.json')
}
function server(input, fail) {
let status = null
let failed = false
const calls = []
const state = { calls, created: 0, approved: 0,
async transport(request) {
calls.push(request)
const { method, path, idempotencyKey } = request
if (path.endsWith('/inbound-code-policy')) return { mode: 'AUTO', manualEntryRequired: false }
if (method === 'GET') return { order: { ...input.payload, id: '5', tenantId: '1', customerId: '1',
status, orderNo: 'FIXTURE-ORDER', inboundCode: status === 'DRAFT' ? null : 'FIXTURE-INBOUND' }, cargoes: input.payload.cargoes }
let op
if (path.endsWith('/first-leg')) {
op = 'create'
assert.ok(idempotencyKey)
if (!status) { state.created++; status = 'DRAFT' }
} else if (path.endsWith('/submit')) {
op = 'submit'
assert.ok(idempotencyKey)
status = 'SUBMITTED'
} else {
assert.match(path, /^\/platform\/v1\/orders\/first-leg\/5\/approve\?tenantId=1$/)
assert.equal(request.role, 'ADMIN')
op = 'approve'
state.approved++
if (fail !== 'approve-before') status = 'APPROVED'
}
if ((fail === op || fail === 'approve-before' && op === 'approve') && !failed) {
failed = true
throw new Error('simulated response loss')
}
return op === 'create' ? '5' : null
},
}
return state
}
async function run(path, input, api, resume = false, authorize = async () => {}) {
return withOrderJournal(path, input, (state, save) => runOrderStage({ input, state, save,
transport: api.transport, authorize, resume }))
}
test('fingerprint is stable for reordered keys', () => {
assert.equal(fingerprint({ a: 1, b: 2 }), fingerprint({ b: 2, a: 1 }))
})
test('rejects multiple SKUs, excess cartons, incorrect company and volume', () => {
for (const change of [i => i.payload.cargoes.push(i.payload.cargoes[0]),
i => i.payload.cargoes[0].packageCount = 300,
i => i.payload.customerId = '999', i => i.origin.code = 'CN-OTHER',
i => i.payload.customerRemark += '-other', i => i.payload.cargoes[0].volumeCbm = 2]) {
const input = fixture(); change(input)
assert.throws(() => validateOrderInput(input))
}
})
test('one create, one submit, one approval; completed resume performs reads only', async t => {
const path = await sandbox(t), input = fixture(), api = server(input)
const result = await run(path, input, api)
assert.equal(result.status, 'ORDER_APPROVED')
assert.equal(result.fullFlowVerified, false)
const count = api.calls.filter(c => c.method === 'POST').length
await run(path, input, api, true)
assert.equal(api.calls.filter(c => c.method === 'POST').length, count)
assert.equal(api.created, 1)
assert.equal(api.approved, 1)
const disk = await readFile(path, 'utf8')
assert.doesNotMatch(disk, /fixture-client|fixture-admin|productImageFileId|https:/)
})
for (const failure of ['create', 'submit', 'approve']) {
test(`recover lost ${failure} response without duplicate business records`, async t => {
const path = await sandbox(t), input = fixture(), api = server(input, failure)
await assert.rejects(run(path, input, api), /response loss/)
await assert.rejects(run(path, input, api), /显式续跑|禁止重发/)
await run(path, input, api, true)
assert.equal(api.created, 1)
assert.equal(api.approved, 1)
if (failure === 'create') {
const creates = api.calls.filter(c => c.method === 'POST' && c.path.endsWith('/first-leg'))
assert.equal(creates.length, 2)
assert.equal(creates[0].idempotencyKey, creates[1].idempotencyKey)
}
})
}
test('uncertain approval that still reads submitted must not be resent', async t => {
const path = await sandbox(t), input = fixture(), api = server(input, 'approve-before')
await assert.rejects(run(path, input, api))
await assert.rejects(run(path, input, api, true), /禁止重发审核/)
assert.equal(api.approved, 1)
})
test('change in environment, account, payload or batch is blocked before HTTP', async t => {
const path = await sandbox(t), input = fixture(), api = server(input)
await run(path, input, api)
for (const change of [i => i.environment = 'https://other.invalid/api', i => i.operator.merchant = 'other',
i => i.payload.channelCode = 'OTHER', i => { i.batch = 'release-fixture-002'; i.payload.customerRemark = `RELEASE-ACCEPTANCE:${i.batch}` }]) {
const other = fixture(); change(other)
const count = api.calls.length
await assert.rejects(run(path, other, api, true), /已变化/)
assert.equal(api.calls.length, count)
}
})
test('authorization failure prevents first write', async t => {
const path = await sandbox(t), input = fixture(), api = server(input)
await assert.rejects(run(path, input, api, false, async () => { throw new Error('wrong customer') }))
assert.equal(api.calls.length, 0)
})
test('foreign order detail blocks submission', async t => {
const path = await sandbox(t), input = fixture(), api = server(input)
const original = api.transport
api.transport = async req => {
const data = await original(req)
if (data?.order) data.order.customerId = '999'
return data
}
await assert.rejects(run(path, input, api), /归属/)
assert.equal(api.calls.filter(c => c.method === 'POST').length, 1)
})
test('concurrent journal process is rejected', async t => {
const path = await sandbox(t), input = fixture()
await withOrderJournal(path, input, async () => {
await assert.rejects(withOrderJournal(path, input, async () => {}), { code: 'EEXIST' })
})
})
test('corrupt journal is preserved and fails closed', async t => {
const path = await sandbox(t), input = fixture(), api = server(input)
await writeFile(path, '{broken')
await assert.rejects(run(path, input, api), /检查点损坏/)
assert.equal(await readFile(path, 'utf8'), '{broken')
assert.equal(api.calls.length, 0)
})
for (const quoteMismatch of [false, true]) {
test(`entry integration: customer/profile/route checks and HTTP headers (quote mismatch=${quoteMismatch})`, async t => {
const path = await sandbox(t), input = fixture(), api = server(input)
const settings = { cn: 'https://fixture.invalid/api', overseas: 'https://fixture.invalid/overseas-api', customer: input.customer,
credentials: Object.fromEntries(['ADMIN', 'MERCHANT', 'CN_PDA', 'OVERSEAS_LAX_PDA'].map(role => [role, { loginId: role, password: 'secret' }])) }
const fetcher = async (url, options) => {
const endpoint = new URL(url).pathname.replace(/^\/(overseas-api|api)/, '') + new URL(url).search
let data
if (endpoint === '/auth/login') {
const role = JSON.parse(options.body).loginId
data = { accessToken: role, overseasAccessToken: `${role}-overseas` }
} else if (endpoint.includes('/iam/')) {
data = { id: '1', tenantCode: 'fixture', tenantName: 'fixture', status: 'ACTIVE', dimensionInitializationStatus: 'READY' }
} else if (endpoint.includes('/warehouse-admin?')) {
const domestic = endpoint.includes('warehouseType=DOMESTIC')
data = { total: '1', records: [{ id: domestic ? '2' : '6', warehouseCode: domestic ? 'CN-YIWU-01' : 'US-LAX-01', status: 'ACTIVE' }] }
} else if (endpoint.endsWith('/pda-loading/warehouses')) {
assert.equal(options.headers.Authorization, 'Bearer CN_PDA')
data = [{ id: '2', warehouseCode: 'CN-YIWU-01' }]
} else if (endpoint.endsWith('/warehouse-profile')) {
assert.equal(options.headers.Authorization, 'Bearer OVERSEAS_LAX_PDA-overseas')
data = { warehouseId: '6', warehouseCode: 'US-LAX-01' }
} else if (/\/platform\/v1\/(pallets|locations)\?/.test(endpoint)) {
data = { total: '1', records: [{ warehouseId: '2', status: 'AVAILABLE' }] }
} else if (endpoint === '/merchant/v1/warehouses/3') {
data = { warehouse: { id: '3', warehouseCode: 'FIXTURE', warehouseType: 'FBA', status: 'ACTIVE', warehouseName: 'fixture' } }
} else if (endpoint.includes('/pricing/channels?')) {
data = [{ channelCode: 'FIXTURE', priceBookVersionId: '7', pricingTaxModes: ['TAX_INCLUDED'] }]
} else if (endpoint.endsWith('/pricing/estimate')) {
assert.equal(options.headers.Authorization, 'Bearer MERCHANT')
const body = JSON.parse(options.body)
assert.equal(body.actualWeightKg, 15)
assert.equal(body.actualVolumeCbm, 0.072)
data = { channelCode: 'FIXTURE', priceBookVersionId: quoteMismatch ? '8' : '7', pricingTaxMode: 'TAX_INCLUDED', estimatedAmount: 10 }
} else {
const role = endpoint.includes('/approve?') ? 'ADMIN' : 'MERCHANT'
assert.equal(options.headers.Authorization, `Bearer ${role}`)
data = await api.transport({ role, path: endpoint, method: options.method,
body: options.body ? JSON.parse(options.body) : undefined, idempotencyKey: options.headers['Idempotency-Key'] })
}
return { ok: true, status: 200, json: async () => ({ code: '200', data }) }
}
if (quoteMismatch) {
await assert.rejects(executeOrder(settings, input, path, false, fetcher), /报价不匹配/)
assert.equal(api.created, 0)
} else {
assert.equal((await executeOrder(settings, input, path, false, fetcher)).status, 'ORDER_APPROVED')
assert.equal(api.created, 1)
assert.equal(api.approved, 1)
await executeOrder(settings, input, path, true, fetcher)
assert.equal(api.created, 1)
assert.equal(api.approved, 1)
}
})
}
@@ -14,6 +14,7 @@ const excludedDirectoryNames = new Set([
const allowedDirectoryPrefixes = [
'scripts/',
'deploy/scripts/',
'deploy/sql/scripts/',
'deploy/rocketmq/',
'firstleg/scripts/',
'lastleg-service/scripts/',
@@ -62,6 +63,7 @@ test('all directly runnable scripts are documented in scripts/README.md', async
...await executableFiles('scripts', ['.mjs', '.ps1', '.groovy']),
...await executableFiles('scripts/openapi-validation', ['.mjs']),
...await executableFiles('deploy/scripts', ['.sh']),
...await executableFiles('deploy/sql/scripts', ['.mjs']),
...await executableFiles('deploy/rocketmq', ['.sh']),
...await executableFiles('firstleg/scripts', ['.mjs']),
...await executableFiles('lastleg-service/scripts', ['.mjs']),