367 lines
11 KiB
JavaScript

export function createAgentRuntime({
store,
journal,
clientFactory,
getPrinters,
jobProcessor,
appVersion = '',
setIntervalImpl = globalThis.setInterval,
clearIntervalImpl = globalThis.clearInterval,
onStateChange = () => {}
}) {
const state = {
running: false,
config: null,
printers: [],
appVersion,
latestReleaseVersion: '',
updateStatusMessage: '',
lastHeartbeatAt: '',
lastClaimAt: '',
lastJobResult: '',
lastError: '',
logLines: []
}
let heartbeatTimer = null
let pollTimer = null
let pollBusy = false
function pushLog(message) {
const line = `${new Date().toLocaleString('zh-CN', { hour12: false })} ${message}`
state.logLines = [line, ...state.logLines].slice(0, 40)
onStateChange({ ...state })
}
async function loadConfig() {
state.config = await store.load()
onStateChange({ ...state })
return state.config
}
async function refreshPrinters() {
state.printers = await getPrinters()
onStateChange({ ...state })
return state.printers
}
function buildLabelProfile() {
return {
paper_size: '60x40mm',
width_mm: 60,
height_mm: 40,
orientation: 'landscape',
scale_mode: 'fit'
}
}
function getClient() {
return clientFactory({
serverUrl: state.config?.serverUrl,
getDeviceToken: () => state.config?.deviceToken || ''
})
}
async function checkForUpdates() {
if (!state.config?.serverUrl) {
state.updateStatusMessage = state.appVersion
? `当前版本 ${state.appVersion}`
: '未配置版本信息'
onStateChange({ ...state })
return null
}
try {
const client = getClient()
const releaseInfo = await client.getPrintAgentReleases()
const latestVersion =
releaseInfo?.latest_version ||
(Array.isArray(releaseInfo?.releases) ? releaseInfo.releases[0]?.version : '') ||
''
state.latestReleaseVersion = latestVersion
state.updateStatusMessage =
latestVersion && latestVersion !== state.appVersion
? `发现新版本 ${latestVersion},可从后台下载更新`
: state.appVersion
? `当前已是最新版本 ${state.appVersion}`
: latestVersion
? `服务器当前版本 ${latestVersion}`
: '暂未读取到版本信息'
onStateChange({ ...state })
return releaseInfo
} catch (error) {
state.updateStatusMessage = '版本检查失败,请稍后重试'
onStateChange({ ...state })
return null
}
}
async function heartbeatOnce() {
if (!state.config) {
await loadConfig()
}
if (!state.config?.deviceToken || !state.config?.terminalId) {
return {
skipped: true,
message: '尚未完成配对'
}
}
const printers = await refreshPrinters()
const client = getClient()
const payload = {
protocolVersion: 2,
terminalId: state.config?.terminalId,
terminalName: state.config?.terminalName,
selectedPrinterName: state.config?.selectedPrinterName,
printerSnapshot: printers,
labelProfile: buildLabelProfile(),
ready:
Boolean(state.config?.deviceToken) && Boolean(state.config?.selectedPrinterName)
}
try {
const response = await client.sendHeartbeat(payload)
state.lastHeartbeatAt = new Date().toISOString()
state.lastError = ''
pushLog(`心跳已发送:${response.message || '服务器已接收'}`)
return response
} catch (error) {
state.lastError = error.message
pushLog(`心跳失败:${error.message}`)
throw error
}
}
async function acknowledgePending(client, pending) {
const status = pending.phase === 'completed_pending_ack' ? 'completed' :
pending.phase === 'failed_pending_ack' ? 'failed' : 'result_unknown'
try {
await client.reportJobStatus(pending.jobId, {
status,
claimToken: pending.claimToken,
errorMessage: pending.errorMessage || (status === 'result_unknown' ? '打印过程曾中断,请人工核实出纸结果' : null)
})
await journal.clear()
pushLog(status === 'completed' ? `任务 ${pending.jobId} 完成结果已确认。` :
`任务 ${pending.jobId} 结果已上报${status === 'result_unknown' ? ',请到系统核实出纸情况' : ''}。`)
} catch (error) {
if (error.status === 409 && typeof client.getJobState === 'function') {
const current = await client.getJobState(pending.jobId)
if (!current || current.id !== pending.jobId || !Object.prototype.hasOwnProperty.call(current, 'claim_token') ||
(current.claim_token !== null && typeof current.claim_token !== 'string') ||
!['queued', 'claimed', 'printing', 'result_unknown', 'completed', 'cancelled', 'failed'].includes(current.status)) {
throw new Error('服务器任务状态响应不完整,保留恢复记录并等待核实')
}
// Do not drop a conflicting acknowledgment without inspecting the server.
// A fenced token or a final result proves this attempt cannot be replayed.
if (current.claim_token !== pending.claimToken || ['completed', 'cancelled', 'failed'].includes(current.status)) {
await journal.clear()
state.lastError = `任务 ${pending.jobId} 已由服务器处理,请核对系统中的结果。`
pushLog(state.lastError)
return
}
}
throw error
}
}
async function pollOnce() {
if (!state.config?.deviceToken || !state.config?.terminalId) {
return null
}
if (pollBusy) return null
pollBusy = true
const client = getClient()
try {
if (!journal) throw new Error('缺少本地打印恢复记录,已阻止自动打印')
const pending = await journal.load()
if (pending) {
if (pending.serverUrl !== state.config.serverUrl || pending.terminalId !== state.config.terminalId) {
throw new Error('上一任务属于其他服务器或终端,请先恢复原配置并核实结果')
}
await acknowledgePending(client, pending)
return null
}
const claimResult = await client.claimNextJob({
terminalId: state.config.terminalId,
protocolVersion: 2
})
state.lastClaimAt = new Date().toISOString()
if (!claimResult?.job) {
return null
}
const job = claimResult.job
if (!job.claim_token) throw new Error('服务器缺少领取凭据,已阻止实际出纸,请更新服务器')
const entry = {
jobId: job.id,
claimToken: job.claim_token,
serverUrl: state.config.serverUrl,
terminalId: state.config.terminalId,
phase: 'claimed'
}
await journal.save(entry)
if (job.job_type !== 'asset_label') {
await journal.save({ ...entry, phase: 'failed_pending_ack', errorMessage: '不支持的打印任务类型,未开始打印' })
await acknowledgePending(client, await journal.load())
return null
}
await client.reportJobStatus(job.id, {
status: 'printing',
claimToken: job.claim_token
})
// Persist intent BEFORE dispatch. A restart here always asks for inspection.
await journal.save({ ...entry, phase: 'dispatching' })
let dispatching = true
let renewalBusy = false
const renewalTimer = setIntervalImpl(async () => {
if (!dispatching || renewalBusy) return
renewalBusy = true
try {
await client.reportJobStatus(job.id, { status: 'printing', claimToken: job.claim_token })
} catch (error) {
pushLog(`任务 ${job.id} 续期失败:${error.message}`)
} finally {
renewalBusy = false
}
}, 30000)
let processingResult
try {
processingResult = await jobProcessor.process(job, {
selectedPrinterName: state.config?.selectedPrinterName
})
} catch (error) {
processingResult = { ok: false, errorMessage: error.message, nextStatus: 'result_unknown' }
} finally {
dispatching = false
clearIntervalImpl(renewalTimer)
}
state.lastJobResult = processingResult.message || processingResult.errorMessage || ''
await journal.save({
...entry,
phase: processingResult.ok ? 'completed_pending_ack' : 'unknown_pending_ack',
errorMessage: processingResult.errorMessage || null
})
// A failed result acknowledgment retains the journal. Next poll retries
// this submission only; it never calls the printer again.
await acknowledgePending(client, await journal.load())
pushLog(
processingResult.ok
? `任务 ${job.id} 已发送到 ${state.config?.selectedPrinterName || '当前打印机'}。`
: `任务 ${job.id} 结果待核实:${processingResult.errorMessage}`
)
return processingResult
} catch (error) {
state.lastError = error.message
pushLog(`领取任务失败:${error.message}`)
throw error
} finally {
pollBusy = false
}
}
async function runTestPrint() {
if (!state.config) {
await loadConfig()
}
const printers = await refreshPrinters()
const result = await jobProcessor.runTestPrint({
selectedPrinterName: state.config?.selectedPrinterName
})
if (state.config?.deviceToken && state.config?.terminalId) {
try {
const client = getClient()
await client.requestTestPrint({
selectedPrinterName: state.config?.selectedPrinterName,
printerSnapshot: printers,
labelProfile: buildLabelProfile(),
success: result.ok,
errorMessage: result.ok ? null : result.message
})
} catch (error) {
state.lastError = error.message
pushLog(`测试打印结果回写失败:${error.message}`)
}
}
state.lastJobResult = result.message
pushLog(result.message)
return result
}
async function updateConfig(patch) {
state.config = await store.save(patch)
onStateChange({ ...state })
return state.config
}
async function start() {
if (heartbeatTimer) {
clearIntervalImpl(heartbeatTimer)
heartbeatTimer = null
}
if (pollTimer) {
clearIntervalImpl(pollTimer)
pollTimer = null
}
const config = await loadConfig()
await refreshPrinters()
await checkForUpdates()
state.running = true
onStateChange({ ...state })
await heartbeatOnce().catch(() => {})
await pollOnce().catch(() => {})
heartbeatTimer = setIntervalImpl(() => {
heartbeatOnce().catch(() => {})
}, config.heartbeatIntervalMs)
pollTimer = setIntervalImpl(() => {
pollOnce().catch(() => {})
}, config.pollIntervalMs)
}
function stop() {
state.running = false
if (heartbeatTimer) {
clearIntervalImpl(heartbeatTimer)
heartbeatTimer = null
}
if (pollTimer) {
clearIntervalImpl(pollTimer)
pollTimer = null
}
onStateChange({ ...state })
}
return {
getState() {
return { ...state }
},
loadConfig,
refreshPrinters,
checkForUpdates,
heartbeatOnce,
pollOnce,
runTestPrint,
updateConfig,
start,
stop
}
}