fix: restore server XLS roster conversion

This commit is contained in:
inman committed 2026-08-31 16:04:26 +08:00
1 parent 4ba2d43710
commit d7a821da16
8 files changed
+218 -63

No files matched your search

+10 -3
View File
@@ -21,6 +21,13 @@ const HTML_WIDTH_ATTRIBUTE_PATTERN = /(\bwidth\s*=\s*["']?)(\d+(?:\.\d+)?)(["']?
const HTML_WIDTH_STYLE_PATTERN = /(\bwidth\s*:\s*)(\d+(?:\.\d+)?)(pt|px)/gi;
export type DocumentConversionStatus = 'converted' | 'already_pdf' | 'converted_xlsx' | 'already_xlsx' | 'source_fallback';
export type DocumentConversionErrorCode =
| 'converter_unavailable'
| 'converter_timeout'
| 'conversion_failed'
| 'conversion_output_missing'
| 'conversion_output_invalid'
| 'converted_file_too_large';
export interface DocumentConversionInput {
fileName: string;
@@ -37,7 +44,7 @@ export interface DocumentConversionResult {
sha256: string;
converted: boolean;
conversion_status: DocumentConversionStatus;
conversion_error_code?: string;
conversion_error_code?: DocumentConversionErrorCode;
}
function sourceExtension(fileName: string, contentType: string): string {
@@ -79,7 +86,7 @@ function isXlsx(contentType: string, content: Buffer): boolean {
function sourceResult(
input: DocumentConversionInput,
errorCode?: string
errorCode?: DocumentConversionErrorCode
): DocumentConversionResult {
return {
fileName: normalizeTaskArtifactFileName(input.fileName) || 'team-file.bin',
@@ -93,7 +100,7 @@ function sourceResult(
};
}
function conversionErrorCode(error: unknown): string {
function conversionErrorCode(error: unknown): DocumentConversionErrorCode {
const value = error && typeof error === 'object' ? error as {
code?: unknown;
killed?: unknown;
+10 -3
View File
@@ -1,6 +1,7 @@
import { extname } from 'node:path';
import ExcelJS from 'exceljs';
import { fromBufferPromise, type Entry, type ZipFile } from 'yauzl';
import type { DocumentConversionErrorCode } from './document-converter.js';
const XLSX_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
const XLS_MAGIC = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
@@ -89,13 +90,18 @@ export class PassengerRosterWorkbookError extends Error {
readonly code: PassengerRosterWorkbookErrorCode;
readonly row?: number;
readonly column?: number;
readonly conversionErrorCode?: DocumentConversionErrorCode;
constructor(code: PassengerRosterWorkbookErrorCode, location: { row?: number; column?: number } = {}) {
constructor(
code: PassengerRosterWorkbookErrorCode,
location: { row?: number; column?: number; conversionErrorCode?: DocumentConversionErrorCode } = {}
) {
super(ERROR_MESSAGES[code]);
this.name = 'PassengerRosterWorkbookError';
this.code = code;
this.row = location.row;
this.column = location.column;
this.conversionErrorCode = location.conversionErrorCode;
}
}
@@ -132,7 +138,7 @@ type ScalarCellValue = string | number | boolean | Date | null;
function fail(
code: PassengerRosterWorkbookErrorCode,
location: { row?: number; column?: number } = {}
location: { row?: number; column?: number; conversionErrorCode?: DocumentConversionErrorCode } = {}
): never {
throw new PassengerRosterWorkbookError(code, location);
}
@@ -553,7 +559,8 @@ export async function normalizePassengerRosterWorkbook(
if (!options.convertLegacyXls) fail('roster_workbook_converter_unavailable');
try {
xlsxContent = await options.convertLegacyXls(input);
} catch {
} catch (error) {
if (error instanceof PassengerRosterWorkbookError) throw error;
fail('roster_workbook_conversion_failed');
}
if (
+106 -56
View File
@@ -2774,7 +2774,9 @@ export class TaskService {
maxBytes: this.config.ARTIFACT_MAX_BYTES
});
if (!converted.converted || converted.conversion_status !== 'converted_xlsx') {
throw new Error(converted.conversion_error_code || 'legacy_xls_conversion_failed');
throw new PassengerRosterWorkbookError('roster_workbook_conversion_failed', {
conversionErrorCode: converted.conversion_error_code || 'conversion_failed'
});
}
return converted.content;
}
@@ -2800,13 +2802,18 @@ export class TaskService {
WHERE task_id = $1 AND purpose = 'passenger_list' AND sha256 = $2`,
[target.rowId, digest]
);
if (!prior.rowCount && target.status !== 'awaiting_attachment') {
// A rejection is not a permanent verdict on the same bytes: converter or
// runtime fixes can make a previously rejected workbook valid. A replay of
// the same AgentBus message is still stopped by its idempotency key below.
const shouldValidateAttachment = !prior.rowCount
|| text((prior.rows[0] as Record<string, unknown> | undefined)?.status) === 'rejected';
if (shouldValidateAttachment && target.status !== 'awaiting_attachment') {
throw new TaskError('invalid_transition', `任务当前状态为 ${target.status},不能接收名单附件。`);
}
let normalized: NormalizedPassengerRosterWorkbook | null = null;
let rejection: { code: string; details: Record<string, unknown> } | null = null;
if (!prior.rowCount) {
if (shouldValidateAttachment) {
try {
normalized = await this.normalizePassengerRosterAttachment(attachment);
} catch (error) {
@@ -2815,7 +2822,10 @@ export class TaskService {
code: error.code,
details: {
...(error.row == null ? {} : { row: error.row }),
...(error.column == null ? {} : { column: error.column })
...(error.column == null ? {} : { column: error.column }),
...(error.conversionErrorCode == null
? {}
: { conversion_error_code: error.conversionErrorCode })
}
};
} else {
@@ -2877,14 +2887,16 @@ export class TaskService {
}
const existingAttachment = await client.query(
`SELECT status, row_count, error_code
`SELECT id, status, row_count, error_code
FROM task_input_attachments
WHERE task_id = $1 AND purpose = 'passenger_list' AND sha256 = $2
FOR UPDATE`,
[target.rowId, digest]
);
if (existingAttachment.rowCount) {
const previous = existingAttachment.rows[0] as Record<string, unknown>;
const previousAttachment = existingAttachment.rowCount
? existingAttachment.rows[0] as Record<string, unknown>
: null;
if (previousAttachment && text(previousAttachment.status) !== 'rejected') {
if (scopedIdempotencyKey) {
await client.query(
`INSERT INTO idempotency_keys (organization_id, scope, idempotency_key, request_hash, task_id)
@@ -2903,17 +2915,13 @@ export class TaskService {
return {
row: taskRow,
event: null,
inputAttachment: text(previous.status) === 'rejected'
? {
status: 'rejected' as const,
error_code: text(previous.error_code) || 'roster_workbook_rejected',
message: `该名单附件此前已校验失败【${text(previous.error_code) || 'roster_workbook_rejected'}】,请修正后重新发送。`
}
: {
status: 'duplicate' as const,
...(Number(previous.row_count) > 0 ? { row_count: Number(previous.row_count) } : {}),
message: '该名单附件已经接收,无需重复发送。'
}
inputAttachment: {
status: 'duplicate' as const,
...(Number(previousAttachment.row_count) > 0
? { row_count: Number(previousAttachment.row_count) }
: {}),
message: '该名单附件已经接收,无需重复发送。'
}
};
}
if (text(taskRow.status) !== 'awaiting_attachment') {
@@ -2930,26 +2938,48 @@ export class TaskService {
let inputAttachment: NonNullable<TaskMessageResult['input_attachment']>;
let event: TaskEvent;
if (normalized) {
await client.query(
`INSERT INTO task_input_attachments
(organization_id, task_id, purpose, source, file_name_ciphertext,
content_type, byte_size, sha256, status, normalizer_version,
normalized_text_ciphertext, row_count)
VALUES ($1, $2, 'passenger_list', $3, $4, $5, $6, $7,
'normalized', $8, $9, $10)`,
[
context.organizationId,
target.rowId,
attachment.source,
encryptedFileName,
attachment.contentType,
attachment.content.byteLength,
digest,
PASSENGER_ROSTER_WORKBOOK_VERSION,
encryptText(this.config, normalized.canonicalTsv),
normalized.rowCount
]
);
const encryptedCanonicalTsv = encryptText(this.config, normalized.canonicalTsv);
if (previousAttachment) {
await client.query(
`UPDATE task_input_attachments
SET source = $2, file_name_ciphertext = $3, content_type = $4,
byte_size = $5, status = 'normalized', normalizer_version = $6,
normalized_text_ciphertext = $7, row_count = $8,
error_code = NULL, error_details = '{}'::jsonb, updated_at = now()
WHERE id = $1 AND status = 'rejected'`,
[
previousAttachment.id,
attachment.source,
encryptedFileName,
attachment.contentType,
attachment.content.byteLength,
PASSENGER_ROSTER_WORKBOOK_VERSION,
encryptedCanonicalTsv,
normalized.rowCount
]
);
} else {
await client.query(
`INSERT INTO task_input_attachments
(organization_id, task_id, purpose, source, file_name_ciphertext,
content_type, byte_size, sha256, status, normalizer_version,
normalized_text_ciphertext, row_count)
VALUES ($1, $2, 'passenger_list', $3, $4, $5, $6, $7,
'normalized', $8, $9, $10)`,
[
context.organizationId,
target.rowId,
attachment.source,
encryptedFileName,
attachment.contentType,
attachment.content.byteLength,
digest,
PASSENGER_ROSTER_WORKBOOK_VERSION,
encryptedCanonicalTsv,
normalized.rowCount
]
);
}
const updated = await client.query(
`UPDATE tasks
SET status = 'parse_queued', stage = 'parse',
@@ -2984,24 +3014,44 @@ export class TaskService {
});
} else {
const safeRejection = rejection || { code: 'roster_workbook_processing_failed', details: {} };
await client.query(
`INSERT INTO task_input_attachments
(organization_id, task_id, purpose, source, file_name_ciphertext,
content_type, byte_size, sha256, status, error_code, error_details)
VALUES ($1, $2, 'passenger_list', $3, $4, $5, $6, $7,
'rejected', $8, $9::jsonb)`,
[
context.organizationId,
target.rowId,
attachment.source,
encryptedFileName,
attachment.contentType,
attachment.content.byteLength,
digest,
safeRejection.code,
safeRejection.details
]
);
if (previousAttachment) {
await client.query(
`UPDATE task_input_attachments
SET source = $2, file_name_ciphertext = $3, content_type = $4,
byte_size = $5, status = 'rejected', normalizer_version = NULL,
normalized_text_ciphertext = NULL, row_count = NULL,
error_code = $6, error_details = $7::jsonb, updated_at = now()
WHERE id = $1 AND status = 'rejected'`,
[
previousAttachment.id,
attachment.source,
encryptedFileName,
attachment.contentType,
attachment.content.byteLength,
safeRejection.code,
safeRejection.details
]
);
} else {
await client.query(
`INSERT INTO task_input_attachments
(organization_id, task_id, purpose, source, file_name_ciphertext,
content_type, byte_size, sha256, status, error_code, error_details)
VALUES ($1, $2, 'passenger_list', $3, $4, $5, $6, $7,
'rejected', $8, $9::jsonb)`,
[
context.organizationId,
target.rowId,
attachment.source,
encryptedFileName,
attachment.contentType,
attachment.content.byteLength,
digest,
safeRejection.code,
safeRejection.details
]
);
}
const updated = await client.query(
`UPDATE tasks
SET message = $2, error = '', updated_at = now()