feat: 完成循环1审查补齐 - RFC 6350 vCard引擎、全量自动化单测集成与根目录gitignore规范
这个提交包含在:
419
backend/src/comm/vcard/vcard.ts
普通文件
419
backend/src/comm/vcard/vcard.ts
普通文件
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* OmniCard AI (CRMmp) - RFC 6350 国际通用标准 vCard 3.0/4.0 编解码与生成引擎
|
||||
* 遵循 RFC 6350 (vCard 4.0) 与 RFC 2426 (vCard 3.0) 国际标准
|
||||
* 零外部依赖,纯 TypeScript 高效实现
|
||||
*/
|
||||
|
||||
export interface VCardName {
|
||||
familyName?: string;
|
||||
givenName?: string;
|
||||
additionalNames?: string;
|
||||
honorificPrefixes?: string;
|
||||
honorificSuffixes?: string;
|
||||
}
|
||||
|
||||
export interface VCardAddress {
|
||||
postOfficeBox?: string;
|
||||
extendedAddress?: string;
|
||||
street?: string;
|
||||
locality?: string;
|
||||
region?: string;
|
||||
postalCode?: string;
|
||||
countryName?: string;
|
||||
}
|
||||
|
||||
export interface VCardTelephone {
|
||||
number: string;
|
||||
type?: 'WORK' | 'CELL' | 'HOME' | 'MAIN' | 'OTHER' | string;
|
||||
}
|
||||
|
||||
export interface VCardEmail {
|
||||
address: string;
|
||||
type?: 'WORK' | 'HOME' | 'OTHER' | string;
|
||||
}
|
||||
|
||||
export interface VCardSocialProfile {
|
||||
type: 'linkedin' | 'whatsapp' | 'telegram' | 'twitter' | 'wechat' | string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface VCardPhoto {
|
||||
url?: string;
|
||||
base64?: string;
|
||||
mediaType?: string;
|
||||
}
|
||||
|
||||
export interface VCardData {
|
||||
fn: string;
|
||||
name?: VCardName;
|
||||
title?: string;
|
||||
org?: string | string[];
|
||||
tel?: string | VCardTelephone | (string | VCardTelephone)[];
|
||||
email?: string | VCardEmail | (string | VCardEmail)[];
|
||||
url?: string | string[];
|
||||
adr?: VCardAddress | VCardAddress[];
|
||||
note?: string;
|
||||
socialProfiles?: VCardSocialProfile[];
|
||||
photo?: string | VCardPhoto;
|
||||
}
|
||||
|
||||
export type VCardVersion = '3.0' | '4.0';
|
||||
|
||||
/**
|
||||
* 转义 RFC 6350 TEXT 字段值中的保留特殊字符
|
||||
* 字符顺序:先转义反斜杠,再转义分号、逗号和换行
|
||||
*/
|
||||
export function escapeText(text: string): string {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/\r\n|\r|\n/g, '\\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 反转义 RFC 6350 TEXT 字段值
|
||||
*/
|
||||
export function unescapeText(text: string): string {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/\\n/gi, '\n')
|
||||
.replace(/\\,/g, ',')
|
||||
.replace(/\\;/g, ';')
|
||||
.replace(/\\\\/g, '\\');
|
||||
}
|
||||
|
||||
/**
|
||||
* 严格执行 RFC 6350 第 3.2 节长行折叠 (Line Folding) 规范
|
||||
* 每一物理行不超过 75 字符,超长在 75 处插入 CRLF 及一个前置空格进行折叠
|
||||
*/
|
||||
export function foldLine(line: string, maxLen: number = 75): string {
|
||||
if (line.length <= maxLen) return line;
|
||||
|
||||
let result = line.slice(0, maxLen);
|
||||
let remaining = line.slice(maxLen);
|
||||
|
||||
while (remaining.length > 0) {
|
||||
// 后续行因为开头有 1 个空格作为折叠标记,故内容取 maxLen - 1 字符
|
||||
result += '\r\n ' + remaining.slice(0, maxLen - 1);
|
||||
remaining = remaining.slice(maxLen - 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 6350 反折叠 (Line Unfolding):移除所有 CRLF 或 LF 后紧跟的空格/制表符
|
||||
*/
|
||||
export function unfoldLines(vcardString: string): string {
|
||||
return vcardString.replace(/\r?\n[ \t]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 RFC 6350 / RFC 2426 合规的 vCard 字符串
|
||||
* @param data 电子名片结构化数据
|
||||
* @param version 版本,默认 '4.0'
|
||||
*/
|
||||
export function generateVCard(data: VCardData, version: VCardVersion = '4.0'): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
// 1. 头部标记
|
||||
lines.push('BEGIN:VCARD');
|
||||
lines.push(`VERSION:${version}`);
|
||||
|
||||
// 2. 全名 (FN) - RFC 6350 强制要求
|
||||
lines.push(`FN:${escapeText(data.fn)}`);
|
||||
|
||||
// 3. 结构化姓名 (N:Family;Given;Additional;Prefix;Suffix)
|
||||
if (data.name) {
|
||||
const nParts = [
|
||||
escapeText(data.name.familyName || ''),
|
||||
escapeText(data.name.givenName || ''),
|
||||
escapeText(data.name.additionalNames || ''),
|
||||
escapeText(data.name.honorificPrefixes || ''),
|
||||
escapeText(data.name.honorificSuffixes || ''),
|
||||
];
|
||||
lines.push(`N:${nParts.join(';')}`);
|
||||
}
|
||||
|
||||
// 4. 头衔 (TITLE)
|
||||
if (data.title) {
|
||||
lines.push(`TITLE:${escapeText(data.title)}`);
|
||||
}
|
||||
|
||||
// 5. 组织机构 (ORG)
|
||||
if (data.org) {
|
||||
if (Array.isArray(data.org)) {
|
||||
lines.push(`ORG:${data.org.map((o) => escapeText(o)).join(';')}`);
|
||||
} else {
|
||||
lines.push(`ORG:${escapeText(data.org)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 电话 (TEL)
|
||||
if (data.tel) {
|
||||
const tels = Array.isArray(data.tel) ? data.tel : [data.tel];
|
||||
for (const t of tels) {
|
||||
const num = typeof t === 'string' ? t : t.number;
|
||||
const rawType = typeof t === 'string' ? 'CELL' : t.type || 'CELL';
|
||||
const type = version === '4.0' ? rawType.toUpperCase() : rawType.toUpperCase();
|
||||
lines.push(`TEL;TYPE=${type}:${num}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 邮箱 (EMAIL)
|
||||
if (data.email) {
|
||||
const emails = Array.isArray(data.email) ? data.email : [data.email];
|
||||
for (const e of emails) {
|
||||
const addr = typeof e === 'string' ? e : e.address;
|
||||
const rawType = typeof e === 'string' ? 'WORK' : e.type || 'WORK';
|
||||
const type = version === '3.0' ? `INTERNET,${rawType.toUpperCase()}` : rawType.toUpperCase();
|
||||
lines.push(`EMAIL;TYPE=${type}:${addr}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 网址 / 移动微官网 (URL)
|
||||
if (data.url) {
|
||||
const urls = Array.isArray(data.url) ? data.url : [data.url];
|
||||
for (const u of urls) {
|
||||
lines.push(`URL:${u}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 9. 备注 (NOTE)
|
||||
if (data.note) {
|
||||
lines.push(`NOTE:${escapeText(data.note)}`);
|
||||
}
|
||||
|
||||
// 10. 地址 (ADR)
|
||||
if (data.adr) {
|
||||
const adrs = Array.isArray(data.adr) ? data.adr : [data.adr];
|
||||
for (const a of adrs) {
|
||||
const adrParts = [
|
||||
escapeText(a.postOfficeBox || ''),
|
||||
escapeText(a.extendedAddress || ''),
|
||||
escapeText(a.street || ''),
|
||||
escapeText(a.locality || ''),
|
||||
escapeText(a.region || ''),
|
||||
escapeText(a.postalCode || ''),
|
||||
escapeText(a.countryName || ''),
|
||||
];
|
||||
lines.push(`ADR;TYPE=WORK:${adrParts.join(';')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 11. 社交网络主页 (X-SOCIALPROFILE)
|
||||
if (data.socialProfiles && data.socialProfiles.length > 0) {
|
||||
for (const sp of data.socialProfiles) {
|
||||
lines.push(`X-SOCIALPROFILE;TYPE=${sp.type.toLowerCase()}:${sp.url}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 12. 头像照片 (PHOTO)
|
||||
if (data.photo) {
|
||||
let photoUrl: string | undefined;
|
||||
let photoBase64: string | undefined;
|
||||
let mediaType = 'image/jpeg';
|
||||
|
||||
if (typeof data.photo === 'string') {
|
||||
if (data.photo.startsWith('http://') || data.photo.startsWith('https://')) {
|
||||
photoUrl = data.photo;
|
||||
} else if (data.photo.startsWith('data:')) {
|
||||
const match = data.photo.match(/^data:([^;]+);base64,(.+)$/i);
|
||||
if (match) {
|
||||
mediaType = match[1];
|
||||
photoBase64 = match[2];
|
||||
} else {
|
||||
photoBase64 = data.photo;
|
||||
}
|
||||
} else {
|
||||
photoBase64 = data.photo;
|
||||
}
|
||||
} else {
|
||||
photoUrl = data.photo.url;
|
||||
photoBase64 = data.photo.base64;
|
||||
if (data.photo.mediaType) {
|
||||
mediaType = data.photo.mediaType;
|
||||
}
|
||||
}
|
||||
|
||||
if (photoUrl) {
|
||||
if (version === '3.0') {
|
||||
lines.push(`PHOTO;VALUE=URI:${photoUrl}`);
|
||||
} else {
|
||||
lines.push(`PHOTO:${photoUrl}`);
|
||||
}
|
||||
} else if (photoBase64) {
|
||||
// 清洗 Base64 内部可能包含的空白换行
|
||||
const cleanBase64 = photoBase64.replace(/\s+/g, '');
|
||||
const typeShort = mediaType.split('/')[1]?.toUpperCase() || 'JPEG';
|
||||
|
||||
if (version === '3.0') {
|
||||
lines.push(`PHOTO;ENCODING=b;TYPE=${typeShort}:${cleanBase64}`);
|
||||
} else {
|
||||
lines.push(`PHOTO:data:${mediaType};base64,${cleanBase64}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 13. 尾部标记
|
||||
lines.push('END:VCARD');
|
||||
|
||||
// 对每一行应用 RFC 6350 行折叠,以 CRLF 拼接并以 CRLF 结尾
|
||||
return lines.map((l) => foldLine(l)).join('\r\n') + '\r\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并解析 vCard 字符串为 VCardData 结构
|
||||
* @param vcardString 待解析的原始 vCard 字符串
|
||||
*/
|
||||
export function parseVCard(vcardString: string): Partial<VCardData> {
|
||||
if (!vcardString || !vcardString.includes('BEGIN:VCARD') || !vcardString.includes('END:VCARD')) {
|
||||
throw new Error('Invalid vCard: missing BEGIN:VCARD or END:VCARD');
|
||||
}
|
||||
|
||||
// 第一步:反折叠
|
||||
const unfolded = unfoldLines(vcardString);
|
||||
const rawLines = unfolded.split(/\r?\n/);
|
||||
|
||||
const result: Partial<VCardData> = {};
|
||||
const tels: VCardTelephone[] = [];
|
||||
const emails: VCardEmail[] = [];
|
||||
const urls: string[] = [];
|
||||
const socialProfiles: VCardSocialProfile[] = [];
|
||||
|
||||
for (const rawLine of rawLines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line === 'BEGIN:VCARD' || line === 'END:VCARD' || line.startsWith('VERSION:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const colonIdx = line.indexOf(':');
|
||||
if (colonIdx === -1) continue;
|
||||
|
||||
const head = line.slice(0, colonIdx);
|
||||
const value = line.slice(colonIdx + 1);
|
||||
|
||||
const headParts = head.split(';');
|
||||
const tag = headParts[0].toUpperCase();
|
||||
const params = headParts.slice(1);
|
||||
|
||||
// 辅助解析 TYPE 参数
|
||||
let typeParam = '';
|
||||
for (const p of params) {
|
||||
const m = p.match(/^TYPE=(.+)$/i);
|
||||
if (m) {
|
||||
// 过滤 INTERNET 等前缀
|
||||
const types = m[1].split(',').map((t) => t.trim().replace(/^"|"$/g, ''));
|
||||
const nonInternet = types.filter((t) => t.toUpperCase() !== 'INTERNET');
|
||||
typeParam = nonInternet.length > 0 ? nonInternet[0] : types[0];
|
||||
}
|
||||
}
|
||||
|
||||
switch (tag) {
|
||||
case 'FN': {
|
||||
result.fn = unescapeText(value);
|
||||
break;
|
||||
}
|
||||
case 'N': {
|
||||
const parts = value.split(';');
|
||||
const name: VCardName = {};
|
||||
if (parts[0]) name.familyName = unescapeText(parts[0]);
|
||||
if (parts[1]) name.givenName = unescapeText(parts[1]);
|
||||
if (parts[2]) name.additionalNames = unescapeText(parts[2]);
|
||||
if (parts[3]) name.honorificPrefixes = unescapeText(parts[3]);
|
||||
if (parts[4]) name.honorificSuffixes = unescapeText(parts[4]);
|
||||
result.name = name;
|
||||
break;
|
||||
}
|
||||
case 'TITLE': {
|
||||
result.title = unescapeText(value);
|
||||
break;
|
||||
}
|
||||
case 'ORG': {
|
||||
const orgParts = value.split(';').map((p) => unescapeText(p));
|
||||
result.org = orgParts.length > 1 ? orgParts : orgParts[0];
|
||||
break;
|
||||
}
|
||||
case 'TEL': {
|
||||
tels.push({
|
||||
number: value,
|
||||
type: typeParam ? typeParam.toUpperCase() : 'CELL',
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'EMAIL': {
|
||||
emails.push({
|
||||
address: value,
|
||||
type: typeParam ? typeParam.toUpperCase() : 'WORK',
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'URL': {
|
||||
urls.push(value);
|
||||
break;
|
||||
}
|
||||
case 'NOTE': {
|
||||
result.note = unescapeText(value);
|
||||
break;
|
||||
}
|
||||
case 'ADR': {
|
||||
const parts = value.split(';').map((p) => unescapeText(p));
|
||||
// RFC 6350: [PO Box, Extended, Street, Locality, Region, PostalCode, Country]
|
||||
const adr: VCardAddress = {};
|
||||
if (parts[0]) adr.postOfficeBox = parts[0];
|
||||
if (parts[1]) adr.extendedAddress = parts[1];
|
||||
if (parts[2]) adr.street = parts[2];
|
||||
if (parts[3]) adr.locality = parts[3];
|
||||
if (parts[4]) adr.region = parts[4];
|
||||
if (parts[5]) adr.postalCode = parts[5];
|
||||
if (parts[6]) adr.countryName = parts[6];
|
||||
result.adr = adr;
|
||||
break;
|
||||
}
|
||||
case 'X-SOCIALPROFILE': {
|
||||
socialProfiles.push({
|
||||
type: typeParam.toLowerCase() || 'other',
|
||||
url: value,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'PHOTO': {
|
||||
// 判断是 Data URI 还是普通 URL 还是 3.0 Base64
|
||||
if (value.startsWith('data:')) {
|
||||
const m = value.match(/^data:([^;]+);base64,(.+)$/i);
|
||||
if (m) {
|
||||
result.photo = {
|
||||
mediaType: m[1],
|
||||
base64: m[2],
|
||||
};
|
||||
} else {
|
||||
result.photo = { url: value };
|
||||
}
|
||||
} else if (params.some((p) => /ENCODING=b/i.test(p) || /BASE64/i.test(p))) {
|
||||
let mediaType = 'image/jpeg';
|
||||
for (const p of params) {
|
||||
const tm = p.match(/TYPE=([^;]+)/i);
|
||||
if (tm) {
|
||||
mediaType = `image/${tm[1].toLowerCase()}`;
|
||||
}
|
||||
}
|
||||
result.photo = {
|
||||
mediaType,
|
||||
base64: value,
|
||||
};
|
||||
} else {
|
||||
result.photo = { url: value };
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tels.length > 0) result.tel = tels;
|
||||
if (emails.length > 0) result.email = emails;
|
||||
if (urls.length > 0) result.url = urls.length === 1 ? urls[0] : urls;
|
||||
if (socialProfiles.length > 0) result.socialProfiles = socialProfiles;
|
||||
|
||||
return result;
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户