diff --git a/backend/src/services/__tests__/printClaimRecovery.integration.test.ts b/backend/src/services/__tests__/printClaimRecovery.integration.test.ts index 8ffc06e..aaa0820 100644 --- a/backend/src/services/__tests__/printClaimRecovery.integration.test.ts +++ b/backend/src/services/__tests__/printClaimRecovery.integration.test.ts @@ -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 }) } }) diff --git a/backend/src/services/__tests__/printLegacyCompatibility.test.ts b/backend/src/services/__tests__/printLegacyCompatibility.test.ts new file mode 100644 index 0000000..38307f0 --- /dev/null +++ b/backend/src/services/__tests__/printLegacyCompatibility.test.ts @@ -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 + } +}) diff --git a/backend/src/services/printService.ts b/backend/src/services/printService.ts index 9609a2e..6b036a0 100644 --- a/backend/src/services/printService.ts +++ b/backend/src/services/printService.ts @@ -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({ diff --git a/docs/2026-10-09_旧版打印助手兼容修复.md b/docs/2026-10-09_旧版打印助手兼容修复.md new file mode 100644 index 0000000..d8694f2 --- /dev/null +++ b/docs/2026-10-09_旧版打印助手兼容修复.md @@ -0,0 +1,13 @@ +# 旧版打印助手兼容修复 + +旧版 .NET 和 Electron 助手不发送 `protocol_version` 与 `claim_token`。强制新版协议导致已安装的助手无法领取任务,本次恢复缺省协议及协议 1 的正常领取、打印中和完成回报,无需用户先升级电脑上的助手。 + +- 领取时由服务端生成带 `legacy-v1:` 标记的唯一凭据,仅允许该任务的已认证领取终端省略回报凭据;新版任务继续严格验证凭据,心跳版本变化不能降级任务校验。 +- 终端行锁与事务内忙碌检查避免旧 Electron 并行轮询同时领到多个任务。 +- 旧助手只领取从未领取过的新任务编号。人工核实旧任务未出纸后,在同一事务内关闭旧编号并创建新编号,防止晚到回报污染重新打印的任务。 +- 已完成任务不能被晚到失败回报覆盖;超时或打印开始后的失败仍进入待核实状态,不自动重新出纸,图片保留到结果确认。 +- 界面取消强制升级门禁,升级提示改为提高中断恢复可靠性的建议。 + +验证:前后端构建通过;后端全量测试 297 项通过、2 项隔离数据库测试默认跳过;独立本机 MySQL 打印集成测试 14 项全部通过;前端打印相关回归 14 项全部通过。集成测试包含旧协议无凭据完整流程、并发领取、跨终端拒绝、现代任务不可降级、过期结果、人工重派的新旧编号隔离及图片保留。 + +本次不修改数据库结构、认证策略、发布配置或打印助手安装包。生产部署和真实助手请求验收结果见本次部署回执。 diff --git a/frontend/scripts/test-legacy-print-compatibility.mjs b/frontend/scripts/test-legacy-print-compatibility.mjs new file mode 100644 index 0000000..a9c7ac2 --- /dev/null +++ b/frontend/scripts/test-legacy-print-compatibility.mjs @@ -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], /已发送/) + } +}) diff --git a/frontend/src/components/BarcodePrint.vue b/frontend/src/components/BarcodePrint.vue index 23cfa47..9139c73 100644 --- a/frontend/src/components/BarcodePrint.vue +++ b/frontend/src/components/BarcodePrint.vue @@ -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') { diff --git a/frontend/src/views/settings/components/PrintTerminalManagement.vue b/frontend/src/views/settings/components/PrintTerminalManagement.vue index ad9bfa7..74d7e22 100644 --- a/frontend/src/views/settings/components/PrintTerminalManagement.vue +++ b/frontend/src/views/settings/components/PrintTerminalManagement.vue @@ -140,7 +140,7 @@ -
打印助手需要升级,请在这台电脑安装兼容版本后再接单。
+旧版助手可正常接单,建议升级以提高中断后的恢复可靠性。
{{ getTerminalError(terminal) }}