fix: restore legacy print agent compatibility safely

This commit is contained in:
WuYongJie 2026-10-09 14:44:18 +08:00
parent 8cfeb1c6b1
commit b6751bb8cb
7 changed files with 357 additions and 25 deletions

View File

@ -32,9 +32,8 @@ test('打印领取恢复在独立MySQL库中的并发与幂等验证', { skip: !
items: [], rendered_labels: [{ equipment_id: 1, image_data_url: PNG_ONE }] }), ...data
} })
try {
await t.test('旧助手缺协议能力时不能领取,两个新请求只有一个领取成功', async () => {
await t.test('两个新请求并发仅一个领取成功,v2不能无token完成', async () => {
const job = await createJob()
await assert.rejects(claimNextPrintJob(terminal.id), /更新打印助手/)
const claims = await Promise.all([claimNextPrintJob(terminal.id, 2), claimNextPrintJob(terminal.id, 2)])
const winners = claims.filter(claim => claim.job)
assert.equal(winners.length, 1)
@ -43,6 +42,46 @@ test('打印领取恢复在独立MySQL库中的并发与幂等验证', { skip: !
const stored = await prisma.printJob.findUniqueOrThrow({ where: { id: job.id } })
assert.equal(stored.attempt_count, 1)
assert.ok(stored.lease_expires_at && stored.lease_expires_at > new Date())
await prisma.printTerminal.update({ where: { id: terminal.id }, data: { protocol_version: 1 } })
await assert.rejects(updateClaimedPrintJobStatus(terminal.id, job.id, { status: 'completed' }), /缺少领取凭据/)
await updateClaimedPrintJobStatus(terminal.id, job.id, { status: 'completed', claim_token: stored.claim_token! })
})
for (const version of [undefined, 1]) await t.test(`旧协议${version ?? '缺省'}无token正常领取打印完成`, async () => {
const job = await createJob()
const claim = await claimNextPrintJob(terminal.id, version)
assert.equal(claim.job?.id, job.id)
assert.match(claim.job!.claim_token!, /^legacy-v1:/)
await assert.rejects(updateClaimedPrintJobStatus(terminal.id + 100000, job.id, { status: 'completed' }), /不属于/)
await assert.rejects(updateClaimedPrintJobStatus(terminal.id, job.id, { status: 'completed', claim_token: 'wrong' }), /凭据/)
await updateClaimedPrintJobStatus(terminal.id, job.id, { status: 'printing' })
await Promise.all([1, 2].map(() => updateClaimedPrintJobStatus(terminal.id, job.id, { status: 'completed' })))
await assert.rejects(updateClaimedPrintJobStatus(terminal.id, job.id, { status: 'failed' }), /结束/)
assert.equal((await prisma.printJob.findUniqueOrThrow({ where: { id: job.id } })).status, 'completed')
})
await t.test('多个排队单的旧版并发轮询仅领取一个,超时阻止新领取且晚到完成接收', async () => {
const first = await createJob(); const second = await createJob()
const claims = await Promise.all([claimNextPrintJob(terminal.id), claimNextPrintJob(terminal.id, 1)])
assert.equal(claims.filter(claim => claim.job).length, 1)
assert.equal(claims.find(claim => claim.job)!.job!.id, first.id)
await prisma.printJob.update({ where: { id: first.id }, data: { lease_expires_at: new Date(Date.now() - 1) } })
assert.equal((await claimNextPrintJob(terminal.id)).job, null)
await assert.rejects(updateClaimedPrintJobStatus(terminal.id, first.id, { status: 'printing' }), /不能更新/)
await updateClaimedPrintJobStatus(terminal.id, first.id, { status: 'completed' })
assert.equal((await claimNextPrintJob(terminal.id)).job?.id, second.id)
await updateClaimedPrintJobStatus(terminal.id, second.id, { status: 'completed' })
})
await t.test('旧任务失败不自动重印,人工重派复制新ID,旧回调不污染新单', async () => {
const job = await createJob(); const claim = await claimNextPrintJob(terminal.id)
await updateClaimedPrintJobStatus(terminal.id, job.id, { status: 'printing' })
await updateClaimedPrintJobStatus(terminal.id, job.id, { status: 'failed', retryable: true })
const results = await Promise.allSettled([1, 2].map(() => resolveUnknownPrintJob(job.id, user.id,
{ confirmed: true, outcome: 'not_printed', expected_claim_token: claim.job!.claim_token! })))
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1)
const replacement = await claimNextPrintJob(terminal.id)
assert.ok(replacement.job && replacement.job.id !== job.id)
assert.equal((await prisma.printJob.findUniqueOrThrow({ where: { id: job.id } })).status, 'cancelled')
await assert.rejects(updateClaimedPrintJobStatus(terminal.id, job.id, { status: 'completed' }), /结束/)
await updateClaimedPrintJobStatus(terminal.id, replacement.job!.id, { status: 'completed' })
})
await t.test('旧token不能回写;同token完成重复提交成功,晚到续期不能倒退', async () => {
const job = await createJob({ status: 'printing', claim_token: suffix, claimed_by_terminal_id: terminal.id,
@ -103,7 +142,7 @@ test('打印领取恢复在独立MySQL库中的并发与幂等验证', { skip: !
assert.ok(final.claim_token)
assert.equal(final.lease_expires_at, null)
})
await t.test('待核实保留PNG,人工确认未出纸后原图重派,完成后才清理', async () => {
await t.test('旧任务待核实保留PNG,新ID重派保留原图,完成后才清理', async () => {
const root = path.resolve(__dirname, '../../../uploads/print-jobs')
const batch = `test-${randomUUID()}`
const directory = path.resolve(root, batch)
@ -113,23 +152,24 @@ test('打印领取恢复在独立MySQL库中的并发与幂等验证', { skip: !
await fs.mkdir(directory, { recursive: true })
await fs.writeFile(imagePath, makePng())
try {
const token = `${suffix}-image`
const token = `legacy-v1:${randomUUID()}`
const job = await createJob({ status: 'result_unknown', claim_token: token, claimed_by_terminal_id: terminal.id,
payload_json: JSON.stringify({ job_type: 'asset_label', items: [], equipment_ids: [1],
rendered_label_files: [{ equipment_id: 1, storage_key: `${batch}/label.png`, content_type: 'image/png' }] }) })
await updateClaimedPrintJobStatus(terminal.id, job.id, { status: 'result_unknown', claim_token: token })
await fs.access(imagePath)
await resolveUnknownPrintJob(job.id, user.id, { confirmed: true, outcome: 'not_printed', expected_claim_token: token })
const replacement = await resolveUnknownPrintJob(job.id, user.id, { confirmed: true, outcome: 'not_printed', expected_claim_token: token })
assert.notEqual(replacement.id, job.id)
await fs.access(imagePath)
const claim = await claimNextPrintJob(terminal.id, 2)
assert.equal(claim.job?.id, job.id)
assert.equal(claim.job?.id, replacement.id)
assert.ok(claim.job?.claim_token && claim.job.claim_token !== token)
const payload = claim.job?.payload
assert.ok(payload && 'rendered_labels' in payload)
assert.equal(payload.rendered_labels?.[0].image_data_url, PNG_ONE)
await assert.rejects(updateClaimedPrintJobStatus(terminal.id, job.id, { status: 'completed', claim_token: token }), /凭据/)
await assert.rejects(updateClaimedPrintJobStatus(terminal.id, job.id, { status: 'completed' }), /结束/)
await fs.access(imagePath)
await updateClaimedPrintJobStatus(terminal.id, job.id, { status: 'completed', claim_token: claim.job!.claim_token! })
await updateClaimedPrintJobStatus(terminal.id, replacement.id, { status: 'completed', claim_token: claim.job!.claim_token! })
await assert.rejects(fs.access(imagePath))
} finally { await fs.rm(directory, { recursive: true, force: true }) }
})

View File

@ -0,0 +1,171 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import prisma from '../../utils/prisma'
import { claimNextPrintJob, resolveUnknownPrintJob, updateClaimedPrintJobStatus } from '../printService'
import { claimPrintAgentJob, updatePrintAgentJobStatus } from '../../controllers/printController'
import { PNG_ONE } from './printFixture'
// No database connection: model the state predicates and transaction lock, and
// separately exercise the same races in the opt-in real MySQL test.
test('旧打印助手兼容保持领取隔离与状态幂等', async (t) => {
const terminal: any = { id: 7, name: 'Legacy test', status: 'online', protocol_version: 1,
last_seen_at: new Date(), selected_printer_name: 'Test printer',
label_profile_json: JSON.stringify({ paper_size: '60x40mm', width_mm: 60, height_mm: 40 }) }
let jobs: any[] = []
let nextId = 1
let locks = 0
const addJob = (data: any = {}) => {
const job = { id: nextId++, status: 'queued', attempt_count: 0, claim_token: null, claimed_at: null,
started_at: null, lease_expires_at: null, job_type: 'asset_label', target_terminal_id: 7,
claimed_by_terminal_id: null, requested_by: 3,
payload_json: JSON.stringify({ job_type: 'asset_label', equipment_ids: [1], items: [],
rendered_labels: [{ equipment_id: 1, image_data_url: PNG_ONE }] }), ...data }
jobs.push(job)
return job
}
const matches = (job: any, where: any): boolean => Object.entries(where).every(([key, value]: any) => {
if (key === 'OR') return value.some((condition: any) => matches(job, condition))
if (value && typeof value === 'object' && !(value instanceof Date)) {
if (value.in) return value.in.includes(job[key])
if (value.gt) return job[key] != null && job[key] > value.gt
if (value.lte) return job[key] != null && job[key] <= value.lte
}
return job[key] === value
})
const read = (job: any) => job && ({ ...job, targetTerminal: terminal,
claimedByTerminal: { id: 7, name: 'Legacy test' }, requester: { id: 3, username: 'test', name: 'Test' } })
const originals = {
terminalFind: prisma.printTerminal.findUnique, find: prisma.printJob.findUnique,
findOrThrow: prisma.printJob.findUniqueOrThrow, first: prisma.printJob.findFirst,
update: prisma.printJob.updateMany, create: prisma.printJob.create, transaction: prisma.$transaction
}
const jobDelegate: any = {
findUnique: async ({ where }: any) => read(jobs.find(job => job.id === where.id)),
findUniqueOrThrow: async ({ where }: any) => read(jobs.find(job => job.id === where.id)),
findFirst: async ({ where }: any) => read(jobs.find(job => matches(job, where))),
updateMany: async ({ where, data }: any) => {
const selected = jobs.filter(job => matches(job, where))
for (const job of selected) for (const [key, value] of Object.entries(data) as any) {
job[key] = value && typeof value === 'object' && value.increment ? job[key] + value.increment : value
}
return { count: selected.length }
},
create: async ({ data }: any) => read(addJob(data))
}
Object.assign(prisma.printJob, jobDelegate)
;(prisma.printTerminal.findUnique as any) = async () => terminal
let queue = Promise.resolve()
;(prisma.$transaction as any) = (fn: any) => {
const run = queue.then(() => fn({ printJob: jobDelegate, $queryRaw: async () => { locks++; return [{ id: 7 }] } }))
queue = run.then(() => undefined, () => undefined)
return run
}
const reset = () => { jobs = []; nextId = 1; locks = 0 }
try {
for (const version of [undefined, 1]) await t.test(`协议${version ?? '缺省'}完成正常无token链`, async () => {
reset(); const job = addJob()
const claimed = await claimNextPrintJob(7, version)
assert.equal(claimed.terminal.readiness.is_ready, true)
assert.equal(claimed.job?.id, job.id)
assert.match(claimed.job!.claim_token!, /^legacy-v1:/)
await updateClaimedPrintJobStatus(7, job.id, { status: 'printing' })
await Promise.all([updateClaimedPrintJobStatus(7, job.id, { status: 'completed' }),
updateClaimedPrintJobStatus(7, job.id, { status: 'completed' })])
assert.equal(job.status, 'completed')
await updateClaimedPrintJobStatus(7, job.id, { status: 'completed' })
for (const status of ['printing', 'failed'] as const) {
await assert.rejects(updateClaimedPrintJobStatus(7, job.id, { status }), /结束/)
}
assert.equal((await claimNextPrintJob(7, version)).job, null)
})
await t.test('同终端多个排队单的并发领取仅发出一个,完成后才能领下一个', async () => {
reset(); addJob(); addJob()
const results = await Promise.all([claimNextPrintJob(7), claimNextPrintJob(7, 1)])
assert.equal(results.filter(result => result.job).length, 1)
assert.equal(locks, 2)
const winner = results.find(result => result.job)!.job!
await updateClaimedPrintJobStatus(7, winner.id, { status: 'completed' })
assert.equal((await claimNextPrintJob(7)).job?.id, 2)
})
await t.test('v2领取不能被heartbeat降级、缺token或报告协议降级绕过', async () => {
reset(); const job = addJob(); const claim = await claimNextPrintJob(7, 2)
assert.doesNotMatch(claim.job!.claim_token!, /^legacy/)
terminal.protocol_version = 1
await assert.rejects(updateClaimedPrintJobStatus(7, job.id, { status: 'completed', protocol_version: 1 } as any), /缺少领取凭据/)
assert.equal(job.status, 'claimed')
await updateClaimedPrintJobStatus(7, job.id, { status: 'completed', claim_token: claim.job!.claim_token! })
})
await t.test('旧任务的显式错误/空token仍拒绝,跨终端及未领取旧任务拒绝', async () => {
reset(); const job = addJob(); await claimNextPrintJob(7)
for (const token of ['wrong', '', null]) await assert.rejects(updateClaimedPrintJobStatus(7, job.id,
{ status: 'completed', claim_token: token } as any), /凭据/)
await assert.rejects(updateClaimedPrintJobStatus(8, job.id, { status: 'completed' }), /不属于/)
job.claimed_by_terminal_id = 8
await assert.rejects(updateClaimedPrintJobStatus(7, job.id, { status: 'completed' }), /凭据/)
const unclaimed = addJob()
await assert.rejects(updateClaimedPrintJobStatus(7, unclaimed.id, { status: 'completed' }), /凭据/)
})
await t.test('旧任务超时阻止继续领取,晚到printing不续期,晚到completed接收', async () => {
reset(); const job = addJob(); await claimNextPrintJob(7)
job.lease_expires_at = new Date(Date.now() - 1); addJob()
assert.equal((await claimNextPrintJob(7)).job, null)
assert.equal(job.status, 'result_unknown')
await assert.rejects(updateClaimedPrintJobStatus(7, job.id, { status: 'printing' }), /不能更新/)
await updateClaimedPrintJobStatus(7, job.id, { status: 'completed' })
assert.equal((await claimNextPrintJob(7)).job?.id, 2)
})
await t.test('旧助手failed在开印前失败,开印后待核实,不自动重派', async () => {
reset(); const job = addJob(); await claimNextPrintJob(7)
await updateClaimedPrintJobStatus(7, job.id, { status: 'failed', retryable: true })
assert.equal(job.status, 'failed')
await assert.rejects(updateClaimedPrintJobStatus(7, job.id, { status: 'completed' }), /结束/)
const second = addJob(); await claimNextPrintJob(7)
await updateClaimedPrintJobStatus(7, second.id, { status: 'printing' })
await updateClaimedPrintJobStatus(7, second.id, { status: 'failed', retryable: true })
assert.equal(second.status, 'result_unknown')
addJob(); assert.equal((await claimNextPrintJob(7)).job, null)
})
await t.test('人工重派旧任务创建新ID,旧无token回报不能修改重派状态', async () => {
reset(); const job = addJob(); await claimNextPrintJob(7)
job.lease_expires_at = new Date(Date.now() - 1)
const decisions = await Promise.allSettled([1, 2].map(() => resolveUnknownPrintJob(job.id, 3,
{ confirmed: true, outcome: 'not_printed', expected_claim_token: job.claim_token })))
assert.equal(decisions.filter(result => result.status === 'fulfilled').length, 1)
assert.equal(jobs.length, 2); assert.equal(job.status, 'cancelled')
const replacement = await claimNextPrintJob(7)
assert.equal(replacement.job?.id, 2)
assert.notEqual(replacement.job?.claim_token, job.claim_token)
assert.equal(jobs[1].payload_json, job.payload_json)
await assert.rejects(updateClaimedPrintJobStatus(7, job.id, { status: 'completed' }), /结束/)
assert.equal(jobs[1].status, 'claimed')
await updateClaimedPrintJobStatus(7, 2, { status: 'completed' })
})
await t.test('重新排队的旧ID不能由legacy重新领取,v2可按新token安全领取', async () => {
reset(); addJob({ attempt_count: 1 })
assert.equal((await claimNextPrintJob(7)).job, null)
assert.ok((await claimNextPrintJob(7, 2)).job)
})
await t.test('控制器按认证终端而非请求terminal_id认领并允许缺省协议回报', async () => {
reset(); addJob()
const response: any = { locals: { printTerminal: { id: 7 } }, status() { return this }, json(body: any) { this.body = body; return this } }
await claimPrintAgentJob({ body: { terminal_id: 8 } } as any, response)
assert.equal(jobs[0].claimed_by_terminal_id, 7)
await updatePrintAgentJobStatus({ params: { id: '1' }, body: { status: 'printing' } } as any, response)
await updatePrintAgentJobStatus({ params: { id: '1' }, body: { status: 'completed' } } as any, response)
assert.equal(jobs[0].status, 'completed')
})
await t.test('未知协议拒绝而非隐式降级', async () => {
reset(); addJob()
for (const version of [0, 3, null, '2']) await assert.rejects(claimNextPrintJob(7, version as any), /协议版本无效/)
assert.equal(jobs[0].status, 'queued')
})
} finally {
prisma.printTerminal.findUnique = originals.terminalFind
prisma.printJob.findUnique = originals.find
prisma.printJob.findUniqueOrThrow = originals.findOrThrow
prisma.printJob.findFirst = originals.first
prisma.printJob.updateMany = originals.update
prisma.printJob.create = originals.create
prisma.$transaction = originals.transaction
}
})

View File

@ -102,7 +102,7 @@ export interface PrintHeartbeatInput {
export interface PrintJobStatusUpdateInput {
status: 'printing' | 'completed' | 'failed' | 'result_unknown'
claim_token: string
claim_token?: string
error_message?: string | null
retryable?: boolean
}
@ -147,6 +147,9 @@ const PRINT_TERMINAL_STALE_MS = 90_000
const PRINT_PAIR_CODE_TTL_MINUTES = 10
const MAX_PRINT_JOB_LIST_PAGE_SIZE = 50
export const PRINT_CLAIM_LEASE_MS = 120_000
const LEGACY_CLAIM_PREFIX = 'legacy-v1:'
const isLegacyPrintClaim = (token: string | null) =>
typeof token === 'string' && /^legacy-v1:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(token)
const PAIR_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
const PNG_DATA_URL_PREFIX = 'data:image/png;base64,'
const PNG_DATA_URL_PATTERN = /^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/i
@ -1653,9 +1656,10 @@ export const expireStalePrintClaims = async (now = new Date()) => {
}
export const claimNextPrintJob = async (terminalId: number, protocolVersion?: number) => {
if (protocolVersion !== 2) {
throw new PrintServiceError('请先更新打印助手,再领取打印任务', 426)
if (protocolVersion !== undefined && protocolVersion !== 1 && protocolVersion !== 2) {
throw new PrintServiceError('打印助手协议版本无效', 422)
}
const legacy = protocolVersion !== 2
await expireStalePrintClaims()
const terminal = await getPrintTerminalById(terminalId)
const terminalView = mapTerminalRecord({
@ -1672,10 +1676,22 @@ export const claimNextPrintJob = async (terminalId: number, protocolVersion?: nu
}
const claimedJob = await prisma.$transaction(async (tx) => {
// Old Electron polls can overlap. Serialize claims for this authenticated
// terminal so two different queued jobs cannot be handed out concurrently.
await tx.$queryRaw`SELECT id FROM print_terminal WHERE id = ${terminalId} FOR UPDATE`
const pending = await tx.printJob.findFirst({
where: {
target_terminal_id: terminalId,
status: { in: legacy ? ['claimed', 'printing', 'result_unknown'] : ['claimed', 'printing'] }
}
})
if (pending) return null
const nextJob = await tx.printJob.findFirst({
where: {
target_terminal_id: terminalId,
status: PRINT_JOB_STATUS.queued
status: PRINT_JOB_STATUS.queued,
// A tokenless old callback cannot distinguish two claims of one ID.
...(legacy ? { attempt_count: 0, claim_token: null, claimed_at: null } : {})
},
orderBy: [{ created_at: 'asc' }, { id: 'asc' }]
})
@ -1694,7 +1710,7 @@ export const claimNextPrintJob = async (terminalId: number, protocolVersion?: nu
status: PRINT_JOB_STATUS.claimed,
claimed_by_terminal_id: terminalId,
claimed_at: new Date(),
claim_token: randomUUID(),
claim_token: legacy ? `${LEGACY_CLAIM_PREFIX}${randomUUID()}` : randomUUID(),
lease_expires_at: new Date(Date.now() + PRINT_CLAIM_LEASE_MS),
result_confirmed_by: null,
result_confirmed_at: null,
@ -1724,7 +1740,7 @@ export const claimNextPrintJob = async (terminalId: number, protocolVersion?: nu
}
}
})
})
}, { isolationLevel: 'ReadCommitted' })
if (!claimedJob) {
return {
@ -1778,8 +1794,9 @@ export const updateClaimedPrintJobStatus = async (
if (!(['printing', 'completed', 'failed', 'result_unknown'] as string[]).includes(input.status)) {
throw new PrintServiceError('打印任务状态无效', 422)
}
const claimToken = sanitizeText(input.claim_token)
if (!claimToken) throw new PrintServiceError('缺少领取凭据,请更新打印助手', 422)
const tokenProvided = input.claim_token !== undefined
const providedToken = sanitizeText(input.claim_token)
if (tokenProvided && !providedToken) throw new PrintServiceError('领取凭据无效', 422)
await expireStalePrintClaims()
const existing = await prisma.printJob.findUnique({
where: {
@ -1806,6 +1823,13 @@ export const updateClaimedPrintJobStatus = async (
throw new PrintServiceError('该任务不属于当前打印终端', 403)
}
// Compatibility belongs to this server-issued claim, never to a mutable
// heartbeat version or the protocol declared in a result callback.
if (!tokenProvided && !isLegacyPrintClaim(existing.claim_token)) {
throw new PrintServiceError('缺少领取凭据,请更新打印助手', 422)
}
const claimToken = tokenProvided ? providedToken! : existing.claim_token!
if (existing.claimed_by_terminal_id !== terminalId || existing.claim_token !== claimToken) {
throw new PrintServiceError('领取凭据已失效,请核实任务结果', 409)
}
@ -1936,6 +1960,26 @@ export const resolveUnknownPrintJob = async (
if (!existing) throw new PrintServiceError('打印任务不存在', 404)
const now = new Date()
const outcome = input.outcome
if (outcome === 'not_printed' && isLegacyPrintClaim(existing.claim_token)) {
// Keep the retired claim's marker: old tokenless callbacks for this ID must
// never reach the replacement. The PNG payload belongs to the new queued ID.
return prisma.$transaction(async (tx) => {
const result = await tx.printJob.updateMany({
where: { id: jobId, status: PRINT_JOB_STATUS.resultUnknown, claim_token: input.expected_claim_token },
data: {
status: PRINT_JOB_STATUS.cancelled, lease_expires_at: null,
result_confirmed_by: actorId, result_confirmed_at: now,
error_message: '管理员已核实未出纸,已创建新的打印任务'
}
})
if (result.count !== 1) throw new PrintServiceError('任务状态已变化,请刷新并重新核实', 409)
const replacement = await tx.printJob.create({ data: {
job_type: existing.job_type, target_terminal_id: existing.target_terminal_id,
requested_by: existing.requested_by, payload_json: existing.payload_json
} })
return { id: replacement.id, original_job_id: jobId, status: PRINT_JOB_STATUS.queued }
})
}
const nextStatus = outcome === 'printed' ? PRINT_JOB_STATUS.completed :
outcome === 'not_printed' ? PRINT_JOB_STATUS.queued : PRINT_JOB_STATUS.cancelled
const result = await prisma.printJob.updateMany({

View File

@ -0,0 +1,13 @@
# 旧版打印助手兼容修复
旧版 .NET 和 Electron 助手不发送 `protocol_version` 与 `claim_token`。强制新版协议导致已安装的助手无法领取任务,本次恢复缺省协议及协议 1 的正常领取、打印中和完成回报,无需用户先升级电脑上的助手。
- 领取时由服务端生成带 `legacy-v1:` 标记的唯一凭据,仅允许该任务的已认证领取终端省略回报凭据;新版任务继续严格验证凭据,心跳版本变化不能降级任务校验。
- 终端行锁与事务内忙碌检查避免旧 Electron 并行轮询同时领到多个任务。
- 旧助手只领取从未领取过的新任务编号。人工核实旧任务未出纸后,在同一事务内关闭旧编号并创建新编号,防止晚到回报污染重新打印的任务。
- 已完成任务不能被晚到失败回报覆盖;超时或打印开始后的失败仍进入待核实状态,不自动重新出纸,图片保留到结果确认。
- 界面取消强制升级门禁,升级提示改为提高中断恢复可靠性的建议。
验证:前后端构建通过;后端全量测试 297 项通过、2 项隔离数据库测试默认跳过;独立本机 MySQL 打印集成测试 14 项全部通过;前端打印相关回归 14 项全部通过。集成测试包含旧协议无凭据完整流程、并发领取、跨终端拒绝、现代任务不可降级、过期结果、人工重派的新旧编号隔离及图片保留。
本次不修改数据库结构、认证策略、发布配置或打印助手安装包。生产部署和真实助手请求验收结果见本次部署回执。

View File

@ -0,0 +1,68 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import ts from 'typescript'
const barcodeSource = fs.readFileSync(new URL('../src/components/BarcodePrint.vue', import.meta.url), 'utf8')
const terminalSource = fs.readFileSync(new URL('../src/views/settings/components/PrintTerminalManagement.vue', import.meta.url), 'utf8')
const extract = (source, name, nextName, bindings = {}) => {
const start = source.indexOf(`const ${name} =`)
const end = source.indexOf(`const ${nextName} =`, start)
assert.ok(start >= 0 && end > start)
const executable = ts.transpileModule(source.slice(start, end), { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText
return new Function(...Object.keys(bindings), `${executable}; return ${name};`)(...Object.values(bindings))
}
test('online legacy and unspecified-protocol terminals remain ready without masking configuration failures', () => {
const ready = extract(terminalSource, 'isTerminalReady', 'getLabelProfileText')
for (const protocol_version of [undefined, 1, 2]) {
const terminal = { status: 'online', protocol_version, selected_printer_name: 'Label printer', readiness: { is_ready: true } }
assert.equal(ready(terminal), true)
assert.equal(ready({ ...terminal, status: 'disabled' }), false)
assert.equal(ready({ ...terminal, readiness: { is_ready: false } }), false)
assert.equal(ready({ ...terminal, readiness: undefined, label_profile: { configured: true } }), true)
assert.equal(ready({ ...terminal, readiness: undefined, selected_printer_name: '' }), false)
}
})
test('legacy remote-print status stays available and upgrade advice does not promise waiting for an update', () => {
for (const protocol_version of [undefined, 1, 2]) {
const defaultTerminal = { value: { id: 7, name: 'Office', status: 'online', protocol_version, readiness: { is_ready: true } } }
const bindings = { computed: (callback) => ({ value: callback() }), defaultTerminal, terminalLoading: { value: false }, terminalError: { value: null }, remotePrinterName: { value: 'Label printer' } }
const status = extract(barcodeSource, 'remoteStatusText', 'remotePrinterName', bindings)
const description = extract(barcodeSource, 'remoteStatusDescription', 'remoteActionDisabled', bindings)
const disabled = extract(barcodeSource, 'remoteActionDisabled', 'getJobStatusText', { ...bindings, loading: { value: false }, remoteSubmitting: { value: false } })
assert.equal(status.value, '可直接打印')
assert.equal(disabled.value, false)
assert.doesNotMatch(description.value, /不能接单|等待兼容|需要升级/)
if (protocol_version !== 2) assert.match(description.value, /旧版助手可正常打印.*建议升级/)
}
})
test('legacy and unspecified-protocol terminals can submit a real label job through the existing handler', async () => {
for (const protocol_version of [undefined, 1]) {
const submissions = []
const notices = []
const latestRemoteJob = { value: null }
const remoteSubmitting = { value: false }
const print = extract(barcodeSource, 'handleRemotePrint', 'handleCancelRemoteJob', {
props: { equipmentIds: [11] }, equipmentList: { value: [{ id: 11 }] },
terminalLoading: { value: false }, defaultTerminal: { value: { id: 7, name: 'Office', status: 'online', protocol_version } },
remoteSubmitting, latestRemoteJob, alert: (message) => notices.push(message),
ensureImagesGenerated: async () => ['data:image/png;base64,YQ=='],
createAssetLabelPrintJob: async (payload) => {
submissions.push(payload)
return { job: { id: 33, status: 'queued', target_terminal_name: 'Office' } }
},
fetchDefaultTerminal: async () => {}
})
await print()
assert.equal(submissions.length, 1)
assert.equal(submissions[0].target_terminal_id, 7)
assert.deepEqual(submissions[0].equipment_ids, [11])
assert.equal(latestRemoteJob.value.id, 33)
assert.equal(remoteSubmitting.value, false)
assert.match(notices[0], /已发送/)
}
})

View File

@ -254,12 +254,11 @@ const remoteStatusDescription = computed(() => {
return '当前还没有设置默认打印电脑,请联系管理员完成终端配置。'
}
if (defaultTerminal.value.status !== 'disabled' && (defaultTerminal.value.protocol_version || 0) < 2) {
return '打印助手需要升级。发送的任务会排队等待兼容的助手接单,请联系管理员更新办公室电脑。'
}
if (defaultTerminal.value.status === 'online' && defaultTerminal.value.readiness?.is_ready) {
return `默认打印电脑 ${defaultTerminal.value.name} 在线,发送后会直接交给 ${remotePrinterName.value}。`
const upgradeSuggestion = (defaultTerminal.value.protocol_version || 0) < 2
? ' 旧版助手可正常打印,建议升级以提高中断后的恢复可靠性。'
: ''
return `默认打印电脑 ${defaultTerminal.value.name} 在线,发送后会直接交给 ${remotePrinterName.value}。${upgradeSuggestion}`
}
if (defaultTerminal.value.status === 'disabled') {

View File

@ -140,7 +140,7 @@
</div>
</div>
<p v-if="(terminal.protocol_version || 0) < 2" class="text-sm text-status-warning">打印助手需要升级,请在这台电脑安装兼容版本后再接单。</p>
<p v-if="(terminal.protocol_version || 0) < 2" class="text-sm text-text-secondary">旧版助手可正常接单,建议升级以提高中断后的恢复可靠性。</p>
<div v-if="getTerminalError(terminal)" class="p-2.5 rounded-md bg-status-error/10 border border-status-error/20">
<p class="text-xs text-status-error">{{ getTerminalError(terminal) }}</p>
</div>
@ -374,7 +374,6 @@ const getTerminalError = (terminal: PrintTerminal) => {
const isTerminalReady = (terminal: PrintTerminal) => {
if (terminal.status === 'disabled') return false
if ((terminal.protocol_version || 0) < 2) return false
if (typeof terminal.readiness?.is_ready === 'boolean') {
return terminal.readiness.is_ready
}
@ -422,8 +421,6 @@ const getTerminalReadinessText = (terminal: PrintTerminal) => {
return '这台终端已停用,不会再被派发远程打印任务。'
}
if ((terminal.protocol_version || 0) < 2) return '打印助手需要升级,当前不能接单。'
if (terminal.status !== 'online') {
return '电脑离线时仍会保留登记信息,等重新上线后再恢复状态。'
}