207 lines
12 KiB
JavaScript
207 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
import { mkdir, writeFile } from 'node:fs/promises'
|
|
import { dirname, join, resolve } from 'node:path'
|
|
import { spawnSync } from 'node:child_process'
|
|
|
|
const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000'
|
|
const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300'
|
|
const store = process.env.CODEX_BROWSER_LOCAL_STORAGE || join(process.env.APPDATA || '', 'Codex', 'web', 'Codex', 'Default', 'Partitions', 'codex-browser-app', 'Local Storage', 'leveldb')
|
|
const outputDir = resolve(process.env.OVERSEAS_CURRENT_SESSION_OUTPUT_DIR || 'reports/current-session-overseas-relabel')
|
|
const adminLoginId = process.env.ADMIN_TEST_USERNAME || 'platform_admin'
|
|
const adminPassword = process.env.ADMIN_TEST_PASSWORD || 'ChangeMe123!'
|
|
const marker = `CURRENT-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}`
|
|
const summary = { marker, startedAt: new Date().toISOString(), scope: 'CURRENT_CODEX_BROWSER_SESSION', passed: false }
|
|
|
|
function assert(value, message) { if (!value) throw new Error(message) }
|
|
function id(value) { return value == null ? '' : String(value) }
|
|
function jwtId(token, ...claimNames) {
|
|
try {
|
|
const payload = Buffer.from(token.split('.')[1], 'base64url').toString('utf8')
|
|
for (const claimName of claimNames) {
|
|
const match = payload.match(new RegExp(`"${claimName}"\\s*:\\s*(?:"(\\d+)"|(\\d+))`))
|
|
if (match) return match[1] || match[2]
|
|
}
|
|
} catch {}
|
|
return ''
|
|
}
|
|
async function api(base, token, method, path, body) {
|
|
const response = await fetch(`${base}${path}`, { method,
|
|
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
|
body: body === undefined ? undefined : JSON.stringify(body) })
|
|
const text = await response.text(); let value
|
|
try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } }
|
|
if (!response.ok || String(value?.code) !== '200') throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`)
|
|
return value.data
|
|
}
|
|
async function rawApi(base, token, method, path, body) {
|
|
const response = await fetch(`${base}${path}`, { method,
|
|
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
|
body: body === undefined ? undefined : JSON.stringify(body) })
|
|
const text = await response.text(); let value
|
|
try { value = text ? JSON.parse(text) : null } catch { value = null }
|
|
return { http: response.status, code: String(value?.code || ''), data: value?.data }
|
|
}
|
|
function storedValues(key) {
|
|
assert(existsSync(store), `Codex browser local storage not found: ${store}`)
|
|
const values = []
|
|
const files = readdirSync(store).filter((name) => /\.(?:log|ldb)$/.test(name))
|
|
.map((name) => ({ name, path: join(store, name) }))
|
|
.sort((left, right) => readFileSync(right.path).length - readFileSync(left.path).length)
|
|
for (const file of files) {
|
|
const source = readFileSync(file.path).toString('latin1')
|
|
let offset = 0
|
|
while ((offset = source.indexOf(key, offset)) >= 0) {
|
|
const match = source.slice(offset + key.length, offset + key.length + 256).match(/[A-Za-z0-9_-]{48,160}/)
|
|
if (match) values.push(match[0])
|
|
offset += key.length
|
|
}
|
|
}
|
|
return [...new Set(values)].reverse()
|
|
}
|
|
function storedJwt(key) {
|
|
for (const file of readdirSync(store).filter((name) => /\.(?:log|ldb)$/.test(name)).reverse()) {
|
|
const source = readFileSync(join(store, file)).toString('latin1')
|
|
let offset = source.lastIndexOf(key)
|
|
while (offset >= 0) {
|
|
const match = source.slice(offset + key.length, offset + key.length + 2000)
|
|
.match(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/)
|
|
if (match) return match[0]
|
|
offset = source.lastIndexOf(key, offset - 1)
|
|
}
|
|
}
|
|
return ''
|
|
}
|
|
async function refreshStoredSession(key, label) {
|
|
for (const refreshToken of storedValues(key)) {
|
|
try { return await api(domestic, '', 'POST', '/api/auth/refresh', { refreshToken }) } catch {}
|
|
}
|
|
throw new Error(`No valid ${label} refresh token was found in the current Codex browser session`)
|
|
}
|
|
async function disableStalePdas(adminToken) {
|
|
const users = await api(domestic, adminToken, 'GET', '/api/platform/v1/iam/users?page=1&size=100&keyword=pda_current_')
|
|
for (const user of users.records || []) {
|
|
if (String(user.username || '').startsWith('pda_current_') && user.status === 'ACTIVE') {
|
|
await api(domestic, adminToken, 'PUT', `/api/platform/v1/iam/users/${id(user.membershipId || user.id)}/status`, { status: 'DISABLED' })
|
|
}
|
|
}
|
|
}
|
|
async function createPda(adminToken, warehouseId, onCreated) {
|
|
const roles = await api(domestic, adminToken, 'GET', '/api/platform/v1/iam/roles')
|
|
const role = roles.find((item) => item.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' && item.status === 'ACTIVE')
|
|
assert(role, 'Active OVERSEAS_WAREHOUSE_PDA_OPERATOR role not found')
|
|
const suffix = `${Date.now()}${process.pid}`.slice(-12)
|
|
const username = `pda_current_${suffix}`
|
|
const password = `PdaCurrent${suffix}A9`
|
|
const created = await api(domestic, adminToken, 'POST', '/api/platform/v1/iam/users', {
|
|
username, displayName: `当前会话海外PDA验收-${suffix}`, phone: `19${suffix.slice(-9)}`,
|
|
email: `${username}@example.com`, tenantId: '0', roleIds: [id(role.id)],
|
|
overseasWarehouseId: warehouseId, password, status: 'ACTIVE',
|
|
})
|
|
onCreated({ membershipId: id(created.membershipId), token: null })
|
|
const login = await api(domestic, '', 'POST', '/api/auth/login', { loginId: username, password })
|
|
assert(login.overseasAccessToken, 'Temporary PDA login did not return an overseas token')
|
|
onCreated({ membershipId: id(created.membershipId), token: login.overseasAccessToken })
|
|
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
const response = await rawApi(overseas, login.overseasAccessToken, 'GET', '/api/pda/v1/last-leg/receiving-containers/current')
|
|
if (response.http === 200 && response.code !== '403' && response.code !== 'A0403') {
|
|
return { membershipId: id(created.membershipId), token: login.overseasAccessToken }
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
}
|
|
throw new Error('Temporary PDA security projection did not converge')
|
|
}
|
|
|
|
async function merchantSessionForTenant(adminToken, tenantId) {
|
|
const ticket = await api(domestic, adminToken, 'POST',
|
|
`/api/platform/v1/iam/tenants/${tenantId}/impersonation`, { reason: `${marker} 海外换标真实验收` })
|
|
return api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
|
|
}
|
|
|
|
async function findRelabelScope(adminToken, preferredMerchant = null) {
|
|
const sessions = []
|
|
if (preferredMerchant?.accessToken && preferredMerchant?.overseasAccessToken) sessions.push(preferredMerchant)
|
|
if (!sessions.length) {
|
|
const tenants = await api(domestic, adminToken, 'GET', '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE')
|
|
for (const tenant of tenants.records || []) {
|
|
try { sessions.push(await merchantSessionForTenant(adminToken, id(tenant.id))) } catch {}
|
|
}
|
|
}
|
|
for (const session of sessions) {
|
|
const customerId = jwtId(session.overseasAccessToken, 'tenant_id', 'tenantId')
|
|
if (!customerId) continue
|
|
let warehouses
|
|
try { warehouses = await api(domestic, session.accessToken, 'GET', '/api/merchant/v1/warehouses?warehouseType=OVERSEAS&page=1&size=200') }
|
|
catch { continue }
|
|
for (const warehouse of warehouses.records || []) {
|
|
if (warehouse.status !== 'ACTIVE') continue
|
|
let candidates
|
|
try { candidates = await api(overseas, session.overseasAccessToken, 'GET', `/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?operationWarehouseId=${encodeURIComponent(id(warehouse.id))}`) }
|
|
catch { continue }
|
|
if (candidates.some((item) => String(item.inboundOrderNo || '').startsWith('DS-')
|
|
&& Number(item.availablePackages) >= 5 && item.destinationFbaWarehouseCode
|
|
&& item.positions?.some((position) => position.palletNo && position.palletNo !== 'UNBOUND'
|
|
&& Number(position.availablePackages) >= 5))) {
|
|
return { merchant: session, customerId, warehouse }
|
|
}
|
|
}
|
|
}
|
|
throw new Error('No active customer has a destination warehouse and five cartons on a physical source pallet')
|
|
}
|
|
|
|
await mkdir(outputDir, { recursive: true })
|
|
let admin
|
|
let pda
|
|
try {
|
|
try {
|
|
admin = await refreshStoredSession('cc-admin-refresh-token', 'platform')
|
|
} catch {
|
|
admin = await api(domestic, '', 'POST', '/api/auth/login', { loginId: adminLoginId, password: adminPassword })
|
|
}
|
|
const storedMerchantToken = storedJwt('cc-client-overseas-access-token')
|
|
const storedCustomerId = jwtId(storedMerchantToken, 'tenant_id', 'tenantId')
|
|
let preferredMerchant = null
|
|
try {
|
|
preferredMerchant = await refreshStoredSession('cc-client-refresh-token', 'merchant')
|
|
} catch {
|
|
if (storedCustomerId) preferredMerchant = await merchantSessionForTenant(admin.accessToken, storedCustomerId)
|
|
}
|
|
assert(admin.accessToken && admin.overseasAccessToken, 'Platform refresh response did not contain both regional tokens')
|
|
const resume = process.env.OVERSEAS_RESUME_FULFILLMENT === 'true'
|
|
if (resume && (!process.env.OVERSEAS_TEST_CUSTOMER_ID || !process.env.OVERSEAS_TEST_WAREHOUSE_ID)) throw new Error('Resume requires explicit customer and warehouse from the saved run')
|
|
const scope = resume ? {
|
|
merchant: await merchantSessionForTenant(admin.accessToken, process.env.OVERSEAS_TEST_CUSTOMER_ID),
|
|
customerId: process.env.OVERSEAS_TEST_CUSTOMER_ID,
|
|
warehouse: { id: process.env.OVERSEAS_TEST_WAREHOUSE_ID, warehouseCode: process.env.OVERSEAS_TEST_WAREHOUSE_CODE || null },
|
|
} : await findRelabelScope(admin.accessToken, preferredMerchant)
|
|
const { merchant, customerId, warehouse: selected } = scope
|
|
// Do not disable accounts from another acceptance run; cleanup owns only this run's account.
|
|
pda = await createPda(admin.accessToken, id(selected.id), (created) => { pda = created })
|
|
|
|
const relabelReport = join(outputDir, 'relabel.json')
|
|
const fulfillmentReport = join(outputDir, 'fulfillment.json')
|
|
const childEnv = { ...process.env, ADMIN_TEST_GATEWAY: domestic, ADMIN_OVERSEAS_GATEWAY: overseas,
|
|
ADMIN_OVERSEAS_ACCESS_TOKEN: admin.overseasAccessToken, MERCHANT_OVERSEAS_ACCESS_TOKEN: merchant.overseasAccessToken,
|
|
PDA_OVERSEAS_ACCESS_TOKEN: pda.token, OVERSEAS_TEST_CUSTOMER_ID: customerId,
|
|
OVERSEAS_TEST_WAREHOUSE_ID: id(selected.id), OVERSEAS_ACCEPTANCE_OUTPUT: relabelReport,
|
|
OVERSEAS_ACCEPTANCE_SOURCE: relabelReport, OVERSEAS_FULFILLMENT_OUTPUT: fulfillmentReport }
|
|
const scripts = process.env.OVERSEAS_CLOSURE_EXTRA === 'true' ? ['relabel-closure-local-acceptance.mjs'] : process.env.OVERSEAS_RESUME_FULFILLMENT === 'true'
|
|
? ['overseas-relabel-fulfillment-acceptance.mjs']
|
|
: ['overseas-relabel-real-acceptance.mjs', 'overseas-relabel-fulfillment-acceptance.mjs']
|
|
for (const script of scripts) {
|
|
const result = spawnSync(process.execPath, [resolve('scripts', script)], { cwd: resolve('.'), env: childEnv, stdio: 'inherit' })
|
|
if (result.error) throw result.error
|
|
if (result.status !== 0) throw new Error(`${script} failed with exit code ${result.status}`)
|
|
}
|
|
Object.assign(summary, { customerId, warehouseId: id(selected.id), warehouseCode: selected.warehouseCode,
|
|
relabelReport, fulfillmentReport, passed: true, finishedAt: new Date().toISOString() })
|
|
} finally {
|
|
if (admin?.accessToken && pda?.membershipId) {
|
|
try { await api(domestic, admin.accessToken, 'PUT', `/api/platform/v1/iam/users/${pda.membershipId}/status`, { status: 'DISABLED' }); summary.pdaCleanup = 'DISABLED' }
|
|
catch (error) { summary.pdaCleanup = `FAILED: ${error.message}` }
|
|
}
|
|
await writeFile(join(outputDir, 'summary.json'), JSON.stringify(summary, null, 2), 'utf8')
|
|
}
|
|
console.log(JSON.stringify(summary, null, 2))
|