gongxiangp2/lib/remote-upload-sync.ts
chenpei 9cd3c0f231 feat: 任务详情无条码开关 + 上传面板信息密度优化 + 历史导入/脚本补全
- 任务详情上传支持"本批素材无条码"开关,跳过条码必填校验
- SKU卡片/主图网格从4列改5列,图片高度压缩,去掉冗余标签和提示
- 详情图/PSD上传区去掉大占位框,改成紧凑按钮+标签
- 补充历史导入相关脚本和工具文件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 10:01:08 +08:00

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}`,
)
}