fix: restore legacy print agent compatibility safely
This commit is contained in:
parent
8cfeb1c6b1
commit
b6751bb8cb
@ -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 }) }
|
||||
})
|
||||
|
||||
171
backend/src/services/__tests__/printLegacyCompatibility.test.ts
Normal file
171
backend/src/services/__tests__/printLegacyCompatibility.test.ts
Normal 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
|
||||
}
|
||||
})
|
||||
@ -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({
|
||||
|
||||
13
docs/2026-10-09_旧版打印助手兼容修复.md
Normal file
13
docs/2026-10-09_旧版打印助手兼容修复.md
Normal file
@ -0,0 +1,13 @@
|
||||
# 旧版打印助手兼容修复
|
||||
|
||||
旧版 .NET 和 Electron 助手不发送 `protocol_version` 与 `claim_token`。强制新版协议导致已安装的助手无法领取任务,本次恢复缺省协议及协议 1 的正常领取、打印中和完成回报,无需用户先升级电脑上的助手。
|
||||
|
||||
- 领取时由服务端生成带 `legacy-v1:` 标记的唯一凭据,仅允许该任务的已认证领取终端省略回报凭据;新版任务继续严格验证凭据,心跳版本变化不能降级任务校验。
|
||||
- 终端行锁与事务内忙碌检查避免旧 Electron 并行轮询同时领到多个任务。
|
||||
- 旧助手只领取从未领取过的新任务编号。人工核实旧任务未出纸后,在同一事务内关闭旧编号并创建新编号,防止晚到回报污染重新打印的任务。
|
||||
- 已完成任务不能被晚到失败回报覆盖;超时或打印开始后的失败仍进入待核实状态,不自动重新出纸,图片保留到结果确认。
|
||||
- 界面取消强制升级门禁,升级提示改为提高中断恢复可靠性的建议。
|
||||
|
||||
验证:前后端构建通过;后端全量测试 297 项通过、2 项隔离数据库测试默认跳过;独立本机 MySQL 打印集成测试 14 项全部通过;前端打印相关回归 14 项全部通过。集成测试包含旧协议无凭据完整流程、并发领取、跨终端拒绝、现代任务不可降级、过期结果、人工重派的新旧编号隔离及图片保留。
|
||||
|
||||
本次不修改数据库结构、认证策略、发布配置或打印助手安装包。生产部署和真实助手请求验收结果见本次部署回执。
|
||||
68
frontend/scripts/test-legacy-print-compatibility.mjs
Normal file
68
frontend/scripts/test-legacy-print-compatibility.mjs
Normal 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], /已发送/)
|
||||
}
|
||||
})
|
||||
@ -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') {
|
||||
|
||||
@ -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 '电脑离线时仍会保留登记信息,等重新上线后再恢复状态。'
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user