Chuyển tới nội dung chính

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):

PropertyMô tảMặc định
govex.audit.enabledBật/tắt toàn bộ tính năng audit dữ liệufalse
govex.audit.allowed-request-methodsCác HTTP method được gắn ngữ cảnh auditPOST, PUT, PATCH, DELETE
govex.audit.packages-to-scanDanh sách package được quét; rỗng nghĩa là mọi packagerỗng
govex.audit.packages-to-excludeDanh sách package bỏ quarỗng
govex.audit.paths-to-matchPattern đường dẫn được audit; rỗng nghĩa là mọi đường dẫnrỗng
govex.audit.paths-to-excludePattern đường dẫn bỏ quarỗng
govex.audit.ignore-tablesDanh sách bảng bỏ qua khi thu thập thay đổirỗng
govex.audit.log-only-change-fieldChỉ ghi nhận cột có giá trị thay đổi khi cập nhậttrue
govex.audit.perform-scanQuét request mapping khi khởi động để tra cứu metadata APItrue
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 @Audit vẫn được ghi lịch sử dù request nằm ngoài package/đường dẫn cấu hình.
  • perform-scan=false bỏ qua bước quét request mapping khi khởi động; khi đó bản ghi audit không có httpMethod, httpUrl, applicationIdtraceId.
  • ignore-tables so khớp không phân biệt hoa thường và bỏ qua tiền tố schema (ví dụ schema.audit_log khớp audit_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.sqloracle.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ínhBắt buộcMặc địnhÝ nghĩa
codeMã thao tác nghiệp vụ, ví dụ TAO_HO_SO.
nameTên hiển thị của thao tác.
actionTypeKhôngWRITENhóm thao tác: READ, WRITE, EXECUTE.
targetEntityKhôngVoid.classEntity nghiệp vụ chính để gom lịch sử.
targetIdKhôngchuỗi rỗngSpEL hoặc giá trị tĩnh xác định ID target.
captureKhôngtrueThu 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ử":

  • targetEntity mặc định là Void.class. Listener thu thập thay đổi bỏ qua toàn bộ bảng khi targetEntity không trỏ tới entity được cấu hình audit; vì vậy hãy khai báo targetEntity (và đánh dấu @AuditedEntity cho 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). Đặt capture = false khi 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ínhBắt buộcMặc địnhÝ nghĩa
idColumnCột định danh row đang thay đổi (khóa chính).
targetEntityKhôngchính entity hiện tạiEntity nghiệp vụ chính để gom lịch sử.
targetIdColumnKhôngchính idColumnCột trên bảng hiện tại chứa ID target entity.
ignoreColumnsKhôngmảng rỗngCá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
labelNhãn tiếng Việt hiển thị trong lịch sử; để trống thì dùng tên field Java.
ignoreBỏ qua trường khi build diff.
hiddenKhô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.
valuesMả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ó @AuditedField mặc định bị bỏ qua khi build diff — chỉ field được đánh dấu mới tham gia lịch sử. @NotAuditedignoreColumns dùng để loại trừ tường minh.
  • Với entity dùng xóa mềm, cột @SoftDelete củ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ác DELETE.

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:

  1. AuditAnnotationAdvisor áp AuditTransactionInterceptor cho method gắn @Audit ở mức ưu tiên cao nhất, tạo AuditContext (mã, tên, targetEntity, targetId, capture) trước khi transaction mở kết nối JDBC đầu tiên.
  2. Business method chạy; AuditChangeConnectionListener đọc change data từ datasource trước commit và chuyển mỗi row thay đổi thành AuditRowChange (kèm rowId, targetId).
  3. AuditChangeBuilder chuyển row changes thành danh sách AuditChange dễ đọc; nếu field có @ValueLookup, resolve oldLabel/newLabel ngay tại thời điểm ghi.
  4. Sau khi transaction kết thúc, interceptor dựng AuditEntry và gọi AuditLogService.record(...) (cài đặt mặc định ghi bằng transaction riêng REQUIRES_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 DocumentDocumentDetail 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.
  • @Audit chạ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 changes là 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 ignoreColumns hoặc @NotAudited.
  • targetType lưu theo Class.getSimpleName(); changes lư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.