260222:1053 20260222 refactor specs/ #1 03-Data-and-Storage
All checks were successful
Build and Deploy / deploy (push) Successful in 1m0s

This commit is contained in:
admin
2026-02-22 10:53:23 +07:00
parent fd9be92b9d
commit c90a664f53
105 changed files with 2561 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,131 @@
# Database Indexing & Performance Strategy
**Version:** 1.0.0
**Context:** Production-scale (100k+ documents, High Concurrency)
**Database:** MySQL 8.x (On-Premise via Docker)
## 1. Core Principles (หลักการสำคัญ)
ในการออกแบบ Database Index สำหรับระบบ DMS ให้ยึดหลักการตัดสินใจดังนี้:
1. **Data Integrity First:** ใช้ `UNIQUE INDEX` เพื่อเป็นปราการด่านสุดท้ายป้องกันการเกิด Duplicate Document Number และ Revision ซ้ำซ้อน (แม้ Application Layer จะมี Logic ดักไว้แล้วก็ตาม)
2. **Soft-Delete Awareness:** ทุก Index ที่เกี่ยวข้องกับความถูกต้องของข้อมูล ต้องคำนึงถึงคอลัมน์ `deleted_at` เพื่อไม่ให้เอกสารที่ถูกลบไปแล้ว มาขัดขวางการสร้างเอกสารใหม่ที่ใช้เลขเดิม
3. **Foreign Key Performance:** สร้าง B-Tree Index ให้กับ Foreign Key (FK) ทุกตัว เพื่อรองรับการ JOIN ข้อมูลที่รวดเร็ว โดยเฉพาะการดึง Workflow และ Routing
4. **Write-Heavy Resilience:** ตารางประเภท `audit_logs` ให้เน้น Index เฉพาะที่จำเป็น (`created_at`, `user_id`, `action`) เพื่อไม่ให้กระทบประสิทธิภาพการ Insert
---
## 2. Document Control Indexes (ป้องกัน Duplicate & Conflict)
หัวใจของ DMS คือห้ามมีเอกสารเลขซ้ำในระบบที่ Active อยู่
### 2.1 Unique Document Number & Revision
เพื่อรองรับระบบ Soft Delete (`deleted_at`) ใน MySQL การตั้ง Unique Index จำเป็นต้องมีเทคนิคเพื่อจัดการกับค่า `NULL` (เนื่องจาก MySQL มองว่า `NULL` ไม่เท่ากับ `NULL` จึงอาจทำให้เกิด Duplicate ได้ถ้าตั้งค่าไม่รัดกุม)
**SQL Recommendation (Functional Index - MySQL 8.0+):**
```sql
-- ป้องกันการสร้าง Document No และ Revision ซ้ำ สำหรับเอกสารที่ยังไม่ถูกลบ (Active)
ALTER TABLE `documents`
ADD UNIQUE INDEX `idx_unique_active_doc_rev` (
`document_no`,
`revision`,
(IF(`deleted_at` IS NULL, 1, NULL))
);
```
*เหตุผล:* โครงสร้างนี้รับประกันว่าจะมี `document_no` + `revision` ที่ Active ได้เพียง 1 รายการเท่านั้น แต่สามารถมีรายการที่ถูกลบ (`deleted_at` มีค่า) ซ้ำกันได้
### 2.2 Current/Superseded Flag Index
การค้นหาว่าเอกสารไหนเป็น "Latest Revision" จะเกิดขึ้นบ่อยมาก
```sql
-- ใช้สำหรับ Filter เอกสารที่เป็นเวอร์ชันล่าสุดอย่างรวดเร็ว
ALTER TABLE `documents`
ADD INDEX `idx_doc_status_is_current` (`is_current`, `status`, `project_id`);
```
---
## 3. High-Concurrency Search Indexes (รองรับ 100k+ Docs)
สำหรับการทำ Filter และ Search บนหน้า Dashboard หรือรายการเอกสาร
### 3.1 Pagination & Sorting
การ Query ข้อมูลแบบแบ่งหน้า (Pagination) พร้อมเรียงลำดับวันที่ มักเกิดปัญหา "Filesort" ที่ทำให้ CPU โหลดหนัก
```sql
-- สำหรับหน้า Dashboard ที่เรียงตามวันที่อัปเดตล่าสุด
ALTER TABLE `documents`
ADD INDEX `idx_project_updated` (`project_id`, `updated_at` DESC);
-- สำหรับ Inbox / Pending Tasks ของ User
ALTER TABLE `workflow_instances`
ADD INDEX `idx_assignee_status` (`assignee_id`, `status`, `created_at` DESC);
```
### 3.2 Full-Text Search (ทางเลือกเบื้องต้นก่อนใช้ Elasticsearch)
หากผู้ใช้ต้องการค้นหาจากชื่อเอกสาร (`title`) หรือเนื้อหาบางส่วน
```sql
-- สร้าง Full-Text Index สำหรับคำค้นหา
ALTER TABLE `documents`
ADD FULLTEXT INDEX `ft_idx_doc_title` (`title`, `subject`);
```
*(หมายเหตุ: หากอนาคตมีระบบ OCR หรือค้นหาในเนื้อหาไฟล์ PDF ให้พิจารณาขยับไปใช้ Elasticsearch แยกต่างหาก ไม่ควรเก็บ Full-Text ขนาดใหญ่ไว้ใน MySQL)*
---
## 4. RBAC & Security Indexes
เพื่อป้องกันปัญหาคอขวด (Bottleneck) ตอนเช็คสิทธิ์ (RBAC validation) ก่อนให้เข้าถึงเอกสาร
```sql
-- ตาราง user_permissions
ALTER TABLE `user_permissions`
ADD UNIQUE INDEX `idx_user_role_project` (`user_id`, `role_id`, `project_id`);
-- ตาราง document_access_logs (Audit)
ALTER TABLE `audit_logs`
ADD INDEX `idx_audit_user_action` (`user_id`, `action`, `created_at`);
```
---
## 5. Audit Log Strategy (การจัดการตารางประวัติ)
ตาราง `audit_logs` จะโตเร็วมาก (Insert-only) คาดว่าจะมีหลักล้าน Record อย่างรวดเร็ว
**คำแนะนำสำหรับ On-Premise:**
1. **Partitioning:** แนะนำให้ทำ Table Partitioning ตามเดือน (Monthly) หรือปี (Yearly) บนคอลัมน์ `created_at`
2. **Minimal Indexing:** ห้ามสร้าง Index เยอะเกินความจำเป็นในตารางนี้ แนะนำแค่:
* `INDEX(document_id, created_at)` สำหรับดู History ของเอกสารนั้นๆ
* `INDEX(user_id, created_at)` สำหรับตรวจสอบพฤติกรรมผู้ใช้ต้องสงสัย (Security Audit)
```sql
-- ตัวอย่างการ Index สำหรับดูกระแสของเอกสาร
ALTER TABLE `audit_logs`
ADD INDEX `idx_entity_history` (`entity_type`, `entity_id`, `created_at` DESC);
```
---
## 6. Maintenance & Optimization (DevOps/Admin)
เนื่องจากระบบอยู่บน On-Prem NAS (QNAP/ASUSTOR) ทรัพยากร I/O ของดิสก์มีจำกัด (Disk IOPS)
* **Index Defragmentation:** ให้กำหนด Scheduled Task (ผ่าน Cronjob หรือ MySQL Event) มารัน `OPTIMIZE TABLE` ทุกๆ ไตรมาส สำหรับตารางที่มีการ Delete/Update บ่อย (ช่วยคืนพื้นที่ดิสก์และลด I/O)
* **Slow Query Monitoring:** ใน `04-infrastructure-ops/04-01-docker-compose.md` ต้องเปิดใช้งาน `slow_query_log=1` และตั้ง `long_query_time=2` เพื่อตรวจสอบว่ามี Query ใดทำงานแบบ Full Table Scan (ไม่ใช้ Index) หรือไม่
## 💡 คำแนะนำเพิ่มเติมจาก Architect (Architect's Notes):
1. **เรื่อง Soft Delete กับ Unique Constraint:** เป็นจุดที่นักพัฒนาพลาดกันบ่อยที่สุด ถ้าระบบอนุญาตให้ลบ `DOC-001 Rev.0` แล้วสร้าง `DOC-001 Rev.0` ใหม่ได้ การจัดการ Unique Constraint บน MySQL ต้องใช้ Functional Index (ตามตัวอย่างในข้อ 2.1) เพื่อป้องกันการตีกันของค่า `NULL` ในฐานข้อมูล
2. **ลดภาระ QNAP/ASUSTOR:** อุปกรณ์จำพวก NAS On-Premise มักจะมีปัญหาเรื่อง Random Read/Write Disk I/O การใช้ **Composite Index** แบบครบคลุม (Covering Index) จะช่วยให้ MySQL คืนค่าได้จาก Index Tree โดยตรง ไม่ต้องกระโดดไปอ่าน Data File จริง ซึ่งจะช่วยรีด Performance ของ NAS ได้สูงสุดครับ

View File

@@ -0,0 +1,315 @@
# 3.3 File Storage and Handling
---
title: 'Data & Storage: File Storage and Handling (Two-Phase)'
version: 1.8.0
status: drafted
owner: Nattanin Peancharoen
last_updated: 2026-02-22
related:
- specs/01-requirements/01-03.10-file-handling.md (Merged)
- specs/03-Data-and-Storage/ADR-003-file-storage-approach.md (Merged)
- specs/02-architecture/02-01-system-architecture.md
- ADR-006-security-best-practices
---
## 1. Overview and Core Infrastructure Requirements
เอกสารฉบับนี้รวบรวมข้อกำหนดการจัดการไฟล์และการจัดเก็บไฟล์ (File Storage Approach) สำหรับ LCBP3-DMS โดยมีข้อบังคับด้าน Infrastructure และ Security ที่สำคัญมากดังต่อไปนี้:
### 1.1 Infrastructure Requirement (การจัดเก็บและ Mount Volume)
**สำคัญ (CRITICAL SPECIFICATION):**
1. **Outside Webroot:** ไฟล์รูปและเอกสารทั้งหมดต้องถูกจัดเก็บไว้ **ภายนอก Webroot ของ Application** ห้ามเก็บไฟล์รูปหรือเอกสารไว้ใน Container หรือโฟลเดอร์ Webroot เด็ดขาด เพื่อป้องกันการเข้าถึงไฟล์โดยตรงจากสาธารณะ (Direct Public Access)
2. **QNAP Volume Mount:** ต้องใช้ **QNAP Volume Mount เข้า Docker** (Mount external volume from QNAP NAS to Docker container) สำหรับเป็นพื้นที่เก็บไฟล์ Storage ให้ Container ดึงไปใช้งาน
3. **Authenticated Endpoint:** ไฟล์ต้องถูกเข้าถึงและให้บริการดาวน์โหลดผ่าน Authenticated Endpoint ในฝั่ง Backend เท่านั้น โดยต้องผ่านการตรวจสอบสิทธิ์ (RBAC / Junction Table) เสียก่อน
### 1.2 Access & Security Rules
- **Virus Scan:** ต้องมีการ scan virus สำหรับไฟล์ที่อัปโหลดทั้งหมด โดยใช้ ClamAV หรือบริการ third-party ก่อนการบันทึก
- **Whitelist File Types:** อนุญาตเฉพาะเอกสารตามที่กำหนด: PDF, DWG, DOCX, XLSX, ZIP
- **Max File Size:** ขนาดไฟล์สูงสุดไม่เกิน 50MB ต่อไฟล์ (Total max 500MB per form submission)
- **Expiration Time:** Download links ที่สร้างขึ้นต้องมี expiration time (default: 24 ชั่วโมง)
- **Integrity Check:** ต้องมี file integrity check (checksum เป็น SHA-256) เพื่อป้องกันการแก้ไขไฟล์ภายหลัง
- **Audit Logging:** ต้องบันทึก audit log ทุกครั้งที่มีการดาวน์โหลดไฟล์สำคัญ
---
## 2. Two-Phase File Storage Approach (ADR-003)
### 2.1 Context and Problem Statement
LCBP3-DMS ต้องจัดการ File Uploads สำหรับ Attachments ของเอกสาร (PDF, DWG, DOCX, etc.) โดยต้องรับมือกับปัญหา:
1. **Orphan Files:** User อัปโหลดไฟล์แล้วไม่ Submit Form ทำให้ไฟล์ค้างใน Storage
2. **Transaction Integrity:** ถ้า Database Transaction Rollback ไฟล์ยังอยู่ใน Storage ต้องสอดคล้องกับ Database Record
3. **Virus Scanning:** ต้อง Scan ไฟล์ก่อน Save เข้าระบบถาวร
4. **File Validation:** ตรวจสอบ Type, Size, และสร้าง Checksum
5. **Storage Organization:** จัดเก็บไฟล์แยกเป็นสัดส่วน (เพื่อไม่ให้ QNAP Storage กระจัดกระจายและจำกัดขนาดได้)
### 2.2 Decision Drivers
- **Data Integrity:** File และ Database Record ต้อง Consistent
- **Security:** ป้องกัน Virus และ Malicious Files
- **User Experience:** Upload ต้องรวดเร็ว ไม่ Block UI (ถ้าอัปโหลดพร้อม Submit อาจทำให้ระบบดูค้าง)
- **Storage Efficiency:** ไม่เก็บไฟล์ที่ไม่ถูกใช้งาน (Orphan files)
- **Auditability:** ติดตามประวัติ File Operations ได้
### 2.3 Considered Options & Decision
- **Option 1:** Direct Upload to Permanent Storage (ทิ้งไฟล์ถ้า Transaction Fail / ได้ Orphan Files) - ❌
- **Option 2:** Upload after Form Submission (UX แย่ ผู้ใช้ต้องรออัปโหลดรวดเดียวท้ายสุด) - ❌
- **Option 3: Two-Phase Storage (Temp → Permanent) ⭐ (Selected Option)** - ✅
**แนวทาง Two-Phase Storage (Temp → Permanent):**
1. **Phase 1 (Upload):** ไฟล์ถูกอัปโหลดเข้าโฟลเดอร์ `temp/` ได้รับ `temp_id`
2. **Phase 2 (Commit):** เมื่อ User กด Submit ฟอร์มสำเร็จ ระบบจะย้ายไฟล์จาก `temp/` ไปยัง `permanent/{YYYY}/{MM}/` และบันทึกลง Database ใน Transaction เดียวกัน
3. **Cleanup:** มี Cron Job ทำหน้าที่ลบไฟล์ใน `temp/` ที่ค้างเกินกำหนด (เช่น 24 ชั่วโมง)
---
## 3. Implementation Details
### 3.1 Database Schema
```sql
CREATE TABLE attachments (
id INT PRIMARY KEY AUTO_INCREMENT,
original_filename VARCHAR(255) NOT NULL,
stored_filename VARCHAR(255) NOT NULL, -- UUID-based
file_path VARCHAR(500) NOT NULL, -- QNAP Mount path
mime_type VARCHAR(100) NOT NULL,
file_size INT NOT NULL,
checksum VARCHAR(64) NULL, -- SHA-256
-- Two-Phase Fields
is_temporary BOOLEAN DEFAULT TRUE,
temp_id VARCHAR(100) NULL, -- UUID for temp reference
expires_at DATETIME NULL, -- Temp file expiration
uploaded_by_user_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (uploaded_by_user_id) REFERENCES users(user_id),
INDEX idx_temp_files (is_temporary, expires_at)
);
```
### 3.2 Two-Phase Storage Flow
```mermaid
sequenceDiagram
participant User as Client
participant BE as Backend
participant Virus as ClamAV
participant TempStorage as Temp Storage (QNAP Volume)
participant PermStorage as Permanent Storage (QNAP Volume)
participant DB as Database
Note over User,DB: Phase 1: Upload to Temporary Storage
User->>BE: POST /attachments/upload (file)
BE->>BE: Validate file type, size
BE->>Virus: Scan virus
alt File is CLEAN
Virus-->>BE: CLEAN
BE->>BE: Generate temp_id (UUID)
BE->>BE: Calculate SHA-256 checksum
BE->>TempStorage: Save to temp/{temp_id}
BE->>DB: INSERT attachment<br/>(is_temporary=TRUE, expires_at=NOW+24h)
BE-->>User: { temp_id, expires_at }
else File is INFECTED
Virus-->>BE: INFECTED
BE-->>User: Error: Virus detected
end
Note over User,DB: Phase 2: Commit to Permanent Storage
User->>BE: POST /correspondences<br/>{ temp_file_ids: [temp_id] }
BE->>DB: BEGIN Transaction
BE->>DB: INSERT correspondence
loop For each temp_file_id
BE->>TempStorage: Read temp file
BE->>PermStorage: Move to permanent/{YYYY}/{MM}/{UUID}
BE->>DB: UPDATE attachment<br/>(is_temporary=FALSE, file_path=new_path)
BE->>DB: INSERT correspondence_attachments
BE->>TempStorage: DELETE temp file
end
BE->>DB: COMMIT Transaction
BE-->>User: Success
Note over BE,TempStorage: Cleanup Job (Every 6 hours)
BE->>DB: SELECT expired temp files
BE->>TempStorage: DELETE expired physical files
BE->>DB: DELETE attachment records
```
### 3.3 NestJS Service Implementation
```typescript
// file-storage.service.ts
import { Injectable, BadRequestException, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron } from '@nestjs/schedule';
import { createHash } from 'crypto';
import * as fs from 'fs-extra';
import * as path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { LessThan } from 'typeorm';
@Injectable()
export class FileStorageService {
private readonly TEMP_DIR: string;
private readonly PERMANENT_DIR: string;
private readonly TEMP_EXPIRY_HOURS = 24;
private readonly logger = new Logger(FileStorageService.name);
constructor(private config: ConfigService) {
// 💡 Must point to the QNAP Volume mount inside the container!
this.TEMP_DIR = this.config.get('STORAGE_PATH') + '/temp';
this.PERMANENT_DIR = this.config.get('STORAGE_PATH') + '/permanent';
}
// Phase 1: Upload to Temporary
async uploadToTemp(file: Express.Multer.File): Promise<UploadResult> {
// 1. Validate file (Size & Type)
this.validateFile(file);
// 2. Virus scan (ClamAV)
await this.virusScan(file);
// 3. Generate temp ID and File paths
const tempId = uuidv4();
const storedFilename = `${tempId}_${file.originalname}`;
const tempPath = path.join(this.TEMP_DIR, storedFilename);
// 4. Calculate checksum
const checksum = await this.calculateChecksum(file.buffer);
// 5. Save to temp directory (Outside Webroot via volume mount)
await fs.writeFile(tempPath, file.buffer);
// 6. Create attachment record in DB (Example assuming typeorm usage)
const attachment = await this.attachmentRepo.save({
original_filename: file.originalname,
stored_filename: storedFilename,
file_path: tempPath,
mime_type: file.mimetype,
file_size: file.size,
checksum,
is_temporary: true,
temp_id: tempId,
expires_at: new Date(Date.now() + this.TEMP_EXPIRY_HOURS * 3600 * 1000),
uploaded_by_user_id: this.currentUserId,
});
return {
temp_id: tempId,
expires_at: attachment.expires_at,
filename: file.originalname,
size: file.size,
};
}
// Phase 2: Commit to Permanent (within Transaction Manager)
async commitFiles(tempIds: string[], entityId: number, entityType: string, manager: EntityManager): Promise<Attachment[]> {
const attachments = [];
for (const tempId of tempIds) {
const tempAttachment = await manager.findOne(Attachment, { where: { temp_id: tempId, is_temporary: true } });
if (!tempAttachment) throw new Error(`Temporary file not found: ${tempId}`);
if (tempAttachment.expires_at < new Date()) throw new Error(`Temporary file expired: ${tempId}`);
// Generate permanent path: permanent/YYYY/MM
const now = new Date();
const year = now.getFullYear();
const month = (now.getMonth() + 1).toString().padStart(2, '0');
const permanentDir = path.join(this.PERMANENT_DIR, year.toString(), month);
await fs.ensureDir(permanentDir);
const permanentFilename = `${uuidv4()}_${tempAttachment.original_filename}`;
const permanentPath = path.join(permanentDir, permanentFilename);
// Move file physically in QNAP Volume
await fs.move(tempAttachment.file_path, permanentPath);
// Update Database record
await manager.update(Attachment, { id: tempAttachment.id }, {
file_path: permanentPath,
stored_filename: permanentFilename,
is_temporary: false,
temp_id: null,
expires_at: null,
});
attachments.push(tempAttachment);
}
return attachments;
}
// Phase 3: Cleanup Job (Cron)
@Cron('0 */6 * * *') // Every 6 hours
async cleanupExpiredFiles(): Promise<void> {
const expiredFiles = await this.attachmentRepo.find({
where: { is_temporary: true, expires_at: LessThan(new Date()) },
});
for (const file of expiredFiles) {
try {
await fs.remove(file.file_path);
await this.attachmentRepo.remove(file);
this.logger.log(`Cleaned up expired file: ${file.temp_id}`);
} catch (error) {
this.logger.error(`Failed to cleanup file: ${file.temp_id}`, error);
}
}
}
private validateFile(file: Express.Multer.File): void {
const allowedTypes = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
// ... (DOCX, WHiteListed Mimetypes + XLSX, DWG, ZIP)
];
const maxSize = 50 * 1024 * 1024; // 50MB
if (!allowedTypes.includes(file.mimetype)) throw new BadRequestException('Invalid file type');
if (file.size > maxSize) throw new BadRequestException('File too large (max 50MB)');
// 💡 Add Magic Number Verification logic in real implementation to avoid simple extension spoofing
}
private async virusScan(file: Express.Multer.File): Promise<void> {
// ClamAV integration example
// const scanner = await this.clamAV.scan(file.buffer);
// if (scanner.isInfected) throw new BadRequestException('Virus detected in file');
}
private async calculateChecksum(buffer: Buffer): Promise<string> {
return createHash('sha256').update(buffer).digest('hex');
}
}
```
### 3.4 API Controller Context
ในส่วนของตัว Controller ฝ่ายรับข้อมูลจะต้องแยกระหว่าง Uploading กับ Comit:
1. `POST /attachments/upload` ใช้เพื่อรับไฟล์และ Return `temp_id` แก่ User ทันที
2. `POST /correspondences` หรือ Object อื่นๆ ใช้เพื่อ Commit Database โดยจะรับ `temp_file_ids: []` พ่วงมากับ Body form
---
## 4. Consequences & Mitigation Strategies
### Positive Consequences
1.**Fast Upload UX:** User upload แบบ Async ก่อน Submit ดำเนินการลื่นไหล
2.**No Orphan Files:** เกิดระบบ Auto-cleanup จัดการไฟล์หมดอายุโดยอัตโนมัติ ไม่เปลืองสเปซ QNAP
3.**Transaction Safe:** Rollback ได้สมบูรณ์หากบันทึกฐานข้อมูลผิดพลาด ไฟล์จะถูก Cron จัดการให้ทีหลังไม่ตกค้างในระบบ
4.**Security:** Virus scan ไฟล์ก่อน Commit เข้าถึงข้อมูล Sensitive Area
5.**Audit Trail:** ติดตามประวัติ Operations ต่างๆ เพื่อความโปร่งใส
6.**Storage Organization:** จัดเก็บอย่างเป็นระเบียบ ด้วยรูปแบบ YYYY/MM ลดคอขวด IO Operations ในระบบ
### Negative Consequences & Mitigations
1.**Complexity:** ต้อง Implement 2 phases ซึ่งซับซ้อนขึ้น
👉 *Mitigation:* รวบ Logic ทุกอย่างให้เป็น Service ชั้นเดียว (`FileStorageService`) เพื่อให้จัดการง่ายและเรียกใช้ง่ายที่สุด
2.**Extra Storage:** ต้องใช้พื้นที่ QNAP ในส่วน Temp directory ควบคู่ไปกับแบบ Permanent
👉 *Mitigation:* คอย Monitor และปรับรอบความถี่ของการ Cleanup หากไฟล์มีปริมาณไหลเวียนเยอะมาก
3.**Edge Cases:** อาจเกิดประเด็นเรื่อง File lock หรือ missing temp files
👉 *Mitigation:* ทำ Proper error handling พร้อม Logging ให้ตรวจสอบได้ง่าย
---
## 5. Performance Optimization Consideration
- **Streaming:** ใช้ multipart/form-data streaming เพิ่อลดภาระ Memory ของฝั่งเครื่องเซิฟเวอร์ (NestJS) ขณะสูบไฟล์ใหญ่ๆ
- **Compression:** พิจารณาเรื่องการบีบอัดสำหรับไฟล์ขนาดใหญ่หรือบางประเภท
- **Deduplication Check:** สามารถใช้งาน Field `checksum` ดักการ Commit ด้วยข้อมูลชุดเดิมที่เคยถูกอัปโหลดเพื่อประหยัดพื้นที่จัดเก็บ (Deduplicate)

Some files were not shown because too many files have changed in this diff Show More