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:
Vendored
+46
-15
@@ -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}"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 操作。
|
||||
|
||||
## 本地验证
|
||||
|
||||
离线合同不连接数据库:
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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='客户付款截图充值申请;平台审核通过后入账';
|
||||
|
||||
|
||||
@@ -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 的数据库阶段、环境锁和构建/部署解耦;
|
||||
- 真实测试环境部署与生产晋级。
|
||||
|
||||
因此现在可以开始编写和评审迁移文件,但不能把离线检查通过当作测试或生产发布通过。
|
||||
+3
-4
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 全量入口 |
|
||||
|
||||
@@ -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,不把内部文件误写成独立入口。
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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, {}), /尚未开放/)
|
||||
}
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
@@ -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' }
|
||||
}
|
||||
@@ -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']),
|
||||
|
||||
Reference in New Issue
Block a user