rwadurian/backend/services/identity-service/src/infrastructure/external/kyc/aliyun-kyc.provider.ts

525 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from 'crypto';
/**
* 二要素验证结果
*/
export interface IdCardVerificationResult {
success: boolean;
errorMessage?: string;
rawResponse?: Record<string, unknown>;
}
/**
* 人脸活体认证初始化结果
*/
export interface FaceVerifyInitResult {
success: boolean;
certifyId?: string; // 认证ID用于客户端SDK
certifyUrl?: string; // H5认证链接可选
errorMessage?: string;
rawResponse?: Record<string, unknown>;
}
/**
* 人脸活体认证查询结果
*/
export interface FaceVerifyQueryResult {
success: boolean;
passed: boolean; // 是否通过认证
status: 'PENDING' | 'PASSED' | 'FAILED' | 'EXPIRED';
errorMessage?: string;
rawResponse?: Record<string, unknown>;
}
/**
* OCR识别结果
*/
export interface IdCardOcrResult {
success: boolean;
name?: string;
idNumber?: string;
address?: string;
ethnicity?: string;
birthDate?: string;
sex?: string;
issueAuthority?: string; // 签发机关
validPeriod?: string; // 有效期限
errorMessage?: string;
rawResponse?: Record<string, unknown>;
}
/**
* 阿里云实人认证服务 - 支持三层认证
*
* 层级1: 实名认证 - 三要素验证(姓名+身份证号+手机号)
* 层级2: 实人认证 - 人脸活体检测
* 层级3: KYC - 证件照OCR识别验证
*
* 文档: https://help.aliyun.com/product/60032.html
*/
@Injectable()
export class AliyunKycProvider {
private readonly logger = new Logger(AliyunKycProvider.name);
private readonly accessKeyId: string;
private readonly accessKeySecret: string;
private readonly enabled: boolean;
private readonly endpoint: string;
private readonly sceneId: string;
constructor(private readonly configService: ConfigService) {
this.accessKeyId = this.configService.get<string>('ALIYUN_ACCESS_KEY_ID', '');
this.accessKeySecret = this.configService.get<string>('ALIYUN_ACCESS_KEY_SECRET', '');
this.enabled = this.configService.get<boolean>('ALIYUN_KYC_ENABLED', false);
this.endpoint = this.configService.get<string>('ALIYUN_KYC_ENDPOINT', 'cloudauth.aliyuncs.com');
this.sceneId = this.configService.get<string>('ALIYUN_KYC_SCENE_ID', '');
if (this.enabled && (!this.accessKeyId || !this.accessKeySecret)) {
this.logger.warn('[AliyunKYC] KYC is enabled but credentials are not configured');
}
}
/**
* ========================================
* 层级1: 实名认证 - 三要素验证
* ========================================
* 验证姓名、身份证号和手机号是否匹配
*/
async verifyIdCard(
realName: string,
idCardNumber: string,
phoneNumber: string,
requestId: string,
): Promise<IdCardVerificationResult> {
this.logger.log(`[AliyunKYC] [Level1] Starting ID card verification (3-factor), requestId: ${requestId}`);
// 开发/测试环境:模拟验证
if (!this.enabled) {
this.logger.warn('[AliyunKYC] KYC is disabled, using mock verification');
return this.mockIdCardVerification(realName, idCardNumber, phoneNumber);
}
try {
// 调用阿里云身份三要素核验 API
const params = {
Action: 'VerifyMaterial',
Version: '2019-03-07',
Format: 'JSON',
BizType: 'ID_CARD_THREE',
Name: realName,
IdCardNumber: idCardNumber,
PhoneNumber: phoneNumber,
};
const response = await this.callAliyunApi(params);
if (response.Code === 'OK' || response.Code === '200') {
this.logger.log(`[AliyunKYC] [Level1] Verification SUCCESS for requestId: ${requestId}`);
return {
success: true,
rawResponse: response,
};
} else {
this.logger.warn(`[AliyunKYC] [Level1] Verification FAILED: ${response.Message}`);
return {
success: false,
errorMessage: this.mapErrorMessage(response.Code, response.Message),
rawResponse: response,
};
}
} catch (error) {
this.logger.error(`[AliyunKYC] [Level1] API call failed: ${error.message}`, error.stack);
return {
success: false,
errorMessage: '实名认证服务暂时不可用',
rawResponse: { error: error.message },
};
}
}
/**
* ========================================
* 层级2: 实人认证 - 初始化人脸活体检测
* ========================================
* 返回认证ID供客户端SDK使用
*/
async initFaceVerify(
userId: string,
realName: string,
idCardNumber: string,
returnUrl?: string,
): Promise<FaceVerifyInitResult> {
this.logger.log(`[AliyunKYC] [Level2] Initializing face verify for user: ${userId}`);
if (!this.enabled) {
this.logger.warn('[AliyunKYC] KYC is disabled, using mock face verify init');
return this.mockFaceVerifyInit(userId);
}
try {
const outerOrderNo = `FACE_${userId}_${Date.now()}`;
// 调用阿里云金融级实人认证初始化 API
const params = {
Action: 'InitFaceVerify',
Version: '2019-03-07',
Format: 'JSON',
SceneId: this.sceneId,
OuterOrderNo: outerOrderNo,
ProductCode: 'ID_PLUS', // 金融级实人认证
CertType: 'IDENTITY_CARD',
CertName: realName,
CertNo: idCardNumber,
ReturnUrl: returnUrl || '',
MetaInfo: JSON.stringify({
zimVer: '3.0.0',
appVersion: '1.0.0',
bioMetaInfo: 'mock',
}),
};
const response = await this.callAliyunApi(params);
if (response.Code === 'OK' || response.Code === '200') {
const resultObject = response.ResultObject || {};
this.logger.log(`[AliyunKYC] [Level2] Init SUCCESS, certifyId: ${resultObject.CertifyId}`);
return {
success: true,
certifyId: resultObject.CertifyId,
certifyUrl: resultObject.CertifyUrl,
rawResponse: response,
};
} else {
this.logger.warn(`[AliyunKYC] [Level2] Init FAILED: ${response.Message}`);
return {
success: false,
errorMessage: this.mapErrorMessage(response.Code, response.Message),
rawResponse: response,
};
}
} catch (error) {
this.logger.error(`[AliyunKYC] [Level2] Init failed: ${error.message}`, error.stack);
return {
success: false,
errorMessage: '实人认证服务暂时不可用',
rawResponse: { error: error.message },
};
}
}
/**
* ========================================
* 层级2: 实人认证 - 查询认证结果
* ========================================
*/
async queryFaceVerify(certifyId: string): Promise<FaceVerifyQueryResult> {
this.logger.log(`[AliyunKYC] [Level2] Querying face verify result, certifyId: ${certifyId}`);
if (!this.enabled) {
this.logger.warn('[AliyunKYC] KYC is disabled, using mock face verify query');
return this.mockFaceVerifyQuery(certifyId);
}
try {
const params = {
Action: 'DescribeFaceVerify',
Version: '2019-03-07',
Format: 'JSON',
SceneId: this.sceneId,
CertifyId: certifyId,
};
const response = await this.callAliyunApi(params);
if (response.Code === 'OK' || response.Code === '200') {
const resultObject = response.ResultObject || {};
const passed = resultObject.Passed === 'T' || resultObject.Passed === true;
this.logger.log(`[AliyunKYC] [Level2] Query result: passed=${passed}`);
return {
success: true,
passed,
status: passed ? 'PASSED' : 'FAILED',
rawResponse: response,
};
} else {
return {
success: false,
passed: false,
status: 'FAILED',
errorMessage: this.mapErrorMessage(response.Code, response.Message),
rawResponse: response,
};
}
} catch (error) {
this.logger.error(`[AliyunKYC] [Level2] Query failed: ${error.message}`, error.stack);
return {
success: false,
passed: false,
status: 'FAILED',
errorMessage: '查询认证结果失败',
rawResponse: { error: error.message },
};
}
}
/**
* ========================================
* 层级3: KYC - 证件照OCR识别
* ========================================
*/
async ocrIdCard(
imageUrl: string,
side: 'front' | 'back',
): Promise<IdCardOcrResult> {
this.logger.log(`[AliyunKYC] [Level3] Starting ID card OCR, side: ${side}`);
if (!this.enabled) {
this.logger.warn('[AliyunKYC] KYC is disabled, using mock OCR');
return this.mockIdCardOcr(side);
}
try {
const params = {
Action: 'RecognizeIdCard',
Version: '2019-03-07',
Format: 'JSON',
Side: side === 'front' ? 'face' : 'back',
ImageUrl: imageUrl,
};
const response = await this.callAliyunApi(params);
if (response.Code === 'OK' || response.Code === '200') {
const data = response.Data || {};
this.logger.log(`[AliyunKYC] [Level3] OCR SUCCESS`);
if (side === 'front') {
return {
success: true,
name: data.Name,
idNumber: data.IdNumber,
address: data.Address,
ethnicity: data.Ethnicity,
birthDate: data.BirthDate,
sex: data.Sex,
rawResponse: response,
};
} else {
return {
success: true,
issueAuthority: data.Issue,
validPeriod: data.ValidPeriod || `${data.StartDate}-${data.EndDate}`,
rawResponse: response,
};
}
} else {
return {
success: false,
errorMessage: this.mapErrorMessage(response.Code, response.Message),
rawResponse: response,
};
}
} catch (error) {
this.logger.error(`[AliyunKYC] [Level3] OCR failed: ${error.message}`, error.stack);
return {
success: false,
errorMessage: '证件识别服务暂时不可用',
rawResponse: { error: error.message },
};
}
}
// ============ 私有方法 ============
/**
* 调用阿里云 API (签名方式)
*/
private async callAliyunApi(params: Record<string, string>): Promise<any> {
const timestamp = new Date().toISOString().replace(/\.\d{3}/, '');
const nonce = crypto.randomUUID();
const commonParams: Record<string, string> = {
AccessKeyId: this.accessKeyId,
Timestamp: timestamp,
SignatureMethod: 'HMAC-SHA1',
SignatureVersion: '1.0',
SignatureNonce: nonce,
...params,
};
// 计算签名
const signature = this.calculateSignature(commonParams);
commonParams['Signature'] = signature;
// 发起请求
const queryString = Object.entries(commonParams)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join('&');
const url = `https://${this.endpoint}/?${queryString}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
return response.json();
}
/**
* 计算阿里云 API 签名
*/
private calculateSignature(params: Record<string, string>): string {
const sortedKeys = Object.keys(params).sort();
const canonicalizedQueryString = sortedKeys
.map((k) => `${this.percentEncode(k)}=${this.percentEncode(params[k])}`)
.join('&');
const stringToSign = `GET&${this.percentEncode('/')}&${this.percentEncode(canonicalizedQueryString)}`;
const hmac = crypto.createHmac('sha1', `${this.accessKeySecret}&`);
hmac.update(stringToSign);
return hmac.digest('base64');
}
private percentEncode(str: string): string {
return encodeURIComponent(str)
.replace(/\+/g, '%20')
.replace(/\*/g, '%2A')
.replace(/~/g, '%7E');
}
/**
* 映射错误消息
*/
private mapErrorMessage(code: string, message: string): string {
const errorMap: Record<string, string> = {
'InvalidParameter': '参数格式错误',
'IdCardNotMatch': '姓名与身份证号不匹配',
'IdCardNotExist': '身份证号不存在',
'IdCardExpired': '身份证已过期',
'FaceNotMatch': '人脸比对不通过',
'LivenessCheckFail': '活体检测失败',
'SystemError': '系统错误,请稍后重试',
};
return errorMap[code] || message || '认证失败';
}
// ============ Mock 方法 (开发/测试环境) ============
private mockIdCardVerification(realName: string, idCardNumber: string, phoneNumber: string): IdCardVerificationResult {
this.logger.log('[AliyunKYC] Using mock ID card verification (3-factor)');
// 基本格式验证
if (!realName || realName.length < 2) {
return {
success: false,
errorMessage: '姓名格式不正确',
rawResponse: { mock: true, reason: 'invalid_name' },
};
}
// 身份证号格式验证
const idCardRegex = /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/;
if (!idCardRegex.test(idCardNumber)) {
return {
success: false,
errorMessage: '身份证号格式不正确',
rawResponse: { mock: true, reason: 'invalid_id_card_format' },
};
}
// 校验码验证
if (!this.validateIdCardChecksum(idCardNumber)) {
return {
success: false,
errorMessage: '身份证号校验码不正确',
rawResponse: { mock: true, reason: 'invalid_checksum' },
};
}
// 手机号格式验证
const phoneRegex = /^1[3-9]\d{9}$/;
if (!phoneNumber || !phoneRegex.test(phoneNumber)) {
return {
success: false,
errorMessage: '手机号格式不正确',
rawResponse: { mock: true, reason: 'invalid_phone' },
};
}
return {
success: true,
rawResponse: { mock: true, verifyTime: new Date().toISOString(), verifyType: '3-factor' },
};
}
private mockFaceVerifyInit(userId: string): FaceVerifyInitResult {
this.logger.log('[AliyunKYC] Using mock face verify init');
const mockCertifyId = `MOCK_CERTIFY_${userId}_${Date.now()}`;
return {
success: true,
certifyId: mockCertifyId,
certifyUrl: `https://mock.aliyun.com/face-verify?certifyId=${mockCertifyId}`,
rawResponse: { mock: true },
};
}
private mockFaceVerifyQuery(certifyId: string): FaceVerifyQueryResult {
this.logger.log('[AliyunKYC] Using mock face verify query');
// 模拟环境中,假设所有以 MOCK_ 开头的认证都通过
const passed = certifyId.startsWith('MOCK_');
return {
success: true,
passed,
status: passed ? 'PASSED' : 'PENDING',
rawResponse: { mock: true },
};
}
private mockIdCardOcr(side: 'front' | 'back'): IdCardOcrResult {
this.logger.log('[AliyunKYC] Using mock ID card OCR');
if (side === 'front') {
return {
success: true,
name: '测试用户',
idNumber: '110101199001011234',
address: '北京市东城区测试街道1号',
ethnicity: '汉',
birthDate: '1990-01-01',
sex: '男',
rawResponse: { mock: true },
};
} else {
return {
success: true,
issueAuthority: '北京市公安局东城分局',
validPeriod: '2020.01.01-2040.01.01',
rawResponse: { mock: true },
};
}
}
private validateIdCardChecksum(idCard: string): boolean {
if (idCard.length !== 18) return false;
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
const checksumChars = '10X98765432';
let sum = 0;
for (let i = 0; i < 17; i++) {
sum += parseInt(idCard[i], 10) * weights[i];
}
const expectedChecksum = checksumChars[sum % 11];
const actualChecksum = idCard[17].toUpperCase();
return expectedChecksum === actualChecksum;
}
}