Backend Integration: OCR & Face Verify APIs
Mục tiêu bài học
- Hiểu API architecture trong eKYC
- Nắm OCR Front/Back APIs
- Biết cách Face Verify API
- Học Check2FA và Ward Search APIs
- Hiểu UpdateInfoSso API integration
Tổng quan API
Kiến trúc
Frontend (Vue 3)
↓
Portal-Server (Express/TypeScript)
↓
External APIs (iNET SSO, EKYC Service)URL cơ sở
// Endpoint Portal-Server
const API_BASE = "/api/v1";
// Dịch vụ bên ngoài
const ENDPOINT_EKYC = process.env.ENDPOINT_EKYC; // Dịch vụ EKYC
const ENDPOINT_MY_INET = process.env.ENDPOINT_MY_INET; // my.inet.vn
const ENDPOINT_JAVA_INET = process.env.ENDPOINT_JAVA_INET; // Backend JavaOCR APIs
Front Card OCR
Endpoint: POST /api/v1/ekyc/ocr-front
Request:
interface IOcrFrontRequest {
image: string; // Base64 encoded image
}Response:
interface IOcrFrontResponse {
status: "success" | "error";
data?: {
id_number: string;
name: string;
birthday: string; // "DD/MM/YYYY"
sex: string; // "Nam" | "Nữ"
nationality: string;
place_of_residence: string; // Full address
address?: string; // Alternative address field
province?: string;
precinct?: string; // Ward/commune
};
error?: string;
}Usage:
async function getOcrData(base64Image: string): Promise<IOcrFrontResponse> {
const response = await ekycService.ocrFront({
image: base64Image,
});
return response.data;
}Back Card OCR
Endpoint: POST /api/v1/ekyc/ocr-back
Request:
interface IOcrBackRequest {
image: string; // Base64 encoded image
}Response:
interface IOcrBackResponse {
status: "success" | "error";
data?: {
issue_date: string; // "DD/MM/YYYY"
issue_place: string;
// ... other fields
};
error?: string;
}Face Verification API
Endpoint: POST /api/v1/ekyc/face-verify
Request:
interface IFaceVerifyRequest {
front_card_image: string; // Base64
face_image: string; // Base64
back_card_image: string; // Base64
front_ocr_data: IOcrFrontResponse["data"];
back_ocr_data: IOcrBackResponse["data"];
}Response:
interface IFaceVerifyResponse {
verify_result: {
is_same_person: boolean;
similarity: number; // 0-1
};
face_anti_spoof_status: {
status: "REAL" | "FAKE";
confidence: number; // 0-1
};
}Usage:
async function verifyFace(
frontCard: string,
faceImage: string,
backCard: string,
frontOcr: any,
backOcr: any
): Promise<IFaceVerifyResponse> {
const response = await ekycService.faceVerify({
front_card_image: frontCard,
face_image: faceImage,
back_card_image: backCard,
front_ocr_data: frontOcr,
back_ocr_data: backOcr,
});
return response.data;
}Check2FA API
Endpoint: POST /api/v1/customer/check-2fa
Mục đích: Kiểm tra thông tin người dùng hiện tại, đặc biệt là addressLevel và ward.
Response:
interface ICheck2FAResponse {
addressLevel: number | string; // 0 = chưa update
ward?: string;
// ... other user fields
}Usage:
async function check2fa(): Promise<ICheck2FAResponse> {
const response = await customerService.check2fa();
const data =
response.data?.values || response.data?.data || response.data || response;
return data;
}Ward Search APIs
Find Ward by Address
Endpoint: POST /api/v1/ward/find-by-address
Request:
interface IFindWardRequest {
address: string; // Full address from OCR
}Response:
interface IFindWardResponse {
found: boolean;
wards: Array<{
wardName: string;
districtName: string;
provinceName: string;
wardCode: string;
}>;
}Usage:
async function findWard(address: string): Promise<IFindWardResponse> {
const response = await customerService.findWardByAddress({ address });
const data =
response.data?.values || response.data?.data || response.data || response;
return data;
}Check New VN Province Ward
Endpoint: POST /api/v1/ward/check-new-vn-province-ward
Mục đích: Tìm phường/xã mới sau sáp nhập (từ 01/07/2025).
Request:
interface ICheckNewWardRequest {
query: string; // Old ward name
}Response:
interface ICheckNewWardResponse {
// Array of new ward options
Array<{
oldWard: string;
newWard: string;
newProvince: string;
}>;
}Usage:
async function checkNewWard(
oldWardName: string
): Promise<ICheckNewWardResponse> {
const response = await customerService.checkNewVnProvinceWard({
query: oldWardName,
});
let data =
response.data?.values || response.data?.data || response.data || response;
if (!Array.isArray(data)) {
data = [];
}
return data;
}Province List API
Endpoint: POST /api/v1/category/province
Mục đích: Lấy danh sách tỉnh/thành phố để ánh xạ tên ↔ mã.
Response:
interface IProvinceListResponse {
Array<{
value: string; // Province code (e.g., "HNI", "HCM")
name: string; // Province name (e.g., "Thành phố Hà Nội")
}>;
}Usage:
async function loadProvinceList(): Promise<IProvinceListResponse> {
const response = await categoryService.listProvince();
const data =
response.data?.values || response.data?.data || response.data || response;
return Array.isArray(data) ? data : [];
}UpdateInfoSso API
Endpoint: POST /api/v1/customer/update-info-sso
Mục đích: Cập nhật thông tin người dùng sau khi xác thực khuôn mặt thành công.
Proxy Backend: Chuyển tiếp tới https://my.inet.vn/api/client/v1/account/updateinfosso
Request:
interface IUpdateInfoSsoRequest {
email?: string; // Auto-filled from authenticated user if not provided
fullname?: string; // From OCR
address?: string; // Combined address detail + ward
ward?: string; // New ward name
country?: string; // Default: "VN"
province?: string; // Province CODE (not name), e.g., "HNI"
gender?: string; // "male" | "female" | "other"
birthDay?: string; // "MM/DD/YYYY HH:mm:ss" format
idNumber?: string; // From OCR
phone?: string; // From OCR (if available)
}Response:
interface IUpdateInfoSsoResponse {
status: "success" | "error";
message?: string;
error?: string;
}Backend Implementation:
// portal-server/src/services/client/UserService.ts
export const updateInfoSsoService = async function (
body: IUpdateInfoSsoRequest
): Promise<any> {
return await actionTryCatchPerformance(
async () => {
const response = await apiRequestInet(endpointMyInet).post(
"api/client/v1/account/updateinfosso",
body
);
if (getNestedPropValue(response, "data")) {
if (getNestedPropValue(response.data, "status") === "error") {
return renderErrorResponse(response.data);
}
return response.data;
} else {
return response;
}
},
{ name: "updateInfoSsoService" }
);
};Frontend Usage:
// After successful face verification
if (isSamePerson(faceData)) {
// Prepare update data
const updateData: IUpdateInfoSsoRequest = {
fullname: mergedFrontOcrData.name,
address: fullAddress, // Combined detail + ward
ward: wardName,
country: "VN",
province: provinceCode, // CODE, not name
gender: genderMap[mergedFrontOcrData.sex], // "male" | "female" | "other"
birthDay: formatBirthday(mergedFrontOcrData.birthday), // "MM/DD/YYYY 17:00:00"
idNumber: mergedFrontOcrData.idNumber,
phone: mergedFrontOcrData.phone,
};
// Call API
await customerService.updateInfoSso(updateData);
}Data Mapping:
// Gender mapping
const genderMap: { [key: string]: string } = {
Nam: "male",
Nữ: "female",
Khác: "other",
};
// Birthday format: "DD/MM/YYYY" → "MM/DD/YYYY 17:00:00"
function formatBirthday(birthday: string): string {
const dateStr = birthday.replace(/-/g, "/");
const parts = dateStr.split("/");
if (parts.length === 3) {
const day = parts[0].padStart(2, "0");
const month = parts[1].padStart(2, "0");
const year = parts[2];
return `${month}/${day}/${year} 17:00:00`;
}
return birthday;
}
// Province: Get code from name
function getProvinceCode(provinceName: string): string | null {
const province = provinceList.value.find(
(p) => normalizeText(p.name) === normalizeText(provinceName)
);
return province?.value || null;
}Các mẫu xử lý lỗi
Xử lý timeout
try {
const response = await ekycService.ocrFront({ image });
} catch (error: any) {
const isTimeout =
error.isTimeout === true ||
error.code === "ECONNABORTED" ||
error.message?.toLowerCase().includes("timeout");
if (isTimeout) {
// Switch to manual verification mode
isManualVerificationMode.value = true;
message.warning("OCR timeout, switching to manual mode");
} else {
throw error;
}
}Các biến thể cấu trúc phản hồi
// Xử lý các cấu trúc phản hồi khác nhau
function extractData(response: any): any {
return (
response.data?.values || response.data?.data || response.data || response
);
}
// Sử dụng
const wardData = extractData(wardResponse);Chiến lược thử lại
async function callWithRetry<T>(
apiCall: () => Promise<T>,
maxRetries = 3,
delay = 1000
): Promise<T> {
for (let i = 0; i < maxRetries; i++) {
try {
return await apiCall();
} catch (error: any) {
if (i === maxRetries - 1) throw error;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw new Error("Max retries exceeded");
}Thực hành tốt nhất
1. Luôn chuẩn hóa dữ liệu phản hồi
// Đừng giả định cấu trúc phản hồi
const data = extractData(response);
// Đảm bảo là mảng
if (!Array.isArray(data)) {
data = [];
}2. Xử lý các trường thiếu một cách nhẹ nhàng
// Sử dụng optional chaining và giá trị mặc định
const address =
frontOcrData.value?.place_of_residence || frontOcrData.value?.address || "";3. Ghi log các lời gọi API để debug
console.log("[API] Gọi updateInfoSso với dữ liệu:", updateData);
const response = await customerService.updateInfoSso(updateData);
console.log("[API] Phản hồi updateInfoSso:", response);4. Không hiển thị lỗi cho các cập nhật nền
// Sau khi xác thực khuôn mặt thành công, updateInfoSso chạy ở nền
try {
await customerService.updateInfoSso(updateData);
} catch (error: any) {
console.error("[API] Lỗi cập nhật thông tin:", error);
// Không hiển thị lỗi cho người dùng (xác thực khuôn mặt đã thành công)
}Tổng kết
Luồng API:
- ✅ OCR Mặt trước/Mặt sau → Trích xuất dữ liệu người dùng
- ✅ Xác thực khuôn mặt → Xác minh danh tính
- ✅ Check2FA → Kiểm tra trạng thái địa chỉ
- ✅ Tìm phường/xã → Tìm kiếm phường/xã từ địa chỉ
- ✅ Kiểm tra phường/xã mới → Lấy các tùy chọn phường/xã sau sáp nhập
- ✅ Tải danh sách tỉnh → Ánh xạ tên ↔ mã
- ✅ UpdateInfoSso → Cập nhật thông tin người dùng sau xác thực
Nguyên tắc chính:
- ✅ Xử lý các biến thể cấu trúc phản hồi
- ✅ Chuẩn hóa dữ liệu trước khi sử dụng
- ✅ Xử lý timeout một cách nhẹ nhàng
- ✅ Ghi log các lời gọi API để debug
- ✅ Không hiển thị lỗi cho các cập nhật nền
Bài học tiếp theo
→ Xử Lý Ward/District/Province
→ Review Step: Check2FA và Ward Logic