fix: restore server XLS roster conversion
This commit is contained in:
1 parent
4ba2d43710
commit
d7a821da16
8 files changed
+218
-63
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user