Play The Event ドキュメンテーション システム
イベントの管理において、請求書、契約書、書類などの文書は重要な資産となります。 許可証、ポスター、証明書、領収書は整理、バージョン管理、共有する必要があります 安全に。で イベントをプレイする、 ドキュメント モジュールはドメイン駆動設計の原則に従って設計されています。 と イベントドキュメント バージョン管理、分類を調整する集約ルートなど メタデータの自動抽出と一時リンクを介した共有。
このアプローチにより、データの整合性、変更の完全な追跡可能性が保証されます。 MIME タイプとカテゴリに基づいてファイルを論理フォルダーに自動編成します。 文書の。
この記事でわかること
- 集約ルート
DocumentoEventoおよびドキュメントの DDD パターン - 11 の事前定義されたカテゴリとイベントごとにカスタマイズされたカテゴリ
- MIME タイプとカテゴリに基づくフォルダーの自動検出
- バージョンの追加と復元を行うバージョン管理システム
- 時間制限とダウンロード制限のあるリンクの共有
- メタデータの自動抽出 (EXIF、PDF、Office)
- SHA-256 ハッシュによるファイルの整合性
- 書類と経費の関係と整理された保管
EventDocument: 集約ルート
DocumentoEvento これは、境界付きドキュメント コンテキストの集約ルートです。毎
ドキュメントは特定のイベントに属し、ユーザーによってアップロードされ、内部で管理されます
バージョン履歴と共有リンク。ファクトリーメソッド crea()
各ドキュメントが有効な状態で作成され、最初のバージョンがすでに作成されていることを確認します。
@Entity
@Table(name = "documenti_evento")
public class DocumentoEvento {
private static final long MAX_DIMENSIONE_BYTES = 50L * 1024 * 1024; // 50MB
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long eventoId;
private String titolo; // max 200 caratteri
private String descrizione; // max 1000 caratteri
@Enumerated(EnumType.STRING)
private CategoriaDocumento categoria;
private Long categoriaPersonalizzataId;
@Enumerated(EnumType.STRING)
private TipoCartella tipoCartella;
private Integer versioneCorrente;
private String nomeFileOriginale;
private String mimeType;
private Long dimensioneBytes;
private Long caricatoDaId;
private Long spesaId; // collegamento opzionale a spesa
@Convert(converter = JsonMapConverter.class)
private Map<String, Object> metadati = new HashMap<>();
@OneToMany(mappedBy = "documento", cascade = CascadeType.ALL)
@OrderBy("numeroVersione DESC")
private List<VersioneDocumento> versioni = new ArrayList<>();
@OneToMany(mappedBy = "documento", cascade = CascadeType.ALL)
private Set<LinkCondivisioneDocumento> linkCondivisione = new HashSet<>();
}
テーブル documenti_evento 5 つのインデックスを使用してクエリを最適化します
最も頻繁に発生する: イベント別、カテゴリ別、フォルダ タイプ別、ユーザー別
文書のアップロードと関連経費の支払い。楽観的ロックによる @Version
同時更新の競合を防ぎます。
完全な検証を備えたファクトリーメソッド
ビルダーは protected ファクトリメソッド経由でのみアクセス可能
crea()、インスタンスを作成する前にすべてのパラメータを検証します。フォルダーの種類
MIME タイプとカテゴリに基づいて自動的に決定されます。
public static DocumentoEvento crea(
Long eventoId, String titolo, String descrizione,
CategoriaDocumento categoria, Long categoriaPersonalizzataId,
String nomeFileOriginale, String mimeType,
Long dimensioneBytes, Long caricatoDaId,
String percorsoStorage, String hashContenuto) {
validaParametriCreazione(eventoId, titolo, nomeFileOriginale,
mimeType, dimensioneBytes, caricatoDaId, percorsoStorage);
DocumentoEvento doc = new DocumentoEvento();
doc.eventoId = eventoId;
doc.titolo = titolo.trim();
doc.categoria = categoria != null ? categoria : CategoriaDocumento.ALTRO;
doc.categoriaPersonalizzataId =
doc.categoria == CategoriaDocumento.PERSONALIZZATA
? categoriaPersonalizzataId : null;
doc.tipoCartella = TipoCartella.fromMimeTypeAndCategoria(mimeType, doc.categoria);
doc.versioneCorrente = 1;
// Crea la prima versione con relazione bidirezionale
VersioneDocumento primaVersione = VersioneDocumento.crea(
1, nomeFileOriginale, percorsoStorage,
dimensioneBytes, mimeType, hashContenuto,
caricatoDaId, "Versione iniziale"
);
primaVersione.setDocumento(doc);
doc.versioni.add(primaVersione);
return doc;
}
11 のデフォルト カテゴリ
アップロードされるすべてのドキュメント
イベントをプレイする
列挙内の 11 の事前定義されたカテゴリの 1 つに関連付けられています CategoriaDocumento。
各カテゴリには、ユーザー インターフェイスの表示可能な名前とアイコンが含まれています。
public enum CategoriaDocumento {
FATTURA("Fattura", "file-text"),
SCONTRINO("Scontrino", "receipt"),
PERMESSO("Permesso", "shield-check"),
BANDO("Bando", "megaphone"),
CONTRATTO("Contratto", "file-signature"),
PREVENTIVO("Preventivo", "calculator"),
ASSICURAZIONE("Assicurazione", "shield"),
CERTIFICATO("Certificato", "award"),
LOCANDINA("Locandina", "image"),
PERSONALIZZATA("Categoria Personalizzata", "tag"),
ALTRO("Altro", "file");
private final String displayName;
private final String icona;
// Verifica se la categoria rappresenta una ricevuta
public boolean isRicevuta() {
return this == FATTURA || this == SCONTRINO;
}
// Suggerisce una categoria di default per un tipo MIME
public static CategoriaDocumento fromMimeType(String mimeType) {
if (mimeType != null && mimeType.startsWith("image/")) {
return LOCANDINA;
}
return ALTRO;
}
}
これらのカテゴリは、イベント管理の一般的なニーズをカバーしています: 税務書類
(請求書, レシート, 予防的)、
法的文書 (契約, 許可する, 知らせ,
保険)、資格書類(証明書) e
販促資料 (ポスター)。方法 isRicevuta()
請求書と領収書を識別して、領収書フォルダーに自動的にルーティングします。
カスタマイズされたイベント カテゴリ
事前に定義された 11 のカテゴリでは不十分な場合、主催者は次のカテゴリを作成できます。
カスタムカテゴリ あなたのイベントに特化したもの。実体
CategoriaDocumentoPersonalizzata 名前、説明を定義できます。
追加の各カテゴリのアイコン、色、表示順序。
@Entity
@Table(name = "categorie_documento_evento",
uniqueConstraints = @UniqueConstraint(
name = "uq_categoria_evento_nome",
columnNames = {"evento_id", "nome"}
))
public class CategoriaDocumentoPersonalizzata {
private static final Pattern HEX_COLOR_PATTERN =
Pattern.compile("^#[0-9A-Fa-f]{6}$");
private static final String COLORE_DEFAULT = "#6b7280";
private static final String ICONA_DEFAULT = "file-text";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long eventoId;
private String nome; // max 50 caratteri, unico per evento
private String descrizione; // max 200 caratteri
private String icona; // nome icona Lucide
private String colore; // formato hex #RRGGBB
private Integer ordine; // ordine di visualizzazione
}
一意性制約 uq_categoria_evento_nome 作成を妨げます
同じイベント内の重複カテゴリ。色は次の方法で検証されます。
16 進形式のみを受け入れる正規表現 #RRGGBB。彼が来なかったら
色またはアイコンを指定した場合は、デフォルト値が適用されます。
ドキュメントでカテゴリが使用されている場合 PERSONALIZZATA、フィールド
categoriaPersonalizzataId del DocumentoEvento 設定されています
対応するカスタム カテゴリ ID を使用します。カテゴリがそうでない場合は、
PERSONALIZZATA、フィールドは自動的にクリアされます。
フォルダーの自動検出
システムは、MIME タイプに基づいてファイルを 3 つの論理フォルダーに自動的に整理します。
ファイルの名前とドキュメントのカテゴリ。列挙型 TipoCartella を定義します
ルーティングロジック。
public enum TipoCartella {
DOCUMENTI("documenti"),
IMMAGINI("immagini"),
RICEVUTE("ricevute");
private final String percorso;
public static TipoCartella fromMimeTypeAndCategoria(
String mimeType, CategoriaDocumento categoria) {
// 1. Fatture e scontrini vanno SEMPRE nella cartella ricevute
if (categoria != null && categoria.isRicevuta()) {
return RICEVUTE;
}
// 2. I file con MIME type image/* vanno nella cartella immagini
if (mimeType != null && mimeType.startsWith("image/")) {
return IMMAGINI;
}
// 3. Tutto il resto va nella cartella documenti
return DOCUMENTI;
}
}
ルーティングの優先順位は明確です。 カテゴリ はぁ
MIME タイプよりも優先されます。 PDF形式の請求書がフォルダに置かれます
/ricevute そして入っていない /documenti、そのカテゴリのため
(FATTURA) 受信済みであることを示します。未分類の JPEG 画像
領収書明細はフォルダーに保存されます /immagini.
ルーティングルール
- /領収書 - INVOICE または RECEIPT カテゴリの文書 (任意のファイル形式)
- /画像 - MIME タイプのファイル
image/*(JPEG、PNG、WebPなど) - /ドキュメント - その他すべてのファイル (PDF、Word、Excel、テキストなど)
バージョン管理システム
各ドキュメントは完全なバージョン履歴を保持します。ユーザーがアップロードするとき
新しいバージョンになると、システムはカウンターをインクリメントします versioneCorrente e
新しいオブジェクトを作成します VersioneDocumento 更新されたファイルのすべてのメタデータが含まれます。
@Entity
@Table(name = "versioni_documento",
uniqueConstraints = @UniqueConstraint(
name = "uq_documento_versione",
columnNames = {"documento_id", "numero_versione"}
))
public class VersioneDocumento {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "documento_id", nullable = false)
private DocumentoEvento documento;
private Integer numeroVersione; // progressivo, >= 1
private String nomeFileOriginale; // max 255 caratteri
private String percorsoStorage; // max 500 caratteri
private Long dimensioneBytes;
private String mimeType;
private String hashContenuto; // SHA-256, 64 caratteri hex
private Long creatoDaId;
private String notaVersione; // max 500 caratteri
private Instant creatoIl;
}
新しいバージョンの追加
バージョンの追加は集約ルートによって完全に処理されます。 内部状態の一貫性。バージョン番号がインクリメントされます 自動的にメインドキュメントのメタデータが次のデータで更新されます。 新しいファイル。
public VersioneDocumento aggiungiVersione(
String nomeFileOriginale, String percorsoStorage,
Long dimensioneBytes, String mimeType,
String hashContenuto, Long caricatoDaId, String notaVersione) {
validaParametriVersione(nomeFileOriginale, percorsoStorage,
dimensioneBytes, mimeType, caricatoDaId);
this.versioneCorrente++;
this.nomeFileOriginale = nomeFileOriginale;
this.mimeType = mimeType;
this.dimensioneBytes = dimensioneBytes;
VersioneDocumento nuovaVersione = VersioneDocumento.crea(
this.versioneCorrente, nomeFileOriginale, percorsoStorage,
dimensioneBytes, mimeType, hashContenuto,
caricatoDaId, notaVersione
);
nuovaVersione.setDocumento(this);
this.versioni.add(nuovaVersione);
return nuovaVersione;
}
以前のバージョンの復元
復元しても履歴は上書きされません。履歴が作成されます。 新しいバージョン と 復元するバージョンの内容。このようにして歴史は不変であり、 すべての操作が追跡されます。
public VersioneDocumento ripristinaVersione(Integer numeroVersione, Long utenteId) {
VersioneDocumento versioneDaRipristinare = versioni.stream()
.filter(v -> v.getNumeroVersione().equals(numeroVersione))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException(
"Versione " + numeroVersione + " non trovata"
));
// Crea una NUOVA versione con i dati della versione precedente
return aggiungiVersione(
versioneDaRipristinare.getNomeFileOriginale(),
versioneDaRipristinare.getPercorsoStorage(),
versioneDaRipristinare.getDimensioneBytes(),
versioneDaRipristinare.getMimeType(),
versioneDaRipristinare.getHashContenuto(),
utenteId,
"Ripristino versione " + numeroVersione
);
}
バージョン管理の不変
以前のバージョンを復元しても履歴は削除または変更されません 存在する。ドキュメントに 3 つのバージョンがあり、バージョン 1 に戻すと、結果は これは、バージョン 1 と同じ内容を持つバージョン 4 になります。このアプローチは、 完全な監査証跡を確保し、偶発的なデータ損失を防ぎます。
一時的な共有リンク
ドキュメントは、必要のない一時リンクを介して外部と共有できます。
認証。実体 LinkCondivisioneDocumento 世代を管理し、
これらのリンクの検証と取り消し。
@Entity
@Table(name = "link_condivisione_documento")
public class LinkCondivisioneDocumento {
private static final Duration DURATA_MASSIMA = Duration.ofDays(30);
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "documento_id", nullable = false)
private DocumentoEvento documento;
private String token; // 64 caratteri, UUID-based, unico
private Instant scadenza; // max 30 giorni dalla creazione
private Long creatoDaId;
private Integer numeroDownload; // contatore incrementale
private Integer maxDownload; // limite opzionale (null = illimitato)
private Boolean revocato;
private Instant revocatoIl;
}
複数条件の有効性
リンクは、次の条件を満たす場合にのみ有効とみなされます。 3つの同時条件: 取り消されておらず、有効期限が切れておらず、ダウンロードの最大制限に達していません。
public boolean isValido() {
// Condizione 1: non revocato manualmente
if (revocato) {
return false;
}
// Condizione 2: non scaduto temporalmente
if (Instant.now().isAfter(scadenza)) {
return false;
}
// Condizione 3: download rimanenti disponibili
if (maxDownload != null && numeroDownload >= maxDownload) {
return false;
}
return true;
}
public void registraDownload() {
if (!isValido()) {
throw new IllegalStateException(
"Impossibile registrare download: link non valido");
}
this.numeroDownload++;
}
トークンは 2 つの UUID を結合し、64 文字に切り詰めることによって生成され、 ユニークさと予測不可能性。リンクに許可される最大期間は次のとおりです。 の 30日。取り消しは冪等な操作です。 リンクが既に取り消されていてもエラーは発生しません。
集約ルートからの生成と取り消し
リンクの作成と取り消しは常に集約ルートを通過します。集約ルートは、 ドキュメントに関連付けられたリンクのセットを完全に制御します。
// Generazione di un nuovo link temporaneo
public LinkCondivisioneDocumento generaLinkCondivisione(
Duration durata, Long creatoDaId, Integer maxDownload) {
LinkCondivisioneDocumento link = LinkCondivisioneDocumento.crea(
durata, creatoDaId, maxDownload
);
link.setDocumento(this);
this.linkCondivisione.add(link);
return link;
}
// Revoca di un link specifico tramite token
public void revocaLinkCondivisione(String token) {
LinkCondivisioneDocumento link = linkCondivisione.stream()
.filter(l -> l.getToken().equals(token))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException(
"Link non trovato: " + token));
link.revoca();
}
// Query: tutti i link ancora attivi
public List<LinkCondivisioneDocumento> getLinkAttivi() {
return linkCondivisione.stream()
.filter(LinkCondivisioneDocumento::isValido)
.collect(Collectors.toList());
}
自動メタデータ抽出
ファイルがアップロードされると、
イベントをプレイする
を使用して利用可能なメタデータを自動的に抽出します アパッチ ティカ そして
図書館 メタデータ抽出子。抽出されたメタデータは JSON として保存されます
野原で metadati 文書の。
public interface MetadatiDocumentoExtractor {
// Estrae tutti i metadati in base al tipo MIME
Map<String, Object> estraiMetadati(
InputStream inputStream, String nomeFile, String mimeType);
// Specializzazioni per tipo di file
Map<String, Object> estraiMetadatiImmagine(InputStream inputStream);
Map<String, Object> estraiMetadatiPdf(InputStream inputStream);
Map<String, Object> estraiMetadatiOffice(
InputStream inputStream, String mimeType);
boolean isFormatoSupportato(String mimeType);
}
画像のメタデータ (EXIF、IPTC、XMP)
画像の場合、システムは次の 4 つのメイン ディレクトリからメタデータを抽出します。 EXIF IFD0 (カメラのブランド、モデル、ソフトウェア、向き)、 EXIFサブIFD (露出時間、絞り、ISO、焦点距離、フラッシュ)、 GPS (緯度、経度、高度) e IPTC (タイトル、説明、 キーワード、著者、都市、国)。
// MIME types immagine con estrazione EXIF completa
"image/jpeg", "image/png", "image/gif", "image/webp",
"image/tiff", "image/bmp", "image/heic", "image/heif", "image/avif"
// Esempio di metadati estratti da una foto JPEG
{
"exifBase": {
"marca": "Canon",
"modello": "EOS R5",
"software": "Adobe Lightroom 14.0",
"orientamento": "Top, left side (Horizontal)"
},
"exifTecnico": {
"dataScatto": "2026:01:15 14:30:22",
"tempoEsposizione": "1/250 sec",
"apertura": "f/2.8",
"iso": "400",
"lunghezzaFocale": "70 mm",
"flash": "Flash did not fire"
},
"gps": {
"latitudine": 41.1171,
"longitudine": 16.8719
}
}
PDF および Office ドキュメントのメタデータ
PDF ファイルの場合、システムはタイトル、作成者、件名、キーワード、ページ数、 PDF バージョンと暗号化情報。 Office文書の場合(Word、Excel、 PowerPoint、OpenDocument)、単語数などの統計、 文字、段落、スライド、総編集時間。
// PDF
"application/pdf"
// Office - Word
"application/msword"
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
// Office - Excel
"application/vnd.ms-excel"
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
// Office - PowerPoint
"application/vnd.ms-powerpoint"
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
// OpenDocument
"application/vnd.oasis.opendocument.text"
"application/vnd.oasis.opendocument.spreadsheet"
"application/vnd.oasis.opendocument.presentation"
// Altri
"text/plain", "text/html", "text/csv",
"application/rtf", "application/zip"
メタデータはドキュメントの JSON フィールドに保存され、参照できます。
メソッドを通じて getMetadato(chiave), aggiungiMetadato(chiave, valore)
e haMetadati() 集約ルートの。
SHA-256 によるファイルの整合性
ドキュメントの各バージョンにはハッシュが含まれます SHA-256 コンテンツの、 アップロード時に計算されます。これでいつでも確認できるようになります ファイルが保管中に変更または破損していないこと。
// Calcolo hash al caricamento
public String calcolaHash(InputStream contenuto) {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = contenuto.read(buffer)) != -1) {
digest.update(buffer, 0, bytesRead);
}
byte[] hash = digest.digest();
return bytesToHex(hash); // 64 caratteri esadecimali
}
// Verifica integrita' su richiesta
public boolean verificaIntegrita(String percorsoRelativo, String hashAtteso) {
if (hashAtteso == null) {
return true; // Se non c'e' hash, considera valido
}
try (InputStream is = leggi(percorsoRelativo).orElse(null)) {
if (is == null) return false;
String hashEffettivo = calcolaHash(is);
return hashAtteso.equals(hashEffettivo);
}
}
ファイルごとの最大制限は次のとおりです 50MB、両方とも工場で検証済み
の方法 DocumentoEvento メソッドよりも aggiungiVersione()。
この制限を超えるファイルは、明示的な例外により拒否されます。
書類と経費の関係
ドキュメントをイベント経費にリンクして関係を作成できます。 文書システムと財務管理システムの間の双方向。これ 領収書、請求書、領収書を経費に添付するのに特に便利です 特派員。
// Collega il documento a una spesa
public void collegaASpesa(Long spesaId) {
this.spesaId = spesaId;
}
// Rimuove il collegamento con una spesa
public void scollegaDaSpesa() {
this.spesaId = null;
}
// Verifica se il documento e' collegato a una spesa
public boolean isCollegatoASpesa() {
return spesaId != null;
}
// Verifica se il documento e' una ricevuta
public boolean isRicevuta() {
return tipoCartella == TipoCartella.RICEVUTE;
}
インデックス idx_documento_spesa コラムの上に spesa_id 最適化する
特定の経費に添付されているすべてのドキュメントを取得するクエリ。これ
各予算項目に関連付けられた領収書をすばやく表示できます
イベントの様子。
ファイルシステム上に整理されたストレージ
ストレージの実装は次のパターンに従います 六角形の建築:
ドメイン層はインターフェースを定義します DocumentoStorageService、一方
インフラストラクチャ層は実装を提供します FileSystemDocumentoStorage。
ファイルは明確な階層構造で編成されています。
{basePath}/
└── {eventoId}/
├── documenti/
│ └── {documentoId}/
│ ├── v1.pdf
│ ├── v2.pdf
│ └── v3.docx
├── immagini/
│ └── {documentoId}/
│ ├── v1.jpg
│ └── v2.png
└── ricevute/
└── {documentoId}/
├── v1.pdf
└── v2.jpg
// Esempio percorso completo:
// /storage/42/documenti/156/v2.pdf
// /storage/42/immagini/201/v1.jpg
// /storage/42/ricevute/189/v3.pdf
バージョンの命名は規則に従います。 v{numero}.{estensione}、ここで
拡張子は元のファイル名から抽出されます。ストレージサービスが管理する
また、ドキュメントを削除した後に空のディレクトリをクリーンアップし、元に戻ります
再帰的にツリーを上ってベース ディレクトリに戻ります。
public interface DocumentoStorageService {
// Salvataggio con percorso auto-generato
String salva(Long eventoId, TipoCartella tipoCartella,
Long documentoId, Integer versione,
String nomeFile, InputStream contenuto);
// Lettura file
Optional<InputStream> leggi(String percorsoRelativo);
// Eliminazione singola versione o tutte le versioni
boolean elimina(String percorsoRelativo);
void eliminaTutteVersioni(Long eventoId, TipoCartella tipoCartella,
Long documentoId);
// Integrita' e verifica
String calcolaHash(InputStream contenuto);
boolean verificaIntegrita(String percorsoRelativo, String hashAtteso);
// Monitoraggio spazio
long getSpazioUtilizzato(Long eventoId);
long getDimensione(String percorsoRelativo);
boolean esiste(String percorsoRelativo);
}
方法 getSpazioUtilizzato() ディレクトリを再帰的に調べます
イベントの情報とすべてのファイルのサイズを合計して、スペースを監視できるようにします。
イベントごとにディスクが占有され、クォータ制限が実装されます。
文書システムの概要
- 集約ルート:
DocumentoEvento完全な検証と工場出荷時のメソッドによる - 11 の事前定義されたカテゴリ + アイコンと色を使用したイベントごとのカスタム カテゴリ
- 自動検出フォルダー: MIME タイプとカテゴリに基づく /receipts、/images、/documents
- 不変のバージョン管理: 変更と復元のたびに新しいバージョンが作成されます
- 一時的なリンク: 有効期限 (最大 30 日)、ダウンロード制限、取り消し付きの共有
- 自動メタデータ: 画像、PDF、Office プロパティの EXIF/IPTC/XMP
- SHA-256 の整合性: オンデマンド検証によるコンテンツハッシュ
- 50MB制限: 作成時および更新時のディメンションの検証
- 経費リンク: 予算項目に添付できる書類
- 階層型ストレージ: イベント、フォルダーの種類、ドキュメントごとに整理
ドキュメント システムのソース コードは次の場所から入手できます。 GitHub。 次の記事では、アーキテクチャの別の側面を探っていきます。 イベントをプレイする、 システムが異なる境界付きコンテキスト間の相互作用をどのように管理するかを分析する ドメインの。







