367 lines
11 KiB
JavaScript
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
|
|
}
|
|
}
|