fix(mail): Duplikate auch bei neu erzeugten PDFs erkennen
Build and Push Multi-Platform Images / build-and-push (push) Successful in 45s
Build and Push Multi-Platform Images / build-and-push (push) Successful in 45s
Absender erzeugen PDFs beim erneuten Versand oft neu (andere Erstellungszeit/Dokument-ID), dadurch unterscheidet sich die Checksumme und die Duplikatprüfung griff nicht (z.B. Mail 7071 vs. 7072). - Neuer Text-Fingerprint (SHA-256 über normalisierten PDF-Text via pdf-parse) in Attachments.ContentFingerprint, inkl. Migration und Index - Wird beim Mail-Abruf berechnet; Altbestand füllt ein Hintergrund- Backfill beim Start auf, fehlende Werte werden bei Bedarf nachberechnet - Duplikatprüfung und Import-Sperre berücksichtigen Anhänge mit gleichem Inhalt, die importiert sind oder gerade importiert werden - Jest braucht für pdf-parse --experimental-vm-modules (test-Skripte) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
534b313054
commit
797cbb9b71
12 files changed
+746
-7
No files matched your search
@@ -0,0 +1,124 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Brackets, Repository } from 'typeorm';
|
||||
import { Attachment } from '../database/entities/attachment.entity';
|
||||
import { Content } from '../database/entities/content.entity';
|
||||
import { computeContentFingerprint } from './pdf-fingerprint.util';
|
||||
|
||||
/**
|
||||
* Verwaltet den inhaltsbasierten Fingerprint der Mail-Anhänge: berechnet ihn
|
||||
* bei Bedarf nach, füllt ihn für Altbestände im Hintergrund auf und findet
|
||||
* bereits importierte Anhänge mit gleichem Inhalt.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AttachmentFingerprintService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(AttachmentFingerprintService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Attachment)
|
||||
private readonly attachmentRepo: Repository<Attachment>,
|
||||
@InjectRepository(Content)
|
||||
private readonly contentRepo: Repository<Content>,
|
||||
) {}
|
||||
|
||||
onApplicationBootstrap(): void {
|
||||
// Nicht auf den Backfill warten – der Start soll nicht blockieren
|
||||
this.backfill().catch((err: Error) =>
|
||||
this.logger.error(`Fingerprint-Backfill fehlgeschlagen: ${err.message}`),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert den Fingerprint eines Anhangs und berechnet ihn nach, falls er
|
||||
* noch fehlt (z.B. Mail vor dem Backfill eingegangen). `null`, wenn kein
|
||||
* vergleichbarer Text vorliegt.
|
||||
*/
|
||||
async ensure(attachment: Attachment): Promise<string | null> {
|
||||
if (attachment.ContentFingerprint != null) {
|
||||
return attachment.ContentFingerprint || null;
|
||||
}
|
||||
if (attachment.ContentType !== 'application/pdf') return null;
|
||||
|
||||
const content = await this.contentRepo.findOne({
|
||||
where: { AttachmentEntityId: attachment.Id },
|
||||
});
|
||||
if (!content) return null;
|
||||
|
||||
const fingerprint = await computeContentFingerprint(content.Content1);
|
||||
if (fingerprint === null) return null;
|
||||
await this.attachmentRepo.update(attachment.Id, {
|
||||
ContentFingerprint: fingerprint,
|
||||
});
|
||||
attachment.ContentFingerprint = fingerprint;
|
||||
return fingerprint || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt es einen anderen Anhang mit gleichem Inhalt, dessen Seitenbereich
|
||||
* bereits importiert wurde oder gerade importiert wird?
|
||||
*/
|
||||
async hasImportedTwin(
|
||||
attachmentId: number,
|
||||
fingerprint: string,
|
||||
range: string,
|
||||
): Promise<boolean> {
|
||||
const count = await this.attachmentRepo
|
||||
.createQueryBuilder('a')
|
||||
.where('a.ContentFingerprint = :fingerprint', { fingerprint })
|
||||
.andWhere('a.Id != :attachmentId', { attachmentId })
|
||||
.andWhere(
|
||||
new Brackets((qb) => {
|
||||
// Abgeschlossener Import mit ermittelter Paperless-ID
|
||||
qb.where(
|
||||
"JSON_CONTAINS_PATH(a.PaperlessDocumentIds, 'one', :jsonPath) = 1",
|
||||
{ jsonPath: `$."${range}"` },
|
||||
)
|
||||
// Laufender oder abgeschlossener Upload (fehlgeschlagene Tasks
|
||||
// löscht der Task-Processor)
|
||||
.orWhere(
|
||||
'EXISTS (SELECT 1 FROM tasks t WHERE t.SourceAttachmentID = a.Id AND t.SourceAttachmentRange = :range)',
|
||||
{ range },
|
||||
);
|
||||
// Ganzer Anhang: jeder Import zählt, auch wenn die ID beim Upload
|
||||
// nicht ermittelt werden konnte
|
||||
if (range === 'full') qb.orWhere('a.ImportStatus = 1');
|
||||
}),
|
||||
)
|
||||
.getCount();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/** Berechnet fehlende Fingerprints aller PDF-Anhänge in kleinen Portionen. */
|
||||
async backfill(batchSize = 25): Promise<number> {
|
||||
let lastId = 0;
|
||||
let updated = 0;
|
||||
for (;;) {
|
||||
const batch = await this.attachmentRepo
|
||||
.createQueryBuilder('a')
|
||||
.select(['a.Id', 'a.ContentType', 'a.ContentFingerprint'])
|
||||
.where('a.ContentFingerprint IS NULL')
|
||||
.andWhere("a.ContentType = 'application/pdf'")
|
||||
.andWhere('a.Id > :lastId', { lastId })
|
||||
.orderBy('a.Id', 'ASC')
|
||||
.take(batchSize)
|
||||
.getMany();
|
||||
if (batch.length === 0) break;
|
||||
|
||||
for (const attachment of batch) {
|
||||
// lastId weiterschieben, damit unlesbare PDFs nicht endlos drankommen
|
||||
lastId = attachment.Id;
|
||||
try {
|
||||
if ((await this.ensure(attachment)) !== null) updated++;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Fingerprint für Anhang ${attachment.Id} nicht berechenbar: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (updated > 0) {
|
||||
this.logger.log(`Fingerprint-Backfill: ${updated} Anhänge ergänzt.`);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user