OCB & BVBank Merchant Portal + PWA
Hệ thống quản lý giao dịch cho đối tác ngân hàng trên Portal desktop và PWA tại điểm bán, dùng chung một SDK phân quyền (RBAC) và đồng bộ trạng thái bằng Zustand.
- React
- TypeScript
- Zustand
- Keycloak
- RBAC
Hệ thống quản lý giao dịch dành cho đối tác ngân hàng, chạy song song dưới hai hình thức: Merchant Portal trên desktop và một PWA cho nhân viên tại điểm bán.
Vấn đề#
Portal và PWA ban đầu là hai codebase riêng. Hệ quả là mỗi lần nghiệp vụ phân quyền thay đổi, cùng một luật phải sửa ở hai nơi — và chúng thường xuyên lệch nhau. Đã có trường hợp một role bị chặn ở Portal nhưng vẫn thao tác được trên PWA.
Giải pháp: SDK phân quyền dùng chung#
Tôi là tác giả của một SDK nội bộ làm single source of truth cho việc phân quyền, được cả hai ứng dụng import.
Chuẩn hoá quyền từ Keycloak#
Keycloak trả về role theo cấu trúc lồng nhau và không đồng nhất giữa các realm. Bước đầu tiên là làm phẳng chúng về một tập quyền duy nhất:
/** Quyền ở dạng `<tài nguyên>:<hành động>`, ví dụ 'transaction:refund'. */
export type Permission = `${string}:${string}`;
export function resolvePermissions(token: DecodedToken): ReadonlySet<Permission> {
const roles = [
...(token.realm_access?.roles ?? []),
...Object.values(token.resource_access ?? {}).flatMap((entry) => entry.roles),
];
/* Set: kiểm tra quyền là O(1) và tự loại bỏ trùng lặp giữa các realm. */
return new Set(roles.flatMap((role) => ROLE_TO_PERMISSIONS[role] ?? []));
}Một điểm kiểm tra duy nhất#
Toàn bộ giao diện của cả hai ứng dụng đi qua cùng một hàm kiểm tra:
export function Can({ perform, children, fallback = null }: CanProps) {
const permissions = usePermissions();
/* Mặc định là từ chối: thiếu khai báo quyền thì UI không hiện, thay vì hiện nhầm. */
return permissions.has(perform) ? <>{children}</> : <>{fallback}</>;
}Đồng bộ trạng thái với Zustand#
PWA phải hoạt động được khi mạng chập chờn tại quầy. Trạng thái giao dịch được lưu cục bộ và đồng bộ lại khi có mạng:
export const useTransactions = create<TransactionState>()(
persist(
(set, get) => ({
queue: [],
enqueue: (draft) => set({ queue: [...get().queue, draft] }),
flush: async () => {
/* Gửi tuần tự: thứ tự giao dịch là ràng buộc nghiệp vụ, không phải chi tiết kỹ thuật. */
for (const item of get().queue) {
await submitTransaction(item);
set({ queue: get().queue.filter((entry) => entry.id !== item.id) });
}
},
}),
{ name: 'txn-outbox' },
),
);Kết quả#
- Luật phân quyền chỉ tồn tại ở một nơi, hết tình trạng Portal và PWA lệch nhau
- Thêm một role mới là sửa một bảng ánh xạ, không phải rà lại giao diện
- PWA tiếp tục ghi nhận giao dịch khi mất mạng và tự đồng bộ khi có lại