525 lines
16 KiB
TypeScript
525 lines
16 KiB
TypeScript
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;
|
||
}
|
||
}
|