Skip to content

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:

  • frontfrontOcrDataback
  • backbackOcrDataface
  • facefaceImagereview

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

Bài học tiếp theo

Giới thiệu MediaPipe FaceMesh

Internal documentation for iNET Portal