OCB & BVBank Merchant Portal + PWA
Hệ thống quản lý giao dịch đa nền tảng cho đối tác ngân hàng, với SDK dùng chung và RBAC đồng bộ giữa Portal và PWA.
- 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