Phê duyệt nhiều cấp, trạng thái, audit logMulti-level approvals, states, audit logs
Approval API nhận command, kiểm tra version rồi ghi trạng thái, audit và outbox trong một transaction. Rule Engine dựng chuỗi duyệt khi gửi. SLA Scheduler quét các bước quá hạn. Bật “Cấp 2 từ chối” hoặc “Quá SLA” để xem các nhánh còn lại.The Approval API takes commands, checks the version, then writes the state, the audit entry and the outbox row in one transaction. The Rule Engine builds the chain on submit. The SLA Scheduler scans overdue steps. Turn on “Level 2 rejects” or “SLA breach” to see the other paths.
1Nhân viên phòng IT gửi đề nghị mua 120 triệu đồng.An IT employee submits a purchase request for 120 million VND.
Mỗi trạng thái chỉ cho phép một số hành động: không thể duyệt yêu cầu đã REJECTED hay sửa yêu cầu đang IN_REVIEW. Bảng chuyển trạng thái nằm ở một chỗ, không rải if khắp service.Each state allows only certain actions: you cannot approve a REJECTED request or edit one that is IN_REVIEW. Keep the transition table in one place, not as if statements scattered across services.
Mỗi luật (số tiền, phòng ban, loại chi phí) là một mắt xích quyết định có thêm cấp duyệt hay không. Chuỗi được chụp lại vào bảng approval_step lúc gửi, nên đổi luật sau đó không ảnh hưởng yêu cầu đang chạy.Each rule (amount, department, cost type) is a link that decides whether to add a level. The chain is frozen into approval_step on submit, so later rule changes leave in-flight requests alone.
Client gửi kèm version đã thấy. UPDATE … WHERE version = ? trả 0 dòng nghĩa là trạng thái đã đổi; API trả 409 và client tải lại.The client sends the version it saw. UPDATE … WHERE version = ? returning 0 rows means the state has moved on; the API answers 409 and the client reloads.
Các bước cùng cấp có mode ANY_OF hoặc ALL_OF. ANY_OF: một người duyệt là đủ, các bước còn lại thành SKIPPED. ALL_OF: chờ đủ, một người từ chối là dừng.Steps on the same level carry a mode, ANY_OF or ALL_OF. ANY_OF: one approval is enough and the rest become SKIPPED. ALL_OF: wait for everyone; one rejection stops it.
Bảng delegation có khoảng thời gian hiệu lực. Khi kiểm quyền, người được uỷ quyền cũng được duyệt, nhưng audit ghi rõ “u09 thay mặt u05”.A delegation table holds a validity window. The permission check accepts the delegate too, but the audit entry says “u09 on behalf of u05”.
Mỗi lần chuyển trạng thái phát một sự kiện qua outbox; email, chat, webhook là các subscriber độc lập. Gửi mail lỗi không làm rollback quyết định duyệt.Every transition emits an event through the outbox; email, chat and webhooks are independent subscribers. A failed email never rolls back a decision.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
approval_request | id, requester_id, type, amount, department, payload, state, current_level, version, created_at | DRAFT → SUBMITTED → IN_REVIEW → APPROVED · REJECTED · RETURNED; version cho optimistic lockDRAFT → SUBMITTED → IN_REVIEW → APPROVED · REJECTED · RETURNED; version for optimistic locking |
approval_step | id, request_id, level, mode, assignee_id, status, due_at, decided_by, decided_at | Chuỗi duyệt chụp lúc gửi; mode ANY_OF / ALL_OF cho cùng levelThe chain frozen at submit; mode ANY_OF / ALL_OF within a level |
audit_log | id, request_id, actor_id, on_behalf_of, action, from_state, to_state, comment, at | Chỉ INSERT; tài khoản ứng dụng không có quyền UPDATE/DELETEINSERT only; the app's DB role has no UPDATE/DELETE grant |
approval_rule | id, type, department, min_amount, level, approver_role, version | Luật có version; yêu cầu ghi lại version luật đã dùngVersioned rules; each request records which version it used |
delegation | delegator_id, delegate_id, valid_from, valid_to | Uỷ quyền khi nghỉ phépOut-of-office delegation |
Một command Decide: tải yêu cầu, kiểm version và quyền, áp dụng chuyển trạng thái, ghi audit và phát sự kiện trong cùng transaction.One Decide command: load the request, check the version and the permission, apply the transition, write the audit entry and publish an event in the same transaction.
@Entity
public class ApprovalRequest {
@Id private Long id;
@Enumerated(EnumType.STRING) private RequestState state;
private int currentLevel;
@Version private long version;
// approveCurrentStep(), reject(), returnForChanges() check the state table
}
@Service
@RequiredArgsConstructor
public class ApprovalService {
private final ApprovalRequestRepository requests;
private final AuditLogRepository audit;
private final DelegationService delegation;
private final ApplicationEventPublisher events;
@Transactional
public void decide(DecideCommand cmd) {
ApprovalRequest r = requests.findById(cmd.requestId()).orElseThrow();
if (r.getVersion() != cmd.expectedVersion()) {
throw new StaleStateException(r.getVersion()); // 409 Conflict
}
ApprovalStep step = r.currentStep();
if (!delegation.canAct(cmd.actorId(), step.assigneeId())) {
throw new AccessDeniedException("not assigned to this step");
}
RequestState from = r.getState();
switch (cmd.action()) {
case APPROVE -> r.approveCurrentStep(cmd.actorId()); // next level or APPROVED
case REJECT -> r.reject(cmd.actorId());
case RETURN -> r.returnForChanges(cmd.actorId());
}
audit.save(AuditEntry.of(r.getId(), cmd.actorId(), step.assigneeId(),
cmd.action(), from, r.getState(), cmd.comment()));
events.publishEvent(new RequestTransitioned(r.getId(), from, r.getState()));
}
}var allowed = map[State]map[Action]bool{
InReview: {Approve: true, Reject: true, Return: true},
Returned: {Submit: true},
Draft: {Submit: true},
}
var ErrStale = errors.New("request changed, reload")
func (s *Service) Decide(ctx context.Context, cmd DecideCmd) error {
return s.tx.Do(ctx, func(tx Tx) error {
r, err := tx.GetRequest(ctx, cmd.RequestID)
if err != nil {
return err
}
if !allowed[r.State][cmd.Action] {
return fmt.Errorf("%s not allowed in %s", cmd.Action, r.State)
}
step := r.CurrentStep()
if !s.delegation.CanAct(ctx, cmd.ActorID, step.AssigneeID) {
return ErrForbidden
}
from := r.State
r.Apply(cmd.Action, cmd.ActorID) // next level, APPROVED, REJECTED or RETURNED
res, err := tx.Exec(ctx, `UPDATE approval_request
SET state = $1, current_level = $2, version = version + 1
WHERE id = $3 AND version = $4`,
r.State, r.CurrentLevel, r.ID, cmd.ExpectedVersion)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrStale // mapped to 409 by the handler
}
if err := tx.InsertAudit(ctx, Audit{RequestID: r.ID, Actor: cmd.ActorID,
Action: cmd.Action, From: from, To: r.State, Comment: cmd.Comment}); err != nil {
return err
}
return tx.InsertOutbox(ctx, "approval.events", Transitioned{r.ID, from, r.State})
})
}| Tự viết state machineHand-written state machine | Workflow engineWorkflow engine | |
|---|---|---|
| Hợp vớiGood for | Vài loại yêu cầu, luồng ổn địnhA few request types with stable flows | Nhiều quy trình, người nghiệp vụ tự vẽ luồngMany processes that business users model themselves |
| Hẹn giờ, SLATimers, SLA | Tự làm scheduler quét due_atYour own scheduler scanning due_at | Có sẵn timer event và escalationBuilt-in timer events and escalation |
| Vận hànhOperations | Chỉ có DB của bạnOnly your own database | Thêm cụm engine, version hoá định nghĩa quy trìnhAn extra engine cluster and versioned process definitions |
| Chọn khiPick it when | Luồng dưới ~10 trạng thái, team nắm rõFewer than ~10 states and the team knows them well | Quy trình dài, nhiều nhánh, thay đổi thường xuyênLong, branching processes that change often |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
Cả hai gửi cùng version. Transaction commit trước thắng và tăng version; transaction sau cập nhật 0 dòng, nhận 409 và thấy trạng thái mới. Không có kết quả nửa vời.Both send the same version. The first commit wins and bumps the version; the second updates 0 rows, gets 409 and sees the new state. There is no half-applied outcome.
Không đổi. Chuỗi duyệt đã được chụp vào approval_step lúc gửi và request ghi version luật đã dùng. Luật mới chỉ áp dụng cho lần gửi sau.Nothing changes. The chain was frozen into approval_step on submit and the request records the rule version. New rules apply only to later submissions.
Chỉ cấp quyền INSERT cho tài khoản ứng dụng, có thể thêm cột hash nối chuỗi với dòng trước, và định kỳ xuất ra kho lưu trữ chỉ-ghi-một-lần.Grant the app role INSERT only, optionally add a hash column chained to the previous row, and export periodically to write-once storage.
Quét bằng FOR UPDATE SKIP LOCKED và cập nhật due_at trong cùng transaction; instance khác hoặc bỏ qua dòng bị khoá, hoặc thấy due_at đã ở tương lai.Scan with FOR UPDATE SKIP LOCKED and update due_at in the same transaction; other instances either skip the locked row or see a due_at in the future.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.