Lịch sử thao tác dữ liệu
Ghi lịch sử tác động dữ liệu theo thao tác nghiệp vụ: ai làm gì, khi nào, trên đối tượng nào và dữ liệu nào đã thay đổi. Dùng khi cần lịch sử thay đổi cho hồ sơ/nghiệp vụ mà không muốn tự viết diff thủ công trong service.
Module này chỉ ghi lịch sử tác động dữ liệu, không phải cơ chế versioning: không đánh số phiên bản, không dựng snapshot và không khôi phục dữ liệu. Cần dựng phiên bản/khôi phục thì dùng govex-cloud-data-revision — module đó đọc lại bảng audit_log do module này ghi.
Khi nào sử dụng
- Cần lưu vết thay đổi dữ liệu (thêm/sửa/xóa) theo thao tác nghiệp vụ để hiển thị lịch sử.
- Muốn gom thay đổi từ nhiều bảng chi tiết về một đối tượng nghiệp vụ chính (target entity).
- Cần biết ai (
userId/userName), khi nào (actionTime), qua API nào (httpMethod/httpUrl) đã tác động dữ liệu. - Cần tra cứu nhanh lịch sử theo đối tượng để phục vụ đối soát việc chỉnh sửa dữ liệu.
Không dùng module để: lưu phiên bản dữ liệu nhằm khôi phục, thay thế transaction undo log, ghi log kỹ thuật/request log/distributed trace, hay audit mọi câu SQL ở mức database forensic.
Cài đặt
<dependency>
<groupId>vn.govex.cloud</groupId>
<artifactId>govex-cloud-data-audit</artifactId>
</dependency>
Version quản lý qua BOM — xem Cài đặt.
Module cần chạy cùng govex-cloud-data-jpa (metadata entity) và govex-cloud-datasource (change data); hai dependency này đi kèm qua transitive dependency.
Cấu hình
Tính năng mặc định tắt; phải bật bằng govex.audit.enabled=true. Các property (prefix govex.audit):
| Property | Mô tả | Mặc định |
|---|---|---|
govex.audit.enabled | Bật/tắt toàn bộ tính năng audit dữ liệu | false |
govex.audit.allowed-request-methods | Các HTTP method được gắn ngữ cảnh audit | POST, PUT, PATCH, DELETE |
govex.audit.packages-to-scan | Danh sách package được quét; rỗng nghĩa là mọi package | rỗng |
govex.audit.packages-to-exclude | Danh sách package bỏ qua | rỗng |
govex.audit.paths-to-match | Pattern đường dẫn được audit; rỗng nghĩa là mọi đường dẫn | rỗng |
govex.audit.paths-to-exclude | Pattern đường dẫn bỏ qua | rỗng |
govex.audit.ignore-tables | Danh sách bảng bỏ qua khi thu thập thay đổi | rỗng |
govex.audit.log-only-change-field | Chỉ ghi nhận cột có giá trị thay đổi khi cập nhật | true |
govex.audit.perform-scan | Quét request mapping khi khởi động để tra cứu metadata API | true |
govex:
audit:
enabled: true
allowed-request-methods:
- POST
- PUT
ignore-tables:
- audit_log
Phân biệt hai lớp cấu hình để tránh hiểu nhầm:
allowed-request-methods,packages-to-*,paths-to-*chỉ chi phối việc gắn ngữ cảnh cho request HTTP (metadata API,traceId, actor, tên đăng nhập). Chúng không chặn interceptor AOP của@Audit: method nghiệp vụ gắn@Auditvẫn được ghi lịch sử dù request nằm ngoài package/đường dẫn cấu hình.perform-scan=falsebỏ qua bước quét request mapping khi khởi động; khi đó bản ghi audit không cóhttpMethod,httpUrl,applicationIdvàtraceId.ignore-tablesso khớp không phân biệt hoa thường và bỏ qua tiền tố schema (ví dụschema.audit_logkhớpaudit_log).
Bảng audit_log
Module không tạo bảng bằng Hibernate. Ứng dụng phải tạo bảng trước khi bật tính năng, bằng Liquibase/Flyway hoặc script triển khai. Script tham khảo có trong dependency: src/main/resources/script/mysql.sql và oracle.sql.
CREATE TABLE IF NOT EXISTS `audit_log`
(
`id` varchar(128) NOT NULL COMMENT 'audit log id',
`action_code` varchar(128) NOT NULL COMMENT 'Mã hành động',
`action_name` varchar(512) DEFAULT NULL COMMENT 'Tên hành động',
`action_type` varchar(32) NOT NULL COMMENT 'Loại hành động',
`target_type` varchar(128) DEFAULT NULL COMMENT 'Loại đối tượng nghiệp vụ chính',
`target_id` varchar(128) DEFAULT NULL COMMENT 'ID đối tượng nghiệp vụ chính',
`changes` longtext DEFAULT NULL COMMENT 'Danh sách thay đổi dạng JSON',
`status` varchar(32) NOT NULL COMMENT 'Trạng thái ghi nhận',
`error_message` longtext DEFAULT NULL COMMENT 'Thông báo lỗi',
`user_id` varchar(128) DEFAULT NULL COMMENT 'ID người thao tác',
`user_name` varchar(255) DEFAULT NULL COMMENT 'Tên người thao tác',
`application_id` varchar(128) DEFAULT NULL COMMENT 'Tên ứng dụng hoặc service',
`trace_id` varchar(128) DEFAULT NULL COMMENT 'TraceId',
`http_method` varchar(32) DEFAULT NULL COMMENT 'HTTP method',
`http_url` varchar(512) DEFAULT NULL COMMENT 'HTTP uri',
`action_time` datetime(6) DEFAULT current_timestamp(6) COMMENT 'Thời gian tác động',
`duration_ms` bigint DEFAULT NULL COMMENT 'Thời gian xử lý',
`context` longtext DEFAULT NULL COMMENT 'Ngữ cảnh mở rộng dạng JSON',
PRIMARY KEY `pk_audit_log` (`id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
Index khuyến nghị để tra cứu theo đối tượng và theo người thao tác:
CREATE INDEX ix_audit_log_target ON audit_log (target_type, target_id, action_time);
CREATE INDEX ix_audit_log_user ON audit_log (user_id, action_time);
Tên bảng mặc định là audit_log; có thể đổi qua constructor DefaultAuditLogService(transactionManager, tableName) khi khai báo bean ghi đè.
Sử dụng
Bước 1 — Đánh dấu thao tác nghiệp vụ bằng @Audit
Khi method chạy thành công, module ghi một bản ghi vào audit_log. Nếu capture = true (mặc định), module còn thu thập thay đổi dữ liệu trong transaction và đưa vào cột changes:
@Audit(
code = "TAO_HO_SO",
name = "Tạo hồ sơ",
actionType = AuditActionType.WRITE,
targetEntity = Document.class,
targetId = "#result",
capture = true
)
public String createDocument(DocumentCreateRequest request) {
Document document = new Document();
// lưu dữ liệu nghiệp vụ
return repository.save(document).getId();
}
| Thuộc tính | Bắt buộc | Mặc định | Ý nghĩa |
|---|---|---|---|
code | Có | — | Mã thao tác nghiệp vụ, ví dụ TAO_HO_SO. |
name | Có | — | Tên hiển thị của thao tác. |
actionType | Không | WRITE | Nhóm thao tác: READ, WRITE, EXECUTE. |
targetEntity | Không | Void.class | Entity nghiệp vụ chính để gom lịch sử. |
targetId | Không | chuỗi rỗng | SpEL hoặc giá trị tĩnh xác định ID target. |
capture | Không | true | Thu thập diff dữ liệu trong transaction hay không. |
@Audit đặt được trên method hoặc class service (áp dụng cho mọi method của class). Hai hành vi cần nhớ để tránh "không thấy lịch sử":
targetEntitymặc định làVoid.class. Listener thu thập thay đổi bỏ qua toàn bộ bảng khitargetEntitykhông trỏ tới entity được cấu hình audit; vì vậy hãy khai báotargetEntity(và đánh dấu@AuditedEntitycho entity tương ứng).- Với
capture = true, nếu transaction không phát sinh thay đổi nào được nhận diện, module không ghi bản ghi audit (tránh nhiễu khi người dùng lưu lại mà dữ liệu không đổi). Đặtcapture = falsekhi luôn cần một bản ghi nghiệp vụ, ví dụ thao tác chỉ gọi hệ thống ngoài.
Xác định targetId
targetId nhận biểu thức SpEL khi bắt đầu bằng #; giá trị không bắt đầu bằng # được coi là giá trị tĩnh:
@Audit(code = "CAP_NHAT_HO_SO", name = "Cập nhật hồ sơ",
targetEntity = Document.class, targetId = "#id")
public void update(String id, DocumentUpdateRequest request) {
// xử lý nghiệp vụ
}
Khi ID chỉ có sau khi method trả kết quả, dùng #result (hỗ trợ cả #result.id):
@Audit(code = "TAO_HO_SO", name = "Tạo hồ sơ",
targetEntity = Document.class, targetId = "#result")
public String create(DocumentCreateRequest request) {
return repository.save(entity).getId();
}
Khi không truyền được targetId từ method, bỏ trống thuộc tính này: datasource listener lấy ID target từ cột targetIdColumn của entity bị thay đổi. Ví dụ @Audit(code = "XOA_CHI_TIET_HO_SO", name = "Xóa chi tiết hồ sơ", targetEntity = Document.class) áp cho method xóa mềm một DocumentDetail.
Bước 2 — Khai báo entity capture diff bằng @AuditedEntity
@AuditedEntity khai báo bảng nào được capture diff và bảng đó liên kết về target entity bằng cột nào. Với entity chính:
@Entity
@Table(name = "DOCUMENT")
@AuditedEntity(
idColumn = "ID",
targetEntity = Document.class,
ignoreColumns = {"FORM_DATA", "INTERNAL_DATA"}
)
public class Document {
@Id
@Column(name = "ID")
private String id;
}
Với entity liên quan, dùng targetIdColumn để trỏ về target entity:
@Entity
@Table(name = "DOCUMENT_DETAIL")
@AuditedEntity(idColumn = "ID", targetEntity = Document.class, targetIdColumn = "DOCUMENT_ID")
public class DocumentDetail {
@Id
@Column(name = "ID")
private String id;
@Column(name = "DOCUMENT_ID")
private String documentId;
}
| Thuộc tính | Bắt buộc | Mặc định | Ý nghĩa |
|---|---|---|---|
idColumn | Có | — | Cột định danh row đang thay đổi (khóa chính). |
targetEntity | Không | chính entity hiện tại | Entity nghiệp vụ chính để gom lịch sử. |
targetIdColumn | Không | chính idColumn | Cột trên bảng hiện tại chứa ID target entity. |
ignoreColumns | Không | mảng rỗng | Các cột bỏ qua khi build diff. |
Bước 3 — Metadata trường bằng @AuditedField
@AuditedField bổ sung nhãn hiển thị và ánh xạ giá trị cho diff:
@Column(name = "STATUS")
@AuditedField(
label = "Trạng thái",
values = {
@AuditedField.Value(value = "DRAFT", label = "Bản nháp"),
@AuditedField.Value(value = "ACTIVE", label = "Đang hiệu lực")
}
)
private String status;
| Thuộc tính | Ý nghĩa |
|---|---|
label | Nhãn tiếng Việt hiển thị trong lịch sử; để trống thì dùng tên field Java. |
ignore | Bỏ qua trường khi build diff. |
hidden | Không hiển thị trường trong lịch sử dù vẫn thu thập. |
deletedColumn | Đánh dấu cột xóa mềm: update bật cột này được hiểu là thao tác xóa. |
values | Mảng @AuditedField.Value(value, label) ánh xạ mã kỹ thuật sang nhãn đọc được. |
Loại trừ tường minh một field bằng @NotAudited:
@NotAudited
@Column(name = "DU_LIEU_NOI_BO")
private String duLieuNoiBo;
- Field không có
@AuditedFieldmặc định bị bỏ qua khi build diff — chỉ field được đánh dấu mới tham gia lịch sử.@NotAuditedvàignoreColumnsdùng để loại trừ tường minh. - Với entity dùng xóa mềm, cột
@SoftDeletecủa Hibernate được nhận diện tự động và ẩn khỏi diff; update bật cột xóa mềm được ghi nhận như thao tácDELETE.
Bước 4 — Chốt nhãn giá trị tại thời điểm ghi với @ValueLookup
Audit là dữ liệu lịch sử: nhãn hiển thị phải được chốt khi ghi, không resolve lại khi đọc. Module lưu oldLabel/newLabel trong JSON changes. Nếu entity đã có cột tên snapshot, audit trực tiếp cột tên và loại trừ cột mã — ví dụ OWNER_CODE đánh @NotAudited, OWNER_NAME đánh @AuditedField(label = "Người phụ trách").
Với trường chỉ lưu mã, dùng @ValueLookup để ứng dụng resolve nhãn lúc ghi. Framework không hiểu ý nghĩa nghiệp vụ của type/key — chúng là hợp đồng riêng của ứng dụng:
@Column(name = "STATUS")
@AuditedField(label = "Trạng thái")
@ValueLookup(type = "DOCUMENT_STATUS", key = "DOCUMENT_STATUS")
private String status;
Ứng dụng đăng ký ValueLookupResolver:
@Component
public class DocumentStatusValueLookupResolver implements ValueLookupResolver {
@Override
public boolean supports(ValueLookupContext context) {
return "DOCUMENT_STATUS".equals(context.lookupType());
}
@Override
public Map<String, String> resolve(ValueLookupContext context, Collection<String> values) {
return lookupService.findLabels(context.lookupKey(), values);
}
}
Resolver nhận nhiều giá trị trong một lần gọi (oldValue + newValue); nên xử lý theo lô, tránh truy vấn từng giá trị. Khi resolver không tìm được nhãn, framework fallback về raw value để không mất thông tin.
Bước 5 — Ghi chú nghiệp vụ cho bản ghi
Trong luồng nghiệp vụ có thể đính kèm ghi chú (lý do hủy, ý kiến phê duyệt...) vào bản ghi audit đang hoạt động bằng AuditContext.attachNote("Hủy theo đề nghị của người nộp hồ sơ"); giá trị lưu vào cột context. Nếu gọi khi không có @Audit đang chạy, lệnh được bỏ qua.
Bước 6 — Tra cứu lịch sử theo đối tượng
List<AuditEntry> activities = auditLogService.findByTarget(
Document.class.getSimpleName(), documentId);
// giới hạn số bản ghi trả về
List<AuditEntry> latest = auditLogService.findByTarget(
Document.class.getSimpleName(), documentId, 20);
Kết quả sắp xếp mới nhất trước (ORDER BY action_time DESC, id DESC). Cài đặt mặc định giới hạn 100 bản ghi (DefaultAuditLogService.DEFAULT_FIND_LIMIT); limit truyền vào phải lớn hơn 0. targetType lưu theo Class.getSimpleName() của target entity.
API tham chiếu
AuditLogService có ba method: record(AuditEntry) (bản ghi null bị bỏ qua, lỗi ghi chỉ được log và không ném ra ngoài), findByTarget(targetType, targetId) (mới nhất trước, giới hạn 100 bản ghi) và findByTarget(targetType, targetId, limit).
AuditEntry (bản ghi đã chuẩn hóa): id, actionCode, actionName, actionType, targetType, targetId, changes, status, errorMessage, userId, userName, applicationId, traceId, httpMethod, httpUrl, actionTime, durationMs, context. AuditChange (một thay đổi cấp trường): tableName, entityName, rowId, field, label, oldValue, newValue, oldLabel, newLabel. AuditActionType: READ, WRITE, EXECUTE.
Luồng hoạt động
Sơ đồ dưới đây mô tả luồng audit: thao tác gắn @Audit được AOP bắt, thu thập diff before/after và ghi vào audit_log.
Thứ tự xử lý runtime:
AuditAnnotationAdvisorápAuditTransactionInterceptorcho method gắn@Auditở mức ưu tiên cao nhất, tạoAuditContext(mã, tên,targetEntity,targetId,capture) trước khi transaction mở kết nối JDBC đầu tiên.- Business method chạy;
AuditChangeConnectionListenerđọc change data từ datasource trước commit và chuyển mỗi row thay đổi thànhAuditRowChange(kèmrowId,targetId). AuditChangeBuilderchuyển row changes thành danh sáchAuditChangedễ đọc; nếu field có@ValueLookup, resolveoldLabel/newLabelngay tại thời điểm ghi.- Sau khi transaction kết thúc, interceptor dựng
AuditEntryvà gọiAuditLogService.record(...)(cài đặt mặc định ghi bằng transaction riêngREQUIRES_NEW); nếu transaction nghiệp vụ rollback, bản ghi audit success không được ghi, còn lỗi khi ghi audit được log và bỏ qua.
Ví dụ hoàn chỉnh
Ghép các mảnh ở trên: entity Document và DocumentDetail gắn @AuditedEntity như Bước 2, service dưới đây chỉ khai báo @Audit với targetEntity — không truyền targetId, framework vẫn xác định được ID hồ sơ từ DOCUMENT_DETAIL.DOCUMENT_ID nhờ targetIdColumn:
@Service
@RequiredArgsConstructor
public class DocumentDetailService {
private final DocumentDetailRepository repository;
@Audit(
code = "CAP_NHAT_KET_QUA_CHI_TIET",
name = "Cập nhật kết quả chi tiết",
actionType = AuditActionType.WRITE,
targetEntity = Document.class
)
public void update(String id, DocumentDetailRequest request) {
DocumentDetail entity = repository.findById(id).orElseThrow();
entity.setResult(request.getResult());
repository.save(entity);
}
}
Lưu ý
- Bắt buộc
govex.audit.enabled=true; mặc định tính năng tắt hoàn toàn. @Auditchạy qua proxy Spring AOP: lời gọi nội bộ trong cùng bean (self-invocation) không đi qua interceptor; cần gọi qua bean khác để ghi lịch sử.- Nhãn giá trị trong
changeslà snapshot tại thời điểm ghi; không resolve lại khi đọc lịch sử. - Không đưa dữ liệu lớn/JSON form động/binary vào diff; cấu hình
ignoreColumnshoặc@NotAudited. targetTypelưu theoClass.getSimpleName();changeslưu dạng JSON. Nếu hệ thống từng dùng schema cũ (root_type,root_id,revision_no...), thêm migration rename/drop thay vì sửa migration đã chạy.- Cần versioning/khôi phục dữ liệu thì dùng
govex-cloud-data-revision.