- 任务详情上传支持"本批素材无条码"开关,跳过条码必填校验 - SKU卡片/主图网格从4列改5列,图片高度压缩,去掉冗余标签和提示 - 详情图/PSD上传区去掉大占位框,改成紧凑按钮+标签 - 补充历史导入相关脚本和工具文件 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
215 lines
5.8 KiB
TypeScript
215 lines
5.8 KiB
TypeScript
import path from 'path'
|
|
import { execFile } from 'child_process'
|
|
import { stat } from 'fs/promises'
|
|
import { promisify } from 'util'
|
|
|
|
const execFileAsync = promisify(execFile)
|
|
const REMOTE_SYNC_SSH_TIMEOUT_MS = Number(process.env.REMOTE_SYNC_SSH_TIMEOUT_MS || 30000)
|
|
const REMOTE_SYNC_SCP_TIMEOUT_MS = Number(process.env.REMOTE_SYNC_SCP_TIMEOUT_MS || 7200000)
|
|
const REMOTE_SYNC_RETRY_COUNT = Number(process.env.REMOTE_SYNC_RETRY_COUNT || 3)
|
|
const SSH_NON_INTERACTIVE_ARGS = [
|
|
'-o',
|
|
'BatchMode=yes',
|
|
'-o',
|
|
'ConnectTimeout=15',
|
|
'-o',
|
|
'ServerAliveInterval=15',
|
|
'-o',
|
|
'ServerAliveCountMax=2',
|
|
]
|
|
|
|
interface RemoteUploadSyncConfig {
|
|
target: string
|
|
uploadRoot: string
|
|
}
|
|
|
|
function cleanEnv(value: string | undefined) {
|
|
return String(value || '').trim()
|
|
}
|
|
|
|
function shellQuote(value: string) {
|
|
return `'${value.replace(/'/g, `'\\''`)}'`
|
|
}
|
|
|
|
function normalizeRemotePath(value: string) {
|
|
return value.replace(/\\/g, '/').replace(/\/+$/, '')
|
|
}
|
|
|
|
function sleep(ms: number) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
}
|
|
|
|
async function execFileWithRetry(
|
|
file: string,
|
|
args: string[],
|
|
options: { timeout: number },
|
|
label: string,
|
|
) {
|
|
let lastError: unknown
|
|
for (let attempt = 1; attempt <= REMOTE_SYNC_RETRY_COUNT; attempt += 1) {
|
|
try {
|
|
return await execFileAsync(file, args, options)
|
|
} catch (error) {
|
|
lastError = error
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
console.warn(
|
|
`[RemoteUploadSync] ${label} 失败 ${attempt}/${REMOTE_SYNC_RETRY_COUNT}: ${message}`,
|
|
)
|
|
if (attempt < REMOTE_SYNC_RETRY_COUNT) {
|
|
await sleep(1000 * attempt)
|
|
}
|
|
}
|
|
}
|
|
|
|
throw lastError
|
|
}
|
|
|
|
function relativeToUploadRoot(storedPath: string) {
|
|
const normalized = String(storedPath || '').replace(/\\/g, '/')
|
|
const relative = normalized.replace(/^\.?\/*uploads\//i, '')
|
|
|
|
if (!relative || relative === normalized && path.posix.isAbsolute(normalized)) {
|
|
throw new Error(`远程同步路径无效: ${storedPath}`)
|
|
}
|
|
|
|
if (relative.split('/').some((segment) => segment === '..')) {
|
|
throw new Error(`远程同步路径不能包含上级目录: ${storedPath}`)
|
|
}
|
|
|
|
return relative
|
|
}
|
|
|
|
export function getRemoteUploadSyncConfig(): RemoteUploadSyncConfig | null {
|
|
const target = cleanEnv(
|
|
process.env.HISTORY_IMPORT_REMOTE_UPLOADS_HOST || process.env.UPLOAD_REMOTE_SYNC_HOST,
|
|
)
|
|
const uploadRoot = cleanEnv(
|
|
process.env.HISTORY_IMPORT_REMOTE_UPLOADS_DIR || process.env.UPLOAD_REMOTE_SYNC_ROOT,
|
|
)
|
|
|
|
if (!target && !uploadRoot) {
|
|
return null
|
|
}
|
|
|
|
if (!target || !uploadRoot) {
|
|
throw new Error(
|
|
'远程 uploads 同步配置不完整:需要同时配置 HISTORY_IMPORT_REMOTE_UPLOADS_HOST 和 HISTORY_IMPORT_REMOTE_UPLOADS_DIR',
|
|
)
|
|
}
|
|
|
|
return {
|
|
target,
|
|
uploadRoot: normalizeRemotePath(uploadRoot),
|
|
}
|
|
}
|
|
|
|
export function isRemoteUploadSyncEnabled() {
|
|
return Boolean(getRemoteUploadSyncConfig())
|
|
}
|
|
|
|
export function getRemoteUploadPath(storedPath: string) {
|
|
const config = getRemoteUploadSyncConfig()
|
|
if (!config) {
|
|
return null
|
|
}
|
|
|
|
return path.posix.join(config.uploadRoot, relativeToUploadRoot(storedPath))
|
|
}
|
|
|
|
export async function assertRemoteUploadSyncAccess() {
|
|
const config = getRemoteUploadSyncConfig()
|
|
if (!config) {
|
|
return
|
|
}
|
|
|
|
const materialsDir = path.posix.join(config.uploadRoot, 'materials')
|
|
await execFileWithRetry(
|
|
'ssh',
|
|
[
|
|
...SSH_NON_INTERACTIVE_ARGS,
|
|
config.target,
|
|
`test -d ${shellQuote(materialsDir)} && test -w ${shellQuote(materialsDir)}`,
|
|
],
|
|
{ timeout: REMOTE_SYNC_SSH_TIMEOUT_MS },
|
|
'检查远程 uploads 权限',
|
|
)
|
|
}
|
|
|
|
export async function syncStoredUploadFileToRemote(
|
|
localFilePath: string,
|
|
storedPath: string,
|
|
expectedSize?: number,
|
|
) {
|
|
const config = getRemoteUploadSyncConfig()
|
|
if (!config) {
|
|
return null
|
|
}
|
|
|
|
const remotePath = getRemoteUploadPath(storedPath)
|
|
if (!remotePath) {
|
|
return null
|
|
}
|
|
|
|
const localStat = await stat(localFilePath)
|
|
const expectedFileSize = expectedSize || localStat.size
|
|
const remoteDir = path.posix.dirname(remotePath)
|
|
const sizeMiB = (expectedFileSize / 1024 / 1024).toFixed(2)
|
|
|
|
console.log(
|
|
`[RemoteUploadSync] 准备同步 ${storedPath} -> ${config.target}:${remotePath} (${sizeMiB} MiB)`,
|
|
)
|
|
await execFileWithRetry(
|
|
'ssh',
|
|
[...SSH_NON_INTERACTIVE_ARGS, config.target, `mkdir -p ${shellQuote(remoteDir)}`],
|
|
{ timeout: REMOTE_SYNC_SSH_TIMEOUT_MS },
|
|
`创建远程目录 ${remoteDir}`,
|
|
)
|
|
console.log(`[RemoteUploadSync] 开始复制 ${storedPath} (${sizeMiB} MiB)`)
|
|
await execFileWithRetry(
|
|
'scp',
|
|
['-q', ...SSH_NON_INTERACTIVE_ARGS, localFilePath, `${config.target}:${remotePath}`],
|
|
{ timeout: REMOTE_SYNC_SCP_TIMEOUT_MS },
|
|
`复制 ${storedPath}`,
|
|
)
|
|
|
|
const { stdout } = await execFileWithRetry(
|
|
'ssh',
|
|
[
|
|
...SSH_NON_INTERACTIVE_ARGS,
|
|
config.target,
|
|
`test -f ${shellQuote(remotePath)} && stat -c %s ${shellQuote(remotePath)}`,
|
|
],
|
|
{ timeout: REMOTE_SYNC_SSH_TIMEOUT_MS },
|
|
`校验远程文件 ${remotePath}`,
|
|
)
|
|
const remoteSize = Number(String(stdout || '').trim())
|
|
|
|
if (remoteSize !== expectedFileSize) {
|
|
throw new Error(
|
|
`远程 uploads 同步校验失败: ${storedPath} 本地=${expectedFileSize} 远程=${remoteSize || 'unknown'}`,
|
|
)
|
|
}
|
|
|
|
console.log(`[RemoteUploadSync] ${storedPath} -> ${config.target}:${remotePath} (${remoteSize} bytes)`)
|
|
return remotePath
|
|
}
|
|
|
|
export async function deleteStoredUploadFileFromRemote(storedPath: string) {
|
|
const config = getRemoteUploadSyncConfig()
|
|
if (!config) {
|
|
return
|
|
}
|
|
|
|
const remotePath = getRemoteUploadPath(storedPath)
|
|
if (!remotePath) {
|
|
return
|
|
}
|
|
|
|
await execFileWithRetry(
|
|
'ssh',
|
|
[...SSH_NON_INTERACTIVE_ARGS, config.target, `rm -f -- ${shellQuote(remotePath)}`],
|
|
{ timeout: REMOTE_SYNC_SSH_TIMEOUT_MS },
|
|
`删除远程文件 ${remotePath}`,
|
|
)
|
|
}
|