296 lines
12 KiB
Plaintext
296 lines
12 KiB
Plaintext
/**
|
||
* bankUtils.uts
|
||
* 银行卡工具集:BIN 识别、OCR 适配、银行列表、表单校验
|
||
* 所有接口均与真实 OCR 厂商解耦,需接入时只修改 recognizeBankCardFromImage 内部实现
|
||
*/
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 1. 银行列表数据源
|
||
// ─────────────────────────────────────────────
|
||
export type BankItem = {
|
||
code: string // 内部 code
|
||
name: string // 银行全称
|
||
shortName: string // 简称,用于 UI 显示
|
||
logo: string // 本地 logo 路径(没有则为空串,前端显示占位)
|
||
}
|
||
|
||
export const BANK_LIST: BankItem[] = [
|
||
{ code: 'ICBC', name: '中国工商银行', shortName: '工商银行', logo: '' },
|
||
{ code: 'CCB', name: '中国建设银行', shortName: '建设银行', logo: '' },
|
||
{ code: 'ABC', name: '中国农业银行', shortName: '农业银行', logo: '' },
|
||
{ code: 'BOC', name: '中国银行', shortName: '中国银行', logo: '' },
|
||
{ code: 'COMM', name: '交通银行', shortName: '交通银行', logo: '' },
|
||
{ code: 'CMB', name: '招商银行', shortName: '招商银行', logo: '' },
|
||
{ code: 'CITIC', name: '中信银行', shortName: '中信银行', logo: '' },
|
||
{ code: 'SPDB', name: '浦发银行', shortName: '浦发银行', logo: '' },
|
||
{ code: 'CEB', name: '光大银行', shortName: '光大银行', logo: '' },
|
||
{ code: 'HXB', name: '华夏银行', shortName: '华夏银行', logo: '' },
|
||
{ code: 'CMBC', name: '民生银行', shortName: '民生银行', logo: '' },
|
||
{ code: 'PAB', name: '平安银行', shortName: '平安银行', logo: '' },
|
||
{ code: 'GDB', name: '广发银行', shortName: '广发银行', logo: '' },
|
||
{ code: 'BOBJ', name: '北京银行', shortName: '北京银行', logo: '' },
|
||
{ code: 'SHRCB', name: '上海银行', shortName: '上海银行', logo: '' },
|
||
{ code: 'PSBC', name: '中国邮政储蓄银行', shortName: '邮储银行', logo: '' },
|
||
]
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 2. BIN → 银行名称映射(仅 6 位前缀)
|
||
// 来源:公开 BIN 数据,仅做参考识别用途
|
||
// ─────────────────────────────────────────────
|
||
type BinEntry = {
|
||
prefix: string
|
||
bankCode: string
|
||
}
|
||
|
||
const BIN_MAP: BinEntry[] = [
|
||
// 工商银行
|
||
{ prefix: '6212', bankCode: 'ICBC' },
|
||
{ prefix: '6216', bankCode: 'ICBC' },
|
||
{ prefix: '6217', bankCode: 'ICBC' },
|
||
{ prefix: '6222', bankCode: 'ICBC' },
|
||
// 建设银行
|
||
{ prefix: '6217', bankCode: 'CCB' },
|
||
{ prefix: '6227', bankCode: 'CCB' },
|
||
{ prefix: '6236', bankCode: 'CCB' },
|
||
// 农业银行
|
||
{ prefix: '6225', bankCode: 'ABC' },
|
||
{ prefix: '6228', bankCode: 'ABC' },
|
||
{ prefix: '9559', bankCode: 'ABC' },
|
||
// 中国银行
|
||
{ prefix: '6013', bankCode: 'BOC' },
|
||
{ prefix: '6259', bankCode: 'BOC' },
|
||
{ prefix: '6539', bankCode: 'BOC' },
|
||
// 交通银行
|
||
{ prefix: '6222', bankCode: 'COMM' },
|
||
{ prefix: '6230', bankCode: 'COMM' },
|
||
{ prefix: '6266', bankCode: 'COMM' },
|
||
// 招商银行
|
||
{ prefix: '6214', bankCode: 'CMB' },
|
||
{ prefix: '6225', bankCode: 'CMB' },
|
||
{ prefix: '6245', bankCode: 'CMB' },
|
||
// 中信银行
|
||
{ prefix: '6217', bankCode: 'CITIC' },
|
||
{ prefix: '4367', bankCode: 'CITIC' },
|
||
// 浦发银行
|
||
{ prefix: '6217', bankCode: 'SPDB' },
|
||
{ prefix: '6216', bankCode: 'SPDB' },
|
||
// 平安银行
|
||
{ prefix: '6214', bankCode: 'PAB' },
|
||
{ prefix: '6216', bankCode: 'PAB' },
|
||
// 邮储银行
|
||
{ prefix: '6210', bankCode: 'PSBC' },
|
||
{ prefix: '6221', bankCode: 'PSBC' },
|
||
{ prefix: '6230', bankCode: 'PSBC' },
|
||
]
|
||
|
||
/**
|
||
* 根据卡号前缀匹配银行(优先匹配最长前缀)
|
||
* @param cardNo 银行卡号(至少 6 位)
|
||
* @returns BankItem | null
|
||
*/
|
||
export function detectBankByBin(cardNo: string): BankItem | null {
|
||
const cleaned = cardNo.replace(/\s/g, '')
|
||
if (cleaned.length < 6) return null
|
||
const prefix6 = cleaned.substring(0, 6)
|
||
const prefix4 = cleaned.substring(0, 4)
|
||
|
||
// 优先 6 位匹配
|
||
for (let i = 0; i < BIN_MAP.length; i++) {
|
||
if (BIN_MAP[i].prefix === prefix6) {
|
||
return findBankByCode(BIN_MAP[i].bankCode)
|
||
}
|
||
}
|
||
// 降级 4 位匹配
|
||
for (let i = 0; i < BIN_MAP.length; i++) {
|
||
if (BIN_MAP[i].prefix === prefix4) {
|
||
return findBankByCode(BIN_MAP[i].bankCode)
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
function findBankByCode(code: string): BankItem | null {
|
||
for (let i = 0; i < BANK_LIST.length; i++) {
|
||
if (BANK_LIST[i].code === code) return BANK_LIST[i]
|
||
}
|
||
return null
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 3. 银行卡号格式化(每 4 位一空格,仅展示用)
|
||
// ─────────────────────────────────────────────
|
||
export function formatCardNo(raw: string): string {
|
||
const digits = raw.replace(/\D/g, '')
|
||
let result = ''
|
||
for (let i = 0; i < digits.length; i++) {
|
||
if (i > 0 && i % 4 === 0) result += ' '
|
||
result += digits[i]
|
||
}
|
||
return result
|
||
}
|
||
|
||
/** 去除格式化空格,返回纯数字 */
|
||
export function cleanCardNo(formatted: string): string {
|
||
return formatted.replace(/\s/g, '')
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 4. 银行卡号脱敏(验证页展示用)
|
||
// ─────────────────────────────────────────────
|
||
export function maskCardNo(cardNo: string): string {
|
||
const digits = cardNo.replace(/\s/g, '')
|
||
if (digits.length < 8) return digits
|
||
const prefix = digits.substring(0, 4)
|
||
const suffix = digits.substring(digits.length - 4)
|
||
return prefix + ' **** **** ' + suffix
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 5. Luhn 校验(可选,建议开启)
|
||
// ─────────────────────────────────────────────
|
||
export function luhnCheck(cardNo: string): boolean {
|
||
const digits = cardNo.replace(/\s/g, '')
|
||
if (digits.length < 13 || digits.length > 19) return false
|
||
let sum = 0
|
||
let alternate = false
|
||
for (let i = digits.length - 1; i >= 0; i--) {
|
||
const digitStr = digits.substring(i, i + 1)
|
||
let n = parseInt(digitStr)
|
||
if (alternate) {
|
||
n *= 2
|
||
if (n > 9) n -= 9
|
||
}
|
||
sum += n
|
||
alternate = !alternate
|
||
}
|
||
return sum % 10 === 0
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 6. 手机号校验
|
||
// ─────────────────────────────────────────────
|
||
export function isValidPhone(phone: string): boolean {
|
||
return /^1[3-9]\d{9}$/.test(phone)
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 7. OCR 适配层(银行卡识别)
|
||
// 【接入说明】真实接入时,在此处替换实现:
|
||
// - 腾讯云:调用 /ocr/v3/BankCardOCR
|
||
// - 阿里云:调用 /cardrecog/recognize_bank_card
|
||
// - 自有服务:调用项目后端 OCR 接口
|
||
// 注意:不要在此硬编码任何 SecretKey / AccessKey
|
||
// ─────────────────────────────────────────────
|
||
export type OCRResult = {
|
||
success: boolean
|
||
cardNo: string
|
||
bankName: string // 识别出的银行名称(可能为空)
|
||
errorMsg: string
|
||
}
|
||
|
||
/**
|
||
* 选择图片并识别银行卡
|
||
* 当前为"前端完整流程 + 后端待接入"占位实现
|
||
* @returns OCRResult
|
||
*/
|
||
export async function recognizeBankCardFromImage(): Promise<OCRResult> {
|
||
return new Promise<OCRResult>((resolve) => {
|
||
function handleSelectedImage(filePath: string): void {
|
||
if (filePath === '') {
|
||
resolve({ success: false, cardNo: '', bankName: '', errorMsg: '未选择图片' } as OCRResult)
|
||
return
|
||
}
|
||
|
||
console.log('[OCR] 已选择图片:', filePath, ',OCR 服务待接入')
|
||
resolve({
|
||
success: false,
|
||
cardNo: '',
|
||
bankName: '',
|
||
errorMsg: 'OCR 服务待接入,请手动输入卡号'
|
||
} as OCRResult)
|
||
}
|
||
|
||
uni.chooseImage({
|
||
count: 1,
|
||
sourceType: ['camera', 'album'],
|
||
success: (imgRes: ChooseImageSuccess): void => {
|
||
const filePath = (imgRes.tempFilePaths as string[])[0]
|
||
try {
|
||
handleSelectedImage(filePath)
|
||
} catch (e) {
|
||
console.error('[OCR] 识别异常:', e)
|
||
resolve({ success: false, cardNo: '', bankName: '', errorMsg: 'OCR 识别失败' } as OCRResult)
|
||
}
|
||
},
|
||
fail: () => {
|
||
resolve({ success: false, cardNo: '', bankName: '', errorMsg: '取消选择' } as OCRResult)
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// 8. 短信验证码适配层
|
||
// 【接入说明】项目后端接入短信服务后,替换此方法
|
||
// ─────────────────────────────────────────────
|
||
export type SmsResult = {
|
||
success: boolean
|
||
errorMsg: string
|
||
}
|
||
|
||
/**
|
||
* 发送短信验证码
|
||
* 当前为占位实现,接入阿里云/腾讯云短信后替换此处
|
||
* @param phone 11 位手机号
|
||
*/
|
||
export async function sendSmsCode(phone: string): Promise<SmsResult> {
|
||
if (!isValidPhone(phone)) {
|
||
return { success: false, errorMsg: '手机号格式不正确' } as SmsResult
|
||
}
|
||
|
||
try {
|
||
// ─── TODO: 接入真实短信服务 ────────────────────────────────
|
||
// 示例(项目后端代理):
|
||
// const res = await uni.request({
|
||
// url: '/api/sms/send',
|
||
// method: 'POST',
|
||
// data: { phone, scene: 'bind_bank_card' }
|
||
// })
|
||
// if (res.data.code === 0) return { success: true, errorMsg: '' }
|
||
// return { success: false, errorMsg: res.data.message }
|
||
// ─────────────────────────────────────────────────────────
|
||
|
||
// 占位实现:模拟发送成功,验证码请查看控制台
|
||
console.log('[SMS] 占位发送验证码到:', phone, ',模拟验证码: 123456')
|
||
return { success: true, errorMsg: '' } as SmsResult
|
||
} catch (e) {
|
||
console.error('[SMS] 发送异常:', e)
|
||
return { success: false, errorMsg: '发送失败,请稍后重试' } as SmsResult
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 验证短信验证码
|
||
* 当前为占位实现:任何 6 位数字均视为通过
|
||
* @param phone 手机号
|
||
* @param code 用户输入的验证码
|
||
*/
|
||
export async function verifySmsCode(phone: string, code: string): Promise<SmsResult> {
|
||
if (code.length !== 6) {
|
||
return { success: false, errorMsg: '请输入 6 位验证码' } as SmsResult
|
||
}
|
||
|
||
try {
|
||
// ─── TODO: 接入真实验证逻辑 ────────────────────────────────
|
||
// const res = await uni.request({ ... })
|
||
// ─────────────────────────────────────────────────────────
|
||
|
||
// 占位:校验通过
|
||
console.log('[SMS] 占位验证通过, phone:', phone, 'code:', code)
|
||
return { success: true, errorMsg: '' } as SmsResult
|
||
} catch (e) {
|
||
console.error('[SMS] 验证异常:', e)
|
||
return { success: false, errorMsg: '验证失败,请重试' } as SmsResult
|
||
}
|
||
}
|