Chi tiết từng bước trong flow eKYC
Mục tiêu bài học
- Hiểu rõ logic xử lý từng step
- Nắm được state management giữa các steps
- Biết cách navigate giữa các steps
- Học cách validate điều kiện chuyển step
Hệ thống điều hướng bước
Định nghĩa các bước và trạng thái
typescript
// EkycSettingEnhanced.vue
type EkycStep = "preparation" | "front" | "back" | "face" | "review";
const currentStep = ref<EkycStep>("preparation");
// State cho từng step
const frontImage = reactive({ path: "", file: null });
const backImage = reactive({ path: "", file: null });
const faceImage = reactive({ path: "", file: null });
const frontOcrData = ref<any>(null);
const backOcrData = ref<any>(null);
const faceVerificationData = ref<any>(null);
// Loading states
const processingOcr = ref(false);
const processingFace = ref(false);Hàm điều hướng giữa các bước
typescript
function goToStep(step: EkycStep) {
// Xác thực: không cho phép nhảy cóc nếu chưa hoàn thành bước trước
if (!canGoToStep(step)) {
message.warning(t("locale.complete_previous_step_first"));
return;
}
currentStep.value = step;
// Kích hoạt các hành động khi vào bước mới
if (step === "review") {
// Gọi check2fa khi vào review
check2faAndWard();
}
}
function canGoToStep(step: EkycStep): boolean {
switch (step) {
case "preparation":
return true; // Luôn được quay lại preparation
case "front":
return true; // Luôn được vào front (có thể chụp lại)
case "back":
return true; // Luôn được vào back
case "face":
return true; // Luôn được vào face
case "review":
// Phải có đủ ảnh mới được vào review
return !!(frontImage.path && backImage.path && faceImage.path);
default:
return false;
}
}Step 0: Preparation
Mục đích
- Giới thiệu quy trình
- Cung cấp thông tin bảo mật
- Hướng dẫn chuẩn bị
UI Components
vue
<template>
<div v-if="currentStep === 'preparation'" class="preparation-step">
<!-- Security Notice -->
<div class="security-card">
<Lucide icon="ShieldCheck" class="w-12 h-12 text-blue-500" />
<h3>{{ t("locale.ekyc_security_notice") }}</h3>
<p>{{ t("locale.ekyc_security_message") }}</p>
</div>
<!-- Instructions -->
<div class="instructions-list">
<div
v-for="step in preparationSteps"
:key="step.id"
class="instruction-item"
>
<div class="step-number">{{ step.id }}</div>
<div class="step-content">
<h4>{{ t(step.titleKey) }}</h4>
<p>{{ t(step.descKey) }}</p>
</div>
</div>
</div>
<!-- eKYC on Another Device (optional) -->
<BtnBase
v-if="!isPublicEkycPage"
class="c-btn-gray"
lucide-icon="QrCode"
:title="t('locale.ekyc_on_another_device')"
@click="showEkycTokenShare = true"
/>
<!-- Start Button -->
<BtnBase
class="c-btn-primary"
lucide-icon="ArrowRight"
:title="t('locale.start_ekyc')"
@click="goToStep('front')"
/>
</div>
</template>
<script setup lang="ts">
const preparationSteps = [
{
id: 1,
titleKey: "locale.ekyc_prep_step_1_title",
descKey: "locale.ekyc_prep_step_1_desc",
},
{
id: 2,
titleKey: "locale.ekyc_prep_step_2_title",
descKey: "locale.ekyc_prep_step_2_desc",
},
{
id: 3,
titleKey: "locale.ekyc_prep_step_3_title",
descKey: "locale.ekyc_prep_step_3_desc",
},
];
</script>Step 1: Front Card (Mặt trước CCCD)
Mục đích
- Chụp/upload ảnh mặt trước CCCD
- Gọi OCR để đọc thông tin
- Validate ảnh có đọc được không
Logic xử lý
typescript
// Timeout cho OCR (15 giây)
const OCR_TIMEOUT = 15000;
const frontOcrTimeout = ref(false);
async function handleFrontCardImage(imageDataUrl: string) {
try {
frontImage.path = imageDataUrl;
frontImage.file = dataURLtoBlob(imageDataUrl);
processingOcr.value = true;
frontOcrTimeout.value = false;
// Đua giữa OCR và timeout
const ocrPromise = ekycService.ocrFront(frontImage.file);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error("OCR_TIMEOUT")), OCR_TIMEOUT)
);
const result = await Promise.race([ocrPromise, timeoutPromise]);
// Xử lý kết quả OCR
if (result.data && result.data.id) {
frontOcrData.value = result.data;
message.success(t("locale.front_card_scanned_successfully"));
// Tự động điều hướng sang back
setTimeout(() => goToStep("back"), 500);
} else {
// OCR không đọc được thông tin
message.warning(t("locale.ocr_no_data_found"));
}
} catch (error: any) {
if (error.message === "OCR_TIMEOUT") {
// Timeout → chuyển sang chế độ thủ công
frontOcrTimeout.value = true;
isManualVerificationMode.value = true;
message.warning(t("locale.ocr_timeout_manual_mode"));
// Vẫn cho phép tiếp tục
setTimeout(() => goToStep("back"), 500);
} else {
message.error(error.message || t("locale.an_error_occurred"));
}
} finally {
processingOcr.value = false;
}
}UI với Camera và Upload
vue
<template>
<div v-if="currentStep === 'front'" class="front-step">
<!-- Image Preview -->
<div class="image-preview-container">
<div v-if="frontImage.path" class="has-image">
<a-image :src="frontImage.path" />
<a-spin v-if="processingOcr" class="processing-overlay" />
</div>
<div v-else class="empty-state">
<Lucide icon="Camera" class="w-20 h-20 text-blue-500" />
<p>{{ t("locale.use_camera_to_capture") }}</p>
</div>
</div>
<!-- Action Buttons -->
<div class="action-buttons">
<BtnBase
class="c-btn-primary"
lucide-icon="Camera"
:title="
frontImage.path ? t('locale.retake_photo') : t('locale.open_camera')
"
:disabled="processingOcr"
@click="showFrontCamera = true"
/>
<BtnBase
class="c-btn-gray"
lucide-icon="Upload"
:title="t('locale.upload')"
:disabled="processingOcr"
@click="triggerFileInput('front')"
/>
</div>
<!-- Hidden file input -->
<input
ref="frontUploadRef"
type="file"
accept="image/*"
class="hidden"
@change="handleFrontUpload"
/>
</div>
<!-- Camera Modal -->
<CardCaptureCamera
v-if="showFrontCamera"
:open="showFrontCamera"
@close="showFrontCamera = false"
@taking-image="handleFrontCardImage"
/>
</template>Step 2: Back Card (Mặt sau CCCD)
Logic tương tự Bước 1, nhưng:
- Gọi
ekycService.ocrBack() - Lưu vào
backOcrData - Tự động điều hướng sang bước
face
typescript
async function handleBackCardImage(imageDataUrl: string) {
try {
backImage.path = imageDataUrl;
backImage.file = dataURLtoBlob(imageDataUrl);
processingOcr.value = true;
backOcrTimeout.value = false;
const result = await Promise.race([
ekycService.ocrBack(backImage.file),
timeout(OCR_TIMEOUT),
]);
if (result.data && result.data.issue_date) {
backOcrData.value = result.data;
message.success(t("locale.back_card_scanned_successfully"));
setTimeout(() => goToStep("face"), 500);
}
} catch (error: any) {
if (error.message === "OCR_TIMEOUT") {
backOcrTimeout.value = true;
isManualVerificationMode.value = true;
setTimeout(() => goToStep("face"), 500);
} else {
message.error(error.message);
}
} finally {
processingOcr.value = false;
}
}Step 3: Face Liveness
Điểm khác biệt
- Không gọi API ngay khi chụp
- Chỉ lưu ảnh, đợi đến bước Review mới xác thực
- Cần tải model MediaPipe trước
typescript
const faceDetectionReady = ref(false);
const faceDetectionModel = ref<any>(null);
// Tải model khi mounted
onMounted(async () => {
try {
// Import wrapper
const { MediaPipeFaceMeshWrapper } = await import(
"@/utils/ekyc/MediaPipeFaceMeshWrapper"
);
const wrapper = new MediaPipeFaceMeshWrapper();
await wrapper.initialize();
faceDetectionModel.value = wrapper;
faceDetectionReady.value = true;
} catch (error) {
console.error("Không thể tải model phát hiện khuôn mặt:", error);
}
});
function handleFaceImage(imageDataUrl: string) {
faceImage.path = imageDataUrl;
faceImage.file = dataURLtoBlob(imageDataUrl);
message.success(t("locale.face_captured_successfully"));
// Tự động điều hướng sang review
setTimeout(() => goToStep("review"), 500);
}Step 4: Review & Submit
Khi vào bước review
typescript
watch(
() => currentStep.value,
async (step) => {
if (step === "review") {
// 1. Gọi check2fa
await check2faAndWard();
}
}
);Gửi xác thực
typescript
async function submitFaceVerification() {
if (!faceImage.file || !frontImage.file) {
message.error(t("locale.missing_images"));
return;
}
try {
processingFace.value = true;
// Tải tất cả ảnh và xác thực
const formData = new FormData();
formData.append("frontImage", frontImage.file);
formData.append("backImage", backImage.file);
formData.append("faceImage", faceImage.file);
const result = await ekycService.verifyFace(formData);
faceVerificationData.value = result;
faceVerificationSubmitted.value = true;
// Kiểm tra kết quả
if (
isSamePerson(result) &&
result.face_anti_spoof_status?.status === "REAL"
) {
message.success(t("locale.verification_success"));
// Chuyển hướng nếu có forwardUrl
if (hasForwardUrl.value) {
handleRedirect();
}
} else {
message.error(t("locale.verification_failed"));
}
} catch (error: any) {
message.error(error.message || t("locale.an_error_occurred"));
} finally {
processingFace.value = false;
}
}
// Helper: kiểm tra có phải cùng một người không
function isSamePerson(data: any): boolean {
return data?.verify_result === 2 || data?.verify_result === 1;
}Điều hướng giữa các steps
Chỉ báo tiến trình
vue
<template>
<div class="ekyc-progress">
<div
v-for="step in steps"
:key="step.id"
class="step-indicator"
:class="{
active: currentStep === step.id,
completed: isStepCompleted(step.id),
clickable: canGoToStep(step.id),
}"
@click="canGoToStep(step.id) && goToStep(step.id)"
>
<div class="step-number">
<Lucide v-if="isStepCompleted(step.id)" icon="Check" />
<span v-else>{{ step.number }}</span>
</div>
<div class="step-title">{{ t(step.titleKey) }}</div>
</div>
</div>
</template>
<script setup lang="ts">
const steps = [
{ id: "preparation", number: 0, titleKey: "locale.preparation" },
{ id: "front", number: 1, titleKey: "locale.front_of_id_card" },
{ id: "back", number: 2, titleKey: "locale.back_of_id_card" },
{ id: "face", number: 3, titleKey: "locale.face_verification" },
{ id: "review", number: 4, titleKey: "locale.review_and_complete" },
];
function isStepCompleted(step: EkycStep): boolean {
switch (step) {
case "preparation":
return currentStep.value !== "preparation";
case "front":
return !!frontOcrData.value || frontOcrTimeout.value;
case "back":
return !!backOcrData.value || backOcrTimeout.value;
case "face":
return !!faceImage.path;
case "review":
return faceVerificationSubmitted.value;
default:
return false;
}
}
</script>Bài học quan trọng
1. Phụ thuộc trạng thái
Các bước phụ thuộc lẫn nhau:
front→frontOcrData→backback→backOcrData→faceface→faceImage→review
2. Khôi phục lỗi
- OCR timeout → dự phòng chế độ thủ công
- Cho phép chụp lại bất cứ lúc nào
- Xác thực trước khi chuyển bước
3. Quy trình UX
- Tự động điều hướng khi thành công
- Phản hồi rõ ràng (tải, thành công, lỗi)
- Không chặn người dùng (có thể quay lại)
Tổng kết
Quy trình 5 bước được thiết kế:
- Tuyến tính nhưng linh hoạt: Có thể quay lại chụp lại
- Tăng dần: Mỗi bước xây dựng trên bước trước
- Khả năng phục hồi: Xử lý timeout và lỗi một cách nhẹ nhàng
- Thân thiện người dùng: Tự động điều hướng + phản hồi rõ ràng