feat(round-15): 跨境多币种发票税额/线索自动化阶段跃升/名片私密访问锁/芯片激光标刻排版 (P1/P2功能达成)
这个提交包含在:
@@ -0,0 +1,138 @@
|
||||
import { Provide } from '@midwayjs/core';
|
||||
import { BaseService } from '@cool-midway/core';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
export interface PrivacyUnlockResult {
|
||||
success: boolean;
|
||||
accessToken?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PrivacyTokenVerifyResult {
|
||||
valid: boolean;
|
||||
cardId?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 针对私密名片口令进行加盐哈希
|
||||
* 格式: `${salt}:${sha256(password + salt)}`
|
||||
*/
|
||||
export function hashPrivacyPassword(password: string): string {
|
||||
if (!password) {
|
||||
throw new Error('口令不能为空');
|
||||
}
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hash = crypto
|
||||
.createHash('sha256')
|
||||
.update(`${password}:${salt}`)
|
||||
.digest('hex');
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 恒定时间校验私密口令
|
||||
*/
|
||||
export function verifyPrivacyPassword(
|
||||
passwordAttempt: string,
|
||||
storedSaltedHash?: string
|
||||
): boolean {
|
||||
if (!passwordAttempt || !storedSaltedHash || !storedSaltedHash.includes(':')) {
|
||||
return false;
|
||||
}
|
||||
const [salt, expectedHash] = storedSaltedHash.split(':');
|
||||
if (!salt || !expectedHash) return false;
|
||||
|
||||
const actualHash = crypto
|
||||
.createHash('sha256')
|
||||
.update(`${passwordAttempt}:${salt}`)
|
||||
.digest('hex');
|
||||
|
||||
const bufA = Buffer.from(actualHash, 'hex');
|
||||
const bufB = Buffer.from(expectedHash, 'hex');
|
||||
if (bufA.length !== bufB.length) return false;
|
||||
return crypto.timingSafeEqual(bufA, bufB);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成临时私密名片访问 Token (默认有效期 2 小时)
|
||||
* 格式: `${cardId}.${expireTimestamp}.${hmac}`
|
||||
*/
|
||||
export function generatePrivacyAccessToken(
|
||||
cardId: number,
|
||||
systemSecret: string = 'omnicard-privacy-default-secret',
|
||||
expireSeconds: number = 7200
|
||||
): string {
|
||||
const expireAt = Math.floor(Date.now() / 1000) + expireSeconds;
|
||||
const payload = `${cardId}.${expireAt}`;
|
||||
const sig = crypto
|
||||
.createHmac('sha256', systemSecret)
|
||||
.update(payload)
|
||||
.digest('hex');
|
||||
return `${payload}.${sig}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验临时私密访问 Token
|
||||
*/
|
||||
export function verifyPrivacyAccessToken(
|
||||
cardId: number,
|
||||
token?: string,
|
||||
systemSecret: string = 'omnicard-privacy-default-secret'
|
||||
): PrivacyTokenVerifyResult {
|
||||
if (!token || !token.includes('.')) {
|
||||
return { valid: false, reason: 'Token 格式无效' };
|
||||
}
|
||||
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
return { valid: false, reason: 'Token 缺少签名段' };
|
||||
}
|
||||
|
||||
const [cidStr, expStr, sig] = parts;
|
||||
const tokenCardId = Number(cidStr);
|
||||
const expireAt = Number(expStr);
|
||||
|
||||
if (tokenCardId !== cardId) {
|
||||
return { valid: false, reason: 'Token 与名片标识不匹配 (Card mismatch)' };
|
||||
}
|
||||
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
if (nowSec > expireAt) {
|
||||
return { valid: false, reason: 'Token 已过期 (Token expired)' };
|
||||
}
|
||||
|
||||
const payload = `${cidStr}.${expStr}`;
|
||||
const expectedSig = crypto
|
||||
.createHmac('sha256', systemSecret)
|
||||
.update(payload)
|
||||
.digest('hex');
|
||||
|
||||
const bufA = Buffer.from(sig, 'hex');
|
||||
const bufB = Buffer.from(expectedSig, 'hex');
|
||||
if (bufA.length !== bufB.length || !crypto.timingSafeEqual(bufA, bufB)) {
|
||||
return { valid: false, reason: 'Token 签名无效' };
|
||||
}
|
||||
|
||||
return { valid: true, cardId: tokenCardId };
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动微官网名片私密保护模式服务
|
||||
*/
|
||||
@Provide()
|
||||
export class CardPrivacyPasswordService extends BaseService {
|
||||
async unlockCard(
|
||||
cardId: number,
|
||||
passwordAttempt: string,
|
||||
storedSaltedHash?: string
|
||||
): Promise<PrivacyUnlockResult> {
|
||||
const isOk = verifyPrivacyPassword(passwordAttempt, storedSaltedHash);
|
||||
if (!isOk) {
|
||||
return { success: false, error: '访问口令错误,请重新输入' };
|
||||
}
|
||||
|
||||
const accessToken = generatePrivacyAccessToken(cardId);
|
||||
return { success: true, accessToken };
|
||||
}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户