feat: 完成全系统30个功能点开发与E2E全链路验收 (8轮调度32个子代理闭环)
- 模块一: 出海案例作品集、博客动态发布、人名发音语音条、RTL阿语多语言切换 - 模块二: AI顾问人设配置、行业术语库润色、RAG切片沙盒调试、流式会话与意向留资卡片 - 模块三: 全球访客雷达大屏、多维加权意向算分、外部CRM对接推送、团队公海池流转 - 模块四: 移动端NFC安全挂失、NFC动态路由规则配置、动态防伪验签、展会制卡Zip批量导出 - 模块五: 组织架构与名片分配、移动端个人中心设置、离职交接与历史线索迁移、安全审计日志 - 模块六: Stripe国际信用卡订阅Webhook、独立二级域名与SSL检测、坐席配额预警、多币种发票 - 模块七: 纸质名片OCR解析入库、国际商务日程RFC 5545 ICS日历、微官网离线SWR预加载、展会实时排行榜大屏 - 模块八: 全球出海SEO OpenGraph元标签注入与社交爬虫探测、30点端到端全链路业务流集成测试套件
这个提交包含在:
@@ -0,0 +1,31 @@
|
||||
import { CoolController, BaseController } from '@cool-midway/core';
|
||||
import { CardBentoEntity } from '../../entity/bento';
|
||||
import { CardBentoService } from '../../service/bento';
|
||||
|
||||
/**
|
||||
* 后台 Bento 标准 CRUD 配置
|
||||
*/
|
||||
export const adminCardBentoCurdOption = {
|
||||
api: ['add', 'delete', 'update', 'info', 'list', 'page'] as Array<
|
||||
'add' | 'delete' | 'update' | 'info' | 'list' | 'page'
|
||||
>,
|
||||
entity: CardBentoEntity,
|
||||
service: CardBentoService,
|
||||
pageQueryOp: {
|
||||
keyWordLikeFields: ['a.title', 'a.content'],
|
||||
fieldEq: ['a.cardId', 'a.type', 'a.status'],
|
||||
addOrderBy: { sortOrder: 'asc' as const },
|
||||
},
|
||||
serviceApis: [
|
||||
{ method: 'getBlocksByCardId', summary: '按名片获取有序 Bento' },
|
||||
{ method: 'batchSaveLayout', summary: '批量同步画布排版' },
|
||||
{ method: 'toggleStatus', summary: '切换块启用状态' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 名片模块-后台 Bento 画布
|
||||
* 路由前缀:/admin/card/bento
|
||||
*/
|
||||
@CoolController(adminCardBentoCurdOption)
|
||||
export class AdminCardBentoController extends BaseController {}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { CoolController, BaseController } from '@cool-midway/core';
|
||||
import { CardCasesEntity } from '../../entity/cases';
|
||||
import { CardCasesService } from '../../service/cases';
|
||||
|
||||
/**
|
||||
* 后台出海案例作品集标准 CRUD 配置
|
||||
*/
|
||||
export const adminCardCasesCurdOption = {
|
||||
api: ['add', 'delete', 'update', 'info', 'list', 'page'] as Array<
|
||||
'add' | 'delete' | 'update' | 'info' | 'list' | 'page'
|
||||
>,
|
||||
entity: CardCasesEntity,
|
||||
service: CardCasesService,
|
||||
pageQueryOp: {
|
||||
keyWordLikeFields: ['a.title', 'a.client', 'a.metrics'],
|
||||
fieldEq: ['a.cardId'],
|
||||
addOrderBy: { sortOrder: 'asc' as const },
|
||||
},
|
||||
serviceApis: [
|
||||
{ method: 'getCasesByCardId', summary: '按名片获取案例列表' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 名片模块-后台出海案例作品集管理
|
||||
* 路由前缀:/admin/card/cases
|
||||
*/
|
||||
@CoolController(adminCardCasesCurdOption)
|
||||
export class AdminCardCasesController extends BaseController {}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { CoolController, BaseController } from '@cool-midway/core';
|
||||
import { CardInfoEntity } from '../../entity/info';
|
||||
import { CardInfoService } from '../../service/info';
|
||||
|
||||
/**
|
||||
* 后台登录态插入参数
|
||||
*/
|
||||
interface AdminInsertCtx {
|
||||
admin?: {
|
||||
userId?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台名片标准 CRUD 配置
|
||||
*/
|
||||
export const adminCardInfoCurdOption = {
|
||||
api: ['add', 'delete', 'update', 'info', 'list', 'page'] as Array<
|
||||
'add' | 'delete' | 'update' | 'info' | 'list' | 'page'
|
||||
>,
|
||||
entity: CardInfoEntity,
|
||||
service: CardInfoService,
|
||||
insertParam: (ctx: AdminInsertCtx) => ({
|
||||
userId: ctx.admin?.userId,
|
||||
}),
|
||||
infoIgnoreProperty: ['remark', 'internalConfig'],
|
||||
pageQueryOp: {
|
||||
keyWordLikeFields: ['a.name', 'a.title', 'a.company', 'a.slug'],
|
||||
fieldEq: ['a.status', 'a.themeCode'],
|
||||
addOrderBy: { updateTime: 'desc' as const },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 名片模块-后台名片信息
|
||||
* 路由前缀:/admin/card/info
|
||||
*/
|
||||
@CoolController(adminCardInfoCurdOption)
|
||||
export class AdminCardInfoController extends BaseController {}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { CoolController, BaseController } from '@cool-midway/core';
|
||||
import { CardLocaleEntity } from '../../entity/locale';
|
||||
import { CardLocaleService } from '../../service/locale';
|
||||
|
||||
/**
|
||||
* 后台名片多语言标准 CRUD 配置
|
||||
*/
|
||||
export const adminCardLocaleCurdOption = {
|
||||
api: ['add', 'delete', 'update', 'info', 'list', 'page'] as Array<
|
||||
'add' | 'delete' | 'update' | 'info' | 'list' | 'page'
|
||||
>,
|
||||
entity: CardLocaleEntity,
|
||||
service: CardLocaleService,
|
||||
pageQueryOp: {
|
||||
fieldEq: ['a.cardId', 'a.locale'],
|
||||
},
|
||||
serviceApis: [
|
||||
{ method: 'upsert', summary: '按名片语言覆盖写入' },
|
||||
{ method: 'listByCardId', summary: '按名片列出多语分支' },
|
||||
{ method: 'getByCardIdAndLocale', summary: '按名片与语言获取' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 名片模块-后台内容级多语言
|
||||
* 路由前缀:/admin/card/locale
|
||||
*/
|
||||
@CoolController(adminCardLocaleCurdOption)
|
||||
export class AdminCardLocaleController extends BaseController {}
|
||||
@@ -6,6 +6,38 @@ import {
|
||||
TagTypes,
|
||||
} from '@cool-midway/core';
|
||||
import { CardPortalService } from '../../service/portal';
|
||||
import { CardCalendarService } from '../../service/calendar';
|
||||
import { CardSeoService } from '../../service/seo';
|
||||
import {
|
||||
GeoHeaderMap,
|
||||
resolveSuggestedLocale,
|
||||
} from '../../../../comm/i18n/geo';
|
||||
|
||||
export interface PortalRequestContext {
|
||||
headers?: GeoHeaderMap;
|
||||
set?: (key: string, value: string) => void;
|
||||
status?: number;
|
||||
body?: unknown;
|
||||
get?: (field: string) => string | undefined;
|
||||
}
|
||||
|
||||
export interface MeetingIcsQuery {
|
||||
summary?: string;
|
||||
title?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
organizerName?: string;
|
||||
organizerEmail?: string;
|
||||
organizer?: string;
|
||||
uid?: string;
|
||||
url?: string;
|
||||
slug?: string;
|
||||
id?: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 访客端公开微官网与短链路由控制器 (免登录触达)
|
||||
@@ -17,6 +49,15 @@ export class OpenCardPortalController extends BaseController {
|
||||
@Inject()
|
||||
cardPortalService: CardPortalService;
|
||||
|
||||
@Inject()
|
||||
cardCalendarService: CardCalendarService;
|
||||
|
||||
@Inject()
|
||||
cardSeoService: CardSeoService;
|
||||
|
||||
@Inject()
|
||||
ctx: PortalRequestContext;
|
||||
|
||||
/**
|
||||
* 获取公开名片微官网详情 (Query 方式)
|
||||
* 接口地址: GET /open/card/portal/detail?id=123 或 ?slug=alex-zheng
|
||||
@@ -26,7 +67,8 @@ export class OpenCardPortalController extends BaseController {
|
||||
async detail(
|
||||
@Query('id') id?: string | number,
|
||||
@Query('slug') slug?: string,
|
||||
@Query('slugOrId') slugOrId?: string
|
||||
@Query('slugOrId') slugOrId?: string,
|
||||
@Query('lang') lang?: string
|
||||
) {
|
||||
const target = slug || id || slugOrId;
|
||||
if (!target) {
|
||||
@@ -34,7 +76,10 @@ export class OpenCardPortalController extends BaseController {
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await this.cardPortalService.getPortalDetail(target);
|
||||
const data = await this.cardPortalService.getPortalDetail(
|
||||
target,
|
||||
this.detectSuggestedLocale(lang)
|
||||
);
|
||||
return this.ok(data);
|
||||
} catch (err: any) {
|
||||
return this.fail(
|
||||
@@ -44,25 +89,234 @@ export class OpenCardPortalController extends BaseController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出国际商务日程预约排期 ICS 日历订阅文件
|
||||
* 接口地址: GET /open/card/portal/meeting-ics
|
||||
*/
|
||||
@CoolTag(TagTypes.IGNORE_TOKEN)
|
||||
@Get('/meeting-ics', { summary: '导出会议排期 ICS 日历订阅文件' })
|
||||
async meetingIcs(
|
||||
@Query() query?: MeetingIcsQuery | string,
|
||||
@Query('summary') summaryParam?: string,
|
||||
@Query('startTime') startTimeParam?: string,
|
||||
@Query('endTime') endTimeParam?: string,
|
||||
@Query('description') descriptionParam?: string,
|
||||
@Query('location') locationParam?: string,
|
||||
@Query('organizerName') organizerNameParam?: string,
|
||||
@Query('organizerEmail') organizerEmailParam?: string,
|
||||
@Query('uid') uidParam?: string
|
||||
) {
|
||||
let summary: string | undefined;
|
||||
let startTime: string | undefined;
|
||||
let endTime: string | undefined;
|
||||
let description: string | undefined;
|
||||
let location: string | undefined;
|
||||
let organizerName: string | undefined;
|
||||
let organizerEmail: string | undefined;
|
||||
let uid: string | undefined;
|
||||
let url: string | undefined;
|
||||
let organizer: string | undefined;
|
||||
|
||||
if (typeof query === 'object' && query !== null) {
|
||||
summary = query.summary || query.title;
|
||||
startTime = query.startTime || query.start;
|
||||
endTime = query.endTime || query.end;
|
||||
description = query.description;
|
||||
location = query.location;
|
||||
organizerName = query.organizerName;
|
||||
organizerEmail = query.organizerEmail;
|
||||
organizer = query.organizer;
|
||||
uid = query.uid;
|
||||
url = query.url;
|
||||
} else if (typeof query === 'string') {
|
||||
summary = query;
|
||||
if (
|
||||
summaryParam &&
|
||||
(summaryParam.includes('-') ||
|
||||
summaryParam.includes('T') ||
|
||||
!isNaN(Number(summaryParam)))
|
||||
) {
|
||||
startTime = summaryParam;
|
||||
endTime = startTimeParam;
|
||||
description = endTimeParam;
|
||||
location = descriptionParam;
|
||||
} else {
|
||||
summary = query || summaryParam;
|
||||
startTime = startTimeParam;
|
||||
endTime = endTimeParam;
|
||||
description = descriptionParam;
|
||||
location = locationParam;
|
||||
}
|
||||
organizerName = organizerNameParam;
|
||||
organizerEmail = organizerEmailParam;
|
||||
uid = uidParam;
|
||||
}
|
||||
|
||||
if (!summary || !startTime || !endTime) {
|
||||
return this.fail('缺少会议标题或排期时间参数~', 1001);
|
||||
}
|
||||
|
||||
try {
|
||||
const ics = this.cardCalendarService.generateIcsEvent({
|
||||
summary,
|
||||
startTime,
|
||||
endTime,
|
||||
description,
|
||||
location,
|
||||
organizer,
|
||||
organizerName,
|
||||
organizerEmail,
|
||||
uid,
|
||||
url,
|
||||
});
|
||||
|
||||
if (this.ctx && typeof this.ctx.set === 'function') {
|
||||
this.ctx.set('Content-Type', 'text/calendar; charset=utf-8');
|
||||
const safeFilename = encodeURIComponent(
|
||||
summary.slice(0, 30).replace(/[/\\?%*:|"<>]/g, '_')
|
||||
);
|
||||
this.ctx.set(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="meeting-${safeFilename || 'schedule'}.ics"`
|
||||
);
|
||||
this.ctx.body = ics;
|
||||
}
|
||||
|
||||
return ics;
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : '生成日历预约文件失败';
|
||||
return this.fail(message, 1001);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 社交爬虫与网页预览 HTML 渲染 (免登录服务端渲染短链预览)
|
||||
* 接口地址: GET /open/card/portal/preview-html/:slug
|
||||
*/
|
||||
@CoolTag(TagTypes.IGNORE_TOKEN)
|
||||
@Get('/preview-html/:slug', { summary: '社交爬虫与网页预览 HTML 渲染' })
|
||||
async previewHtml(@Param('slug') slug: string, @Query('lang') lang?: string) {
|
||||
if (!slug) {
|
||||
if (this.ctx && typeof this.ctx.set === 'function') {
|
||||
this.ctx.set('Content-Type', 'text/html; charset=utf-8');
|
||||
}
|
||||
if (this.ctx) this.ctx.status = 400;
|
||||
return this.cardSeoService.renderNotFoundHtml('缺少名片标识参数~');
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await this.cardPortalService.getPortalDetail(
|
||||
slug,
|
||||
this.detectSuggestedLocale(lang)
|
||||
);
|
||||
|
||||
const userAgent = this.getRequestHeader('user-agent');
|
||||
const isCrawler = this.cardSeoService.isCrawler(userAgent);
|
||||
const crawlerName = this.cardSeoService.detectCrawler(userAgent);
|
||||
|
||||
const html = this.cardSeoService.renderCardHtml(data, {
|
||||
isCrawler,
|
||||
crawlerName,
|
||||
});
|
||||
|
||||
if (this.ctx && typeof this.ctx.set === 'function') {
|
||||
this.ctx.set('Content-Type', 'text/html; charset=utf-8');
|
||||
}
|
||||
if (this.ctx) {
|
||||
this.ctx.status = 200;
|
||||
this.ctx.body = html;
|
||||
}
|
||||
return html;
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '名片不存在或已下线';
|
||||
const status =
|
||||
typeof err === 'object' && err !== null && 'status' in err
|
||||
? Number((err as { status?: number }).status) || 404
|
||||
: 404;
|
||||
|
||||
if (this.ctx && typeof this.ctx.set === 'function') {
|
||||
this.ctx.set('Content-Type', 'text/html; charset=utf-8');
|
||||
}
|
||||
if (this.ctx) {
|
||||
this.ctx.status = status;
|
||||
this.ctx.body = this.cardSeoService.renderNotFoundHtml(message);
|
||||
}
|
||||
return this.cardSeoService.renderNotFoundHtml(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据自定义短链或 ID 获取微官网详情 (Path 参数方式)
|
||||
* 接口地址: GET /open/card/portal/:slugOrId (如 /open/card/portal/alex-zheng)
|
||||
*/
|
||||
@CoolTag(TagTypes.IGNORE_TOKEN)
|
||||
@Get('/:slugOrId', { summary: '公开微官网详情 (Path 短链/ID)' })
|
||||
async getBySlugOrId(@Param('slugOrId') slugOrId: string) {
|
||||
if (!slugOrId || slugOrId === 'detail') {
|
||||
async getBySlugOrId(
|
||||
@Param('slugOrId') slugOrId: string,
|
||||
@Query('lang') lang?: string
|
||||
) {
|
||||
if (
|
||||
!slugOrId ||
|
||||
slugOrId === 'detail' ||
|
||||
slugOrId === 'meeting-ics' ||
|
||||
slugOrId === 'preview-html'
|
||||
) {
|
||||
return this.fail('缺少名片标识参数~', 1001);
|
||||
}
|
||||
|
||||
// 社交爬虫探测:若检测到社交机器人 UA,自动透明转为返回服务端渲染 HTML
|
||||
const userAgent = this.getRequestHeader('user-agent');
|
||||
if (this.cardSeoService && this.cardSeoService.isCrawler(userAgent)) {
|
||||
return (await this.previewHtml(slugOrId, lang)) as unknown as ReturnType<
|
||||
BaseController['ok']
|
||||
>;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await this.cardPortalService.getPortalDetail(slugOrId);
|
||||
return this.ok(data);
|
||||
} catch (err: any) {
|
||||
return this.fail(
|
||||
err?.message || '获取名片微官网失败',
|
||||
err?.status || 1001
|
||||
const data = await this.cardPortalService.getPortalDetail(
|
||||
slugOrId,
|
||||
this.detectSuggestedLocale(lang)
|
||||
);
|
||||
return this.ok(data);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '获取名片微官网失败';
|
||||
const status =
|
||||
typeof err === 'object' && err !== null && 'status' in err
|
||||
? Number((err as { status?: number }).status) || 1001
|
||||
: 1001;
|
||||
return this.fail(message, status);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 query.lang 与请求头探测建议语言
|
||||
*/
|
||||
private detectSuggestedLocale(lang?: string): string {
|
||||
return resolveSuggestedLocale({
|
||||
lang,
|
||||
headers: this.ctx?.headers,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全获取请求头字段
|
||||
*/
|
||||
private getRequestHeader(name: string): string | undefined {
|
||||
if (!this.ctx) return undefined;
|
||||
if (typeof this.ctx.get === 'function') {
|
||||
const val = this.ctx.get(name);
|
||||
if (val) return val;
|
||||
}
|
||||
const headers = this.ctx.headers;
|
||||
if (!headers) return undefined;
|
||||
const target = name.toLowerCase();
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (key.toLowerCase() === target) {
|
||||
if (Array.isArray(value)) return value[0];
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Inject, Provide, Get, Query } from '@midwayjs/core';
|
||||
import {
|
||||
CoolController,
|
||||
BaseController,
|
||||
CoolTag,
|
||||
TagTypes,
|
||||
} from '@cool-midway/core';
|
||||
import { CardQrcodeService } from '../../service/qrcode';
|
||||
|
||||
/**
|
||||
* 访客端公开动态短链二维码下载(免登录)
|
||||
* 路由前缀: /open/card/qrcode
|
||||
*/
|
||||
@Provide()
|
||||
@CoolController()
|
||||
export class OpenCardQrcodeController extends BaseController {
|
||||
@Inject()
|
||||
cardQrcodeService: CardQrcodeService;
|
||||
|
||||
/**
|
||||
* 下载 SVG 二维码
|
||||
* 接口地址: GET /open/card/qrcode/svg?slugOrId=alex-zheng
|
||||
*/
|
||||
@CoolTag(TagTypes.IGNORE_TOKEN)
|
||||
@Get('/svg', { summary: '公开名片二维码 SVG' })
|
||||
async svg(@Query('slugOrId') slugOrId?: string) {
|
||||
const data = await this.cardQrcodeService.generateSvg(slugOrId);
|
||||
return this.ok(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载 PNG 二维码(dataURL)
|
||||
* 接口地址: GET /open/card/qrcode/png?slugOrId=alex-zheng
|
||||
*/
|
||||
@CoolTag(TagTypes.IGNORE_TOKEN)
|
||||
@Get('/png', { summary: '公开名片二维码 PNG' })
|
||||
async png(@Query('slugOrId') slugOrId?: string) {
|
||||
const data = await this.cardQrcodeService.generatePng(slugOrId);
|
||||
return this.ok(data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { BaseEntity } from '../../../comm/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import type { CardCase } from '../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* 出海案例作品集实体
|
||||
* 映射数据表 card_cases,严格契合 CardCase 跨端数据契约
|
||||
*/
|
||||
@Entity('card_cases')
|
||||
export class CardCasesEntity extends BaseEntity implements CardCase {
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'card_id',
|
||||
comment: '所属名片 ID',
|
||||
type: 'bigint',
|
||||
})
|
||||
cardId: number;
|
||||
|
||||
@Column({
|
||||
name: 'title',
|
||||
comment: '案例标题/项目名称',
|
||||
length: 150,
|
||||
})
|
||||
title: string;
|
||||
|
||||
@Column({
|
||||
name: 'client',
|
||||
comment: '客户/出海品牌名称',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
})
|
||||
client: string;
|
||||
|
||||
@Column({
|
||||
name: 'duration',
|
||||
comment: '项目周期 (如: 2024.01 - 2024.06)',
|
||||
length: 50,
|
||||
nullable: true,
|
||||
})
|
||||
duration: string;
|
||||
|
||||
@Column({
|
||||
name: 'metrics',
|
||||
comment: '量化成效/KPI (如: +150% 留资率, 2M+ 曝光)',
|
||||
length: 255,
|
||||
nullable: true,
|
||||
})
|
||||
metrics: string;
|
||||
|
||||
@Column({
|
||||
name: 'cover_image',
|
||||
comment: '案例封面图 URL',
|
||||
length: 500,
|
||||
nullable: true,
|
||||
})
|
||||
coverImage: string;
|
||||
|
||||
@Column({
|
||||
name: 'pdf_url',
|
||||
comment: '案例详情 PDF / 白皮书 URL',
|
||||
length: 500,
|
||||
nullable: true,
|
||||
})
|
||||
pdfUrl: string;
|
||||
|
||||
@Column({
|
||||
name: 'description',
|
||||
comment: '案例详情简述/背景成果',
|
||||
type: 'text',
|
||||
nullable: true,
|
||||
})
|
||||
description: string;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'sort_order',
|
||||
comment: '排序序号 (升序)',
|
||||
type: 'int',
|
||||
default: 0,
|
||||
})
|
||||
sortOrder: number;
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { BaseEntity } from '../../../comm/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import { CardProfile, CardStatus } from '../../../comm/types/omni';
|
||||
import {
|
||||
CardProfile,
|
||||
CardStatus,
|
||||
HandoverRedirectType,
|
||||
} from '../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* 名片基础信息实体
|
||||
@@ -78,6 +82,30 @@ export class CardInfoEntity extends BaseEntity implements CardProfile {
|
||||
})
|
||||
linkedin: string;
|
||||
|
||||
@Column({
|
||||
name: 'telegram',
|
||||
comment: 'Telegram 账号或链接',
|
||||
length: 255,
|
||||
nullable: true,
|
||||
})
|
||||
telegram: string;
|
||||
|
||||
@Column({
|
||||
name: 'twitter',
|
||||
comment: 'X (Twitter) 账号或链接',
|
||||
length: 255,
|
||||
nullable: true,
|
||||
})
|
||||
twitter: string;
|
||||
|
||||
@Column({
|
||||
name: 'wechat',
|
||||
comment: '微信号',
|
||||
length: 255,
|
||||
nullable: true,
|
||||
})
|
||||
wechat: string;
|
||||
|
||||
@Column({
|
||||
name: 'timezone',
|
||||
comment: '所在地时区',
|
||||
@@ -136,4 +164,22 @@ export class CardInfoEntity extends BaseEntity implements CardProfile {
|
||||
nullable: true,
|
||||
})
|
||||
internalConfig: string;
|
||||
|
||||
@Column({
|
||||
name: 'redirect_type',
|
||||
comment: '短链重定向策略 (none: 无, card: 接替人名片, portal: 企业公共微官网)',
|
||||
length: 20,
|
||||
nullable: true,
|
||||
default: null,
|
||||
})
|
||||
redirectType?: HandoverRedirectType | string | null;
|
||||
|
||||
@Column({
|
||||
name: 'redirect_target',
|
||||
comment: '短链重定向目标 (接替人名片ID/Slug 或 企业公共微官网URL)',
|
||||
length: 500,
|
||||
nullable: true,
|
||||
default: null,
|
||||
})
|
||||
redirectTarget?: string | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { BaseEntity } from '../../../comm/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 名片内容级多语言分支
|
||||
* 同一张名片可并存多语种文案,唯一逻辑 (cardId, locale) 由服务 upsert 保证
|
||||
*/
|
||||
@Entity('card_locale')
|
||||
export class CardLocaleEntity extends BaseEntity {
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'card_id',
|
||||
comment: '所属名片 ID',
|
||||
type: 'bigint',
|
||||
})
|
||||
cardId: number;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'locale',
|
||||
comment: '语言标签 (如 zh-CN / en-US / ar-SA)',
|
||||
length: 16,
|
||||
})
|
||||
locale: string;
|
||||
|
||||
@Column({
|
||||
name: 'name',
|
||||
comment: '姓名',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
})
|
||||
name?: string;
|
||||
|
||||
@Column({
|
||||
name: 'title',
|
||||
comment: '职业头衔/职位',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
})
|
||||
title?: string;
|
||||
|
||||
@Column({
|
||||
name: 'company',
|
||||
comment: '企业/组织名称',
|
||||
length: 150,
|
||||
nullable: true,
|
||||
})
|
||||
company?: string;
|
||||
|
||||
@Column({
|
||||
name: 'slogan',
|
||||
comment: '个人签名/品牌 Slogan',
|
||||
type: 'text',
|
||||
nullable: true,
|
||||
})
|
||||
slogan?: string;
|
||||
}
|
||||
@@ -13,6 +13,30 @@ export interface GetBentoOptions {
|
||||
status?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理后台 serviceApis:按名片拉取有序 Bento
|
||||
*/
|
||||
export interface GetBlocksByCardIdParams {
|
||||
cardId: number;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理后台 serviceApis:批量同步画布排版
|
||||
*/
|
||||
export interface BatchSaveLayoutParams {
|
||||
cardId: number;
|
||||
blocks: Array<Partial<CardBentoEntity>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理后台 serviceApis:切换块启用状态
|
||||
*/
|
||||
export interface ToggleStatusParams {
|
||||
id: number;
|
||||
targetStatus?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bento Grid 便当盒组件树服务
|
||||
* 提供微官网组件树的有序解析、批量网格排版同步、状态切换与增删改查
|
||||
@@ -28,6 +52,7 @@ export class CardBentoService extends BaseService {
|
||||
/**
|
||||
* 按 cardId 获取有序的 Bento 网格块列表
|
||||
* 严格按照 sortOrder 从小到大升序排序
|
||||
* 兼容管理后台 serviceApis:POST body 为单参数对象 { cardId, status }
|
||||
*
|
||||
* @param cardId 名片 ID
|
||||
* @param options 过滤配置 (如过滤启用状态)
|
||||
@@ -35,7 +60,24 @@ export class CardBentoService extends BaseService {
|
||||
async getBlocksByCardId(
|
||||
cardId: number,
|
||||
options?: GetBentoOptions
|
||||
): Promise<CardBentoEntity[]>;
|
||||
async getBlocksByCardId(
|
||||
params: GetBlocksByCardIdParams
|
||||
): Promise<CardBentoEntity[]>;
|
||||
async getBlocksByCardId(
|
||||
cardIdOrParams: number | GetBlocksByCardIdParams,
|
||||
options?: GetBentoOptions
|
||||
): Promise<CardBentoEntity[]> {
|
||||
if (typeof cardIdOrParams !== 'number') {
|
||||
return this.getBlocksByCardId(
|
||||
cardIdOrParams.cardId,
|
||||
cardIdOrParams.status !== undefined
|
||||
? { status: cardIdOrParams.status }
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
|
||||
const cardId = cardIdOrParams;
|
||||
const where: Record<string, unknown> = { cardId };
|
||||
if (options?.status !== undefined) {
|
||||
where.status = options.status;
|
||||
@@ -68,6 +110,7 @@ export class CardBentoService extends BaseService {
|
||||
/**
|
||||
* 批量保存更新 Bento 网格布局
|
||||
* 支持微官网可视化拖拽器的全量画布同步(包含增、改、删旧块以及重排 sortOrder)
|
||||
* 兼容管理后台 serviceApis:POST body 为单参数对象 { cardId, blocks }
|
||||
*
|
||||
* @param cardId 名片 ID
|
||||
* @param blocks 前端提交的 Bento 块列表 (按画板排序)
|
||||
@@ -75,18 +118,32 @@ export class CardBentoService extends BaseService {
|
||||
async batchSaveLayout(
|
||||
cardId: number,
|
||||
blocks: Array<Partial<CardBentoEntity>>
|
||||
): Promise<CardBentoEntity[]>;
|
||||
async batchSaveLayout(
|
||||
params: BatchSaveLayoutParams
|
||||
): Promise<CardBentoEntity[]>;
|
||||
async batchSaveLayout(
|
||||
cardIdOrParams: number | BatchSaveLayoutParams,
|
||||
blocks?: Array<Partial<CardBentoEntity>>
|
||||
): Promise<CardBentoEntity[]> {
|
||||
if (typeof cardIdOrParams !== 'number') {
|
||||
return this.batchSaveLayout(cardIdOrParams.cardId, cardIdOrParams.blocks);
|
||||
}
|
||||
|
||||
const cardId = cardIdOrParams;
|
||||
if (!cardId) {
|
||||
throw new CoolCommException('名片 ID 不能为空');
|
||||
}
|
||||
|
||||
const incomingBlocks = blocks as Array<Partial<CardBentoEntity>>;
|
||||
|
||||
// 1. 查询当前数据库中该名片下的所有块
|
||||
const existingBlocks = await this.cardBentoEntity.find({
|
||||
where: { cardId },
|
||||
});
|
||||
|
||||
// 2. 收集本次提交中带有 ID 的旧块集合
|
||||
const incomingIds = blocks
|
||||
const incomingIds = incomingBlocks
|
||||
.map(b => (b.id ? Number(b.id) : null))
|
||||
.filter((id): id is number => id !== null);
|
||||
|
||||
@@ -100,7 +157,7 @@ export class CardBentoService extends BaseService {
|
||||
}
|
||||
|
||||
// 4. 规范化并保存本次提交的所有块
|
||||
const entitiesToSave = blocks.map((block, index) => {
|
||||
const entitiesToSave = incomingBlocks.map((block, index) => {
|
||||
const normalized = this.normalizeBlock(block);
|
||||
normalized.cardId = cardId;
|
||||
// 按照数组顺序校准排序序号
|
||||
@@ -132,6 +189,7 @@ export class CardBentoService extends BaseService {
|
||||
|
||||
/**
|
||||
* 切换或指定 Bento 块的状态 (启用/禁用)
|
||||
* 兼容管理后台 serviceApis:POST body 为单参数对象 { id, targetStatus }
|
||||
*
|
||||
* @param id 块 ID
|
||||
* @param targetStatus 可选的目标状态,若不传则在 0 和 1 间翻转
|
||||
@@ -139,7 +197,17 @@ export class CardBentoService extends BaseService {
|
||||
async toggleStatus(
|
||||
id: number,
|
||||
targetStatus?: number
|
||||
): Promise<CardBentoEntity>;
|
||||
async toggleStatus(params: ToggleStatusParams): Promise<CardBentoEntity>;
|
||||
async toggleStatus(
|
||||
idOrParams: number | ToggleStatusParams,
|
||||
targetStatus?: number
|
||||
): Promise<CardBentoEntity> {
|
||||
if (typeof idOrParams !== 'number') {
|
||||
return this.toggleStatus(idOrParams.id, idOrParams.targetStatus);
|
||||
}
|
||||
|
||||
const id = idOrParams;
|
||||
const block = await this.cardBentoEntity.findOneBy({ id });
|
||||
if (!block) {
|
||||
throw new CoolCommException('Bento 网格块不存在');
|
||||
@@ -162,6 +230,10 @@ export class CardBentoService extends BaseService {
|
||||
): Partial<CardBentoEntity> {
|
||||
const item: Partial<CardBentoEntity> = { ...block };
|
||||
|
||||
if (item.status === undefined) {
|
||||
item.status = 1;
|
||||
}
|
||||
|
||||
// 约束 colSpan 在 1~2 之间
|
||||
if (item.colSpan !== undefined) {
|
||||
const col = Number(item.colSpan);
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import { Provide } from '@midwayjs/core';
|
||||
import { BaseService } from '@cool-midway/core';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* 会议组织者接口
|
||||
*/
|
||||
export interface MeetingOrganizer {
|
||||
name?: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会议参会者接口
|
||||
*/
|
||||
export interface MeetingAttendee {
|
||||
name?: string;
|
||||
email: string;
|
||||
rsvp?: boolean;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 5545 日程事件配置项
|
||||
*/
|
||||
export interface MeetingEventOptions {
|
||||
uid?: string;
|
||||
summary: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
startTime: Date | string | number;
|
||||
endTime: Date | string | number;
|
||||
start?: Date | string | number;
|
||||
end?: Date | string | number;
|
||||
organizer?: MeetingOrganizer | string;
|
||||
organizerName?: string;
|
||||
organizerEmail?: string;
|
||||
attendees?: MeetingAttendee[];
|
||||
url?: string;
|
||||
status?: 'CONFIRMED' | 'TENTATIVE' | 'CANCELLED' | string;
|
||||
dtStamp?: Date | string | number;
|
||||
prodId?: string;
|
||||
sequence?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义 RFC 5545 TEXT 字段值中的保留特殊字符
|
||||
* 顺序:反斜杠 -> 分号 -> 逗号 -> 换行符
|
||||
*/
|
||||
export function escapeIcsText(text?: string): string {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/\r\n|\r|\n/g, '\\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 反转义 RFC 5545 TEXT 字段值
|
||||
*/
|
||||
export function unescapeIcsText(text?: string): string {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/\\n/gi, '\n')
|
||||
.replace(/\\,/g, ',')
|
||||
.replace(/\\;/g, ';')
|
||||
.replace(/\\\\/g, '\\');
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 UTC 时间戳 (RFC 5545: YYYYMMDDTHHmmssZ)
|
||||
*/
|
||||
export function formatIcsDate(date: Date | string | number): string {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
if (isNaN(d.getTime())) {
|
||||
throw new Error(`Invalid date provided for ICS: ${date}`);
|
||||
}
|
||||
const pad = (n: number): string => (n < 10 ? '0' + n : String(n));
|
||||
const year = d.getUTCFullYear();
|
||||
const month = pad(d.getUTCMonth() + 1);
|
||||
const day = pad(d.getUTCDate());
|
||||
const hours = pad(d.getUTCHours());
|
||||
const minutes = pad(d.getUTCMinutes());
|
||||
const seconds = pad(d.getUTCSeconds());
|
||||
return `${year}${month}${day}T${hours}${minutes}${seconds}Z`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 RFC 5545 兼容的 ORGANIZER 行
|
||||
*/
|
||||
function formatOrganizerLine(
|
||||
organizer?: MeetingOrganizer | string,
|
||||
organizerName?: string,
|
||||
organizerEmail?: string
|
||||
): string {
|
||||
if (typeof organizer === 'string') {
|
||||
if (organizer.startsWith('ORGANIZER:')) return organizer;
|
||||
if (organizer.startsWith('mailto:')) return `ORGANIZER:${organizer}`;
|
||||
if (organizer.includes('@')) return `ORGANIZER:mailto:${organizer}`;
|
||||
return `ORGANIZER:${organizer}`;
|
||||
}
|
||||
|
||||
const name = organizer?.name || organizerName;
|
||||
const email = organizer?.email || organizerEmail;
|
||||
|
||||
if (email) {
|
||||
const mailto = email.startsWith('mailto:') ? email : `mailto:${email}`;
|
||||
if (name) {
|
||||
return `ORGANIZER;CN=${escapeIcsText(name)}:${mailto}`;
|
||||
}
|
||||
return `ORGANIZER:${mailto}`;
|
||||
}
|
||||
|
||||
return 'ORGANIZER:mailto:service@omnicard.ai';
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成符合 RFC 5545 标准的完整 iCalendar (.ics) 文本
|
||||
*/
|
||||
export function generateIcsEvent(meeting: MeetingEventOptions): string {
|
||||
const prodId = meeting.prodId || '-//OmniCard AI//OmniCard Calendar 1.0//EN';
|
||||
const uid =
|
||||
meeting.uid ||
|
||||
`omnicard-${Date.now()}-${crypto.randomBytes(4).toString('hex')}@omnicard.ai`;
|
||||
const dtStamp = formatIcsDate(meeting.dtStamp || new Date());
|
||||
const dtStart = formatIcsDate(meeting.startTime ?? meeting.start ?? new Date());
|
||||
const dtEnd = formatIcsDate(meeting.endTime ?? meeting.end ?? new Date());
|
||||
|
||||
const summary = escapeIcsText(meeting.summary || meeting.title || '商务洽谈');
|
||||
const description = escapeIcsText(meeting.description || '');
|
||||
const location = escapeIcsText(meeting.location || '');
|
||||
const organizerLine = formatOrganizerLine(
|
||||
meeting.organizer,
|
||||
meeting.organizerName,
|
||||
meeting.organizerEmail
|
||||
);
|
||||
|
||||
const lines: string[] = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
`PRODID:${prodId}`,
|
||||
'CALSCALE:GREGORIAN',
|
||||
'METHOD:REQUEST',
|
||||
'BEGIN:VEVENT',
|
||||
`UID:${uid}`,
|
||||
`DTSTAMP:${dtStamp}`,
|
||||
`DTSTART:${dtStart}`,
|
||||
`DTEND:${dtEnd}`,
|
||||
`SUMMARY:${summary}`,
|
||||
`DESCRIPTION:${description}`,
|
||||
`LOCATION:${location}`,
|
||||
organizerLine,
|
||||
];
|
||||
|
||||
if (meeting.url) {
|
||||
lines.push(`URL:${meeting.url}`);
|
||||
}
|
||||
|
||||
if (meeting.status) {
|
||||
lines.push(`STATUS:${meeting.status.toUpperCase()}`);
|
||||
}
|
||||
|
||||
if (meeting.attendees && meeting.attendees.length > 0) {
|
||||
for (const att of meeting.attendees) {
|
||||
const cn = att.name ? `;CN=${escapeIcsText(att.name)}` : '';
|
||||
const rsvp = att.rsvp ? ';RSVP=TRUE' : '';
|
||||
lines.push(`ATTENDEE${cn}${rsvp}:mailto:${att.email}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('END:VEVENT', 'END:VCALENDAR');
|
||||
|
||||
return lines.join('\r\n') + '\r\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* 遵循 RFC 5545 第 3.1 节长行折叠规范 (Line Folding)
|
||||
* 单行超过 75 字符时在末尾插入 CRLF 及 1 个空格缩进
|
||||
*/
|
||||
export function foldIcsLine(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) {
|
||||
result += '\r\n ' + remaining.slice(0, maxLen - 1);
|
||||
remaining = remaining.slice(maxLen - 1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 RFC 5545 ICS 字符串为结构化 MeetingEventOptions 数据
|
||||
*/
|
||||
export function parseIcsEvent(ics: string): Partial<MeetingEventOptions> {
|
||||
if (!ics) return {};
|
||||
|
||||
const unfolded = ics.replace(/\r\n[ \t]/g, '');
|
||||
const lines = unfolded.split(/\r\n|\r|\n/);
|
||||
|
||||
const result: Partial<MeetingEventOptions> = {};
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line || line.startsWith('BEGIN:') || line.startsWith('END:')) continue;
|
||||
const colonIdx = line.indexOf(':');
|
||||
if (colonIdx === -1) continue;
|
||||
|
||||
const propHeader = line.slice(0, colonIdx);
|
||||
const value = line.slice(colonIdx + 1);
|
||||
|
||||
if (propHeader === 'UID') {
|
||||
result.uid = value;
|
||||
} else if (propHeader === 'SUMMARY') {
|
||||
result.summary = unescapeIcsText(value);
|
||||
} else if (propHeader === 'DESCRIPTION') {
|
||||
result.description = unescapeIcsText(value);
|
||||
} else if (propHeader === 'LOCATION') {
|
||||
result.location = unescapeIcsText(value);
|
||||
} else if (propHeader === 'DTSTART') {
|
||||
result.startTime = value;
|
||||
} else if (propHeader === 'DTEND') {
|
||||
result.endTime = value;
|
||||
} else if (propHeader === 'URL') {
|
||||
result.url = value;
|
||||
} else if (propHeader === 'STATUS') {
|
||||
result.status = value;
|
||||
} else if (propHeader.startsWith('ORGANIZER')) {
|
||||
const email = value.replace(/^mailto:/i, '');
|
||||
const cnMatch = propHeader.match(/CN=([^;:]+)/i);
|
||||
const name = cnMatch ? unescapeIcsText(cnMatch[1]) : undefined;
|
||||
result.organizer = name ? { name, email } : email;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* OmniCard 国际商务日程预约与 ICS 日历订阅服务
|
||||
*/
|
||||
@Provide()
|
||||
export class CardCalendarService extends BaseService {
|
||||
/**
|
||||
* 生成 RFC 5545 标准 ICS 事件文本
|
||||
*/
|
||||
generateIcsEvent(meeting: MeetingEventOptions): string {
|
||||
return generateIcsEvent(meeting);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 UTC 时间戳
|
||||
*/
|
||||
formatIcsDate(date: Date | string | number): string {
|
||||
return formatIcsDate(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义文本
|
||||
*/
|
||||
escapeIcsText(text?: string): string {
|
||||
return escapeIcsText(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 反转义文本
|
||||
*/
|
||||
unescapeIcsText(text?: string): string {
|
||||
return unescapeIcsText(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 长行折叠
|
||||
*/
|
||||
foldIcsLine(line: string, maxLen: number = 75): string {
|
||||
return foldIcsLine(line, maxLen);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 ICS 事件字符串
|
||||
*/
|
||||
parseIcsEvent(ics: string): Partial<MeetingEventOptions> {
|
||||
return parseIcsEvent(ics);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Init, Provide } from '@midwayjs/core';
|
||||
import { BaseService } from '@cool-midway/core';
|
||||
import { InjectEntityModel } from '@midwayjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CardCasesEntity } from '../entity/cases';
|
||||
|
||||
/**
|
||||
* 后台按名片拉取案例的参数格式
|
||||
*/
|
||||
export interface GetCasesByCardIdParams {
|
||||
cardId: number | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 出海案例作品集服务
|
||||
* 提供出海案例的标准 CRUD 及按名片筛选能力
|
||||
*/
|
||||
@Provide()
|
||||
export class CardCasesService extends BaseService {
|
||||
@InjectEntityModel(CardCasesEntity)
|
||||
cardCasesEntity: Repository<CardCasesEntity>;
|
||||
|
||||
@Init()
|
||||
async init() {
|
||||
await super.init();
|
||||
this.setEntity(this.cardCasesEntity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按名片 cardId 获取案例列表
|
||||
* 严格按照 sortOrder 升序排列
|
||||
*
|
||||
* @param params 名片 ID 或对象参数
|
||||
*/
|
||||
async getCasesByCardId(
|
||||
params: number | string | GetCasesByCardIdParams
|
||||
): Promise<CardCasesEntity[]> {
|
||||
const cardId = typeof params === 'object' && params !== null ? params.cardId : params;
|
||||
const numericCardId = Number(cardId);
|
||||
if (!numericCardId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.cardCasesEntity.find({
|
||||
where: { cardId: numericCardId },
|
||||
order: { sortOrder: 'ASC', id: 'DESC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Init, Inject, Provide } from '@midwayjs/core';
|
||||
import { BaseService } from '@cool-midway/core';
|
||||
import { InjectEntityModel } from '@midwayjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CardInfoEntity } from '../entity/info';
|
||||
import { CardPortalService } from './portal';
|
||||
|
||||
/**
|
||||
* 名片增删改载荷(Cool CRUD 传入对象、主键或主键列表)
|
||||
*/
|
||||
interface CardModifyRecord {
|
||||
id?: string | number;
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台名片信息服务
|
||||
*/
|
||||
@Provide()
|
||||
export class CardInfoService extends BaseService {
|
||||
@InjectEntityModel(CardInfoEntity)
|
||||
cardInfoEntity: Repository<CardInfoEntity>;
|
||||
|
||||
@Inject()
|
||||
cardPortalService: CardPortalService;
|
||||
|
||||
@Init()
|
||||
async init() {
|
||||
await super.init();
|
||||
this.setEntity(this.cardInfoEntity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增/修改/删除后清理门户缓存
|
||||
*/
|
||||
async modifyAfter(
|
||||
data: CardModifyRecord | Array<CardModifyRecord | string | number> | string | number,
|
||||
type: 'delete' | 'update' | 'add'
|
||||
): Promise<void> {
|
||||
if (type !== 'add' && type !== 'update' && type !== 'delete') {
|
||||
return;
|
||||
}
|
||||
const keys = this.collectCacheKeys(data);
|
||||
for (const key of keys) {
|
||||
await this.cardPortalService.clearPortalCache(key);
|
||||
}
|
||||
}
|
||||
|
||||
private collectCacheKeys(
|
||||
data: CardModifyRecord | Array<CardModifyRecord | string | number> | string | number
|
||||
): Array<string | number> {
|
||||
if (data === undefined || data === null || data === '') {
|
||||
return [];
|
||||
}
|
||||
if (typeof data === 'number') {
|
||||
return [data];
|
||||
}
|
||||
if (typeof data === 'string') {
|
||||
return data
|
||||
.split(',')
|
||||
.map(item => item.trim())
|
||||
.filter(item => item.length > 0);
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
return data.flatMap(item => this.collectCacheKeys(item));
|
||||
}
|
||||
const keys: Array<string | number> = [];
|
||||
if (data.id !== undefined && data.id !== null && data.id !== '') {
|
||||
keys.push(data.id);
|
||||
}
|
||||
if (data.slug) {
|
||||
keys.push(data.slug);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Init, Provide } from '@midwayjs/core';
|
||||
import { BaseService, CoolCommException } from '@cool-midway/core';
|
||||
import { InjectEntityModel } from '@midwayjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CardLocaleEntity } from '../entity/locale';
|
||||
|
||||
/**
|
||||
* 名片多语言文案字段
|
||||
*/
|
||||
export interface CardLocaleFields {
|
||||
name?: string;
|
||||
title?: string;
|
||||
company?: string;
|
||||
slogan?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理后台 serviceApis:按名片语言覆盖写入
|
||||
*/
|
||||
export interface UpsertCardLocaleParams extends CardLocaleFields {
|
||||
cardId: number;
|
||||
locale: string;
|
||||
fields?: CardLocaleFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理后台 serviceApis:按名片列出多语分支
|
||||
*/
|
||||
export interface ListCardLocaleParams {
|
||||
cardId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理后台 serviceApis:按名片与语言获取
|
||||
*/
|
||||
export interface GetCardLocaleParams {
|
||||
cardId: number;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 名片内容级多语言服务
|
||||
* (cardId, locale) 由 upsert 保证唯一,重复写入覆盖已有文案
|
||||
*/
|
||||
@Provide()
|
||||
export class CardLocaleService extends BaseService {
|
||||
@InjectEntityModel(CardLocaleEntity)
|
||||
cardLocaleEntity: Repository<CardLocaleEntity>;
|
||||
|
||||
@Init()
|
||||
async init() {
|
||||
await super.init();
|
||||
this.setEntity(this.cardLocaleEntity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按名片与语言写入或覆盖文案
|
||||
* 兼容管理后台 serviceApis:POST body 为单参数对象
|
||||
*/
|
||||
async upsert(
|
||||
cardId: number,
|
||||
locale: string,
|
||||
fields?: CardLocaleFields
|
||||
): Promise<CardLocaleEntity>;
|
||||
async upsert(params: UpsertCardLocaleParams): Promise<CardLocaleEntity>;
|
||||
async upsert(
|
||||
cardIdOrParams: number | UpsertCardLocaleParams,
|
||||
locale?: string,
|
||||
fields?: CardLocaleFields
|
||||
): Promise<CardLocaleEntity> {
|
||||
if (typeof cardIdOrParams !== 'number') {
|
||||
const body = cardIdOrParams;
|
||||
return this.upsert(body.cardId, body.locale, {
|
||||
name: body.fields?.name ?? body.name,
|
||||
title: body.fields?.title ?? body.title,
|
||||
company: body.fields?.company ?? body.company,
|
||||
slogan: body.fields?.slogan ?? body.slogan,
|
||||
});
|
||||
}
|
||||
|
||||
const cardId = Number(cardIdOrParams);
|
||||
const localeTag = String(locale || '').trim();
|
||||
if (!cardId) {
|
||||
throw new CoolCommException('名片 ID 不能为空');
|
||||
}
|
||||
if (!localeTag || localeTag.length > 16) {
|
||||
throw new CoolCommException('语言标签不能为空');
|
||||
}
|
||||
|
||||
const incoming: CardLocaleFields = fields || {};
|
||||
const existing = await this.cardLocaleEntity.findOneBy({
|
||||
cardId,
|
||||
locale: localeTag,
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
if (incoming.name !== undefined) existing.name = incoming.name;
|
||||
if (incoming.title !== undefined) existing.title = incoming.title;
|
||||
if (incoming.company !== undefined) existing.company = incoming.company;
|
||||
if (incoming.slogan !== undefined) existing.slogan = incoming.slogan;
|
||||
return (await this.cardLocaleEntity.save(existing)) as CardLocaleEntity;
|
||||
}
|
||||
|
||||
const created = new CardLocaleEntity();
|
||||
created.cardId = cardId;
|
||||
created.locale = localeTag;
|
||||
created.name = incoming.name;
|
||||
created.title = incoming.title;
|
||||
created.company = incoming.company;
|
||||
created.slogan = incoming.slogan;
|
||||
return (await this.cardLocaleEntity.save(created)) as CardLocaleEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按名片列出全部语种分支
|
||||
* 兼容管理后台 serviceApis:POST body 为单参数对象 { cardId }
|
||||
*/
|
||||
async listByCardId(cardId: number): Promise<CardLocaleEntity[]>;
|
||||
async listByCardId(params: ListCardLocaleParams): Promise<CardLocaleEntity[]>;
|
||||
async listByCardId(
|
||||
cardIdOrParams: number | ListCardLocaleParams
|
||||
): Promise<CardLocaleEntity[]> {
|
||||
if (typeof cardIdOrParams !== 'number') {
|
||||
return this.listByCardId(cardIdOrParams.cardId);
|
||||
}
|
||||
|
||||
return await this.cardLocaleEntity.find({
|
||||
where: { cardId: Number(cardIdOrParams) },
|
||||
order: { locale: 'ASC', id: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 按名片与语言精确获取
|
||||
* 兼容管理后台 serviceApis:POST body 为单参数对象 { cardId, locale }
|
||||
*/
|
||||
async getByCardIdAndLocale(
|
||||
cardId: number,
|
||||
locale: string
|
||||
): Promise<CardLocaleEntity | null>;
|
||||
async getByCardIdAndLocale(
|
||||
params: GetCardLocaleParams
|
||||
): Promise<CardLocaleEntity | null>;
|
||||
async getByCardIdAndLocale(
|
||||
cardIdOrParams: number | GetCardLocaleParams,
|
||||
locale?: string
|
||||
): Promise<CardLocaleEntity | null> {
|
||||
if (typeof cardIdOrParams !== 'number') {
|
||||
return this.getByCardIdAndLocale(
|
||||
cardIdOrParams.cardId,
|
||||
cardIdOrParams.locale
|
||||
);
|
||||
}
|
||||
|
||||
return await this.cardLocaleEntity.findOneBy({
|
||||
cardId: Number(cardIdOrParams),
|
||||
locale: String(locale || '').trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -54,9 +54,13 @@ export class CardPortalService extends BaseService {
|
||||
* 获取微官网公开详情 (通过名片 ID 或自定义 Slug 短链)
|
||||
*
|
||||
* @param slugOrId 名片主键 ID 或 自定义短链标识
|
||||
* @param suggestedLocale Geo / 浏览器语言建议(浅拷贝写入返回,不进入缓存)
|
||||
* @returns 脱敏后的微官网聚合资产
|
||||
*/
|
||||
async getPortalDetail(slugOrId: string | number): Promise<CardPortalDetail> {
|
||||
async getPortalDetail(
|
||||
slugOrId: string | number,
|
||||
suggestedLocale?: string
|
||||
): Promise<CardPortalDetail> {
|
||||
if (slugOrId === undefined || slugOrId === null || slugOrId === '') {
|
||||
throw new CoolCommException('参数错误:缺少名片标识', 400);
|
||||
}
|
||||
@@ -69,7 +73,7 @@ export class CardPortalService extends BaseService {
|
||||
// 1. 尝试从高速缓存读取
|
||||
const cached = this.getFromCache(queryKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
return this.withSuggestedLocale(cached, suggestedLocale);
|
||||
}
|
||||
|
||||
// 2. 判定查询维度 (ID 优先或 Slug 优先)
|
||||
@@ -140,6 +144,9 @@ export class CardPortalService extends BaseService {
|
||||
email: card.email,
|
||||
whatsapp: card.whatsapp,
|
||||
linkedin: card.linkedin,
|
||||
telegram: card.telegram,
|
||||
twitter: card.twitter,
|
||||
wechat: card.wechat,
|
||||
timezone: card.timezone,
|
||||
audioPronunciationUrl: card.audioPronunciationUrl,
|
||||
themeCode: card.themeCode,
|
||||
@@ -148,10 +155,23 @@ export class CardPortalService extends BaseService {
|
||||
cachedAt: Date.now(),
|
||||
};
|
||||
|
||||
// 7. 写入多键关联高速缓存
|
||||
// 7. 写入多键关联高速缓存(不含 suggestedLocale,避免污染多语言缓存)
|
||||
this.setToCache(portalDetail);
|
||||
|
||||
return portalDetail;
|
||||
return this.withSuggestedLocale(portalDetail, suggestedLocale);
|
||||
}
|
||||
|
||||
/**
|
||||
* 浅拷贝附加建议语言,不回写 localCache
|
||||
*/
|
||||
private withSuggestedLocale(
|
||||
detail: CardPortalDetail,
|
||||
suggestedLocale?: string
|
||||
): CardPortalDetail {
|
||||
return {
|
||||
...detail,
|
||||
suggestedLocale: suggestedLocale || 'en-US',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Inject, Provide } from '@midwayjs/core';
|
||||
import { BaseService, CoolCommException } from '@cool-midway/core';
|
||||
import { CardPortalService } from './portal';
|
||||
import {
|
||||
buildCardPublicUrl,
|
||||
generateQrPngDataUrl,
|
||||
generateQrSvg,
|
||||
CardQrcodePngResult,
|
||||
CardQrcodeSvgResult,
|
||||
} from '../../../comm/qrcode/qrcode';
|
||||
import { CardPortalDetail } from '../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* 已发布名片动态短链二维码服务
|
||||
* 复用 CardPortalService.getPortalDetail 做存在性与 published 校验
|
||||
*/
|
||||
@Provide()
|
||||
export class CardQrcodeService extends BaseService {
|
||||
@Inject()
|
||||
cardPortalService: CardPortalService;
|
||||
|
||||
/**
|
||||
* 生成 SVG 二维码(内容为微官网公开地址)
|
||||
*/
|
||||
async generateSvg(slugOrId?: string | number): Promise<CardQrcodeSvgResult> {
|
||||
const url = await this.resolvePublishedCardUrl(slugOrId);
|
||||
const svg = await generateQrSvg(url);
|
||||
return { svg, url };
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 PNG dataURL 二维码(内容为微官网公开地址)
|
||||
*/
|
||||
async generatePng(slugOrId?: string | number): Promise<CardQrcodePngResult> {
|
||||
const url = await this.resolvePublishedCardUrl(slugOrId);
|
||||
const png = await generateQrPngDataUrl(url);
|
||||
return { png, url };
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验标识并解析已发布名片的公开短链
|
||||
*/
|
||||
private async resolvePublishedCardUrl(
|
||||
slugOrId?: string | number
|
||||
): Promise<string> {
|
||||
if (slugOrId === undefined || slugOrId === null || String(slugOrId).trim() === '') {
|
||||
throw new CoolCommException('参数错误:缺少名片标识', 400);
|
||||
}
|
||||
|
||||
const portal = await this.cardPortalService.getPortalDetail(slugOrId);
|
||||
this.assertPublished(portal);
|
||||
|
||||
const key = portal.slug?.trim() || String(portal.id);
|
||||
return buildCardPublicUrl(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* getPortalDetail 已拦截 draft;此处再守一层,避免 mock/异常返回未发布数据
|
||||
*/
|
||||
private assertPublished(portal: CardPortalDetail): void {
|
||||
if (portal.status !== 'published') {
|
||||
throw new CoolCommException('名片未发布或已下线', 404);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
import { Provide } from '@midwayjs/core';
|
||||
import { BaseService } from '@cool-midway/core';
|
||||
import {
|
||||
CardPortalDetail,
|
||||
CardOgMeta,
|
||||
CardProfile,
|
||||
} from '../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* 社交爬虫与搜索引擎识别正则
|
||||
*/
|
||||
const CRAWLER_REGEX =
|
||||
/whatsapp|linkedinbot|twitterbot|facebookexternalhit|facebot|telegrambot|slackbot|discordbot|pinterest|skypeuripreview|googlebot|bingbot|baiduspider|yandexbot|duckduckbot|applebot/i;
|
||||
|
||||
const RTL_LANGUAGES = new Set(['ar', 'he', 'fa', 'ur', 'arc', 'syr', 'dv']);
|
||||
const DEFAULT_SITE_NAME = 'OmniCard AI';
|
||||
const DEFAULT_OG_IMAGE = 'https://omnicard.ai/assets/og-default.png';
|
||||
const DEFAULT_DESCRIPTION =
|
||||
'OmniCard AI - Global Digital Business Card & Micro Portal';
|
||||
|
||||
/**
|
||||
* SEO & OpenGraph 配置选项
|
||||
*/
|
||||
export interface SeoOptions {
|
||||
baseUrl?: string;
|
||||
currentUrl?: string;
|
||||
locale?: string;
|
||||
siteName?: string;
|
||||
defaultImage?: string;
|
||||
defaultDescription?: string;
|
||||
type?: string;
|
||||
twitterCard?: 'summary_large_image' | 'summary' | 'app' | 'player';
|
||||
isRtl?: boolean;
|
||||
}
|
||||
|
||||
export interface RenderCardHtmlOptions {
|
||||
baseUrl?: string;
|
||||
isCrawler?: boolean;
|
||||
crawlerName?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准 OpenGraph 与 Twitter Card 元标签字典
|
||||
*/
|
||||
export interface OpenGraphMeta {
|
||||
'og:title': string;
|
||||
'og:description': string;
|
||||
'og:image': string;
|
||||
'og:url': string;
|
||||
'og:type': string;
|
||||
'og:locale': string;
|
||||
'og:site_name': string;
|
||||
'twitter:card': string;
|
||||
'twitter:title': string;
|
||||
'twitter:description': string;
|
||||
'twitter:image': string;
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 特殊字符转义 (防范 XSS 注入)
|
||||
*/
|
||||
export function escapeHtml(str?: string | null): string {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除危险可执行标签 (script/iframe/img等) 与内联事件 (onerror/onclick等),保留文本字符
|
||||
*/
|
||||
export function sanitizeMetaText(str?: string | null): string {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, '')
|
||||
.replace(
|
||||
/<\/?(script|iframe|img|svg|object|embed|style|link|meta)\b[^>]*>/gi,
|
||||
''
|
||||
)
|
||||
.replace(/\bon\w+\s*=[^"'\s>]+/gi, '')
|
||||
.replace(/\bon\w+\s*=\s*(?:"[^"]*"|'[^']*')/gi, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* CardSeoService - SEO、社交爬虫探测与微官网服务端渲染服务
|
||||
*/
|
||||
@Provide()
|
||||
export class CardSeoService extends BaseService {
|
||||
/**
|
||||
* 判定请求 User-Agent 是否为已知社交爬虫或搜索机器人
|
||||
*/
|
||||
isCrawler(userAgent?: string): boolean {
|
||||
if (!userAgent || typeof userAgent !== 'string') {
|
||||
return false;
|
||||
}
|
||||
return CRAWLER_REGEX.test(userAgent.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别具体爬虫品牌渠道
|
||||
*/
|
||||
detectCrawler(userAgent?: string): string | null {
|
||||
if (!userAgent || typeof userAgent !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const ua = userAgent.toLowerCase();
|
||||
if (ua.includes('whatsapp')) return 'whatsapp';
|
||||
if (ua.includes('linkedinbot') || ua.includes('linkedin'))
|
||||
return 'linkedin';
|
||||
if (ua.includes('telegrambot') || ua.includes('telegram'))
|
||||
return 'telegram';
|
||||
if (ua.includes('twitterbot') || ua.includes('twitter')) return 'twitter';
|
||||
if (ua.includes('facebookexternalhit') || ua.includes('facebot'))
|
||||
return 'facebook';
|
||||
if (ua.includes('slackbot') || ua.includes('slack')) return 'slack';
|
||||
if (ua.includes('discordbot') || ua.includes('discord')) return 'discord';
|
||||
if (ua.includes('googlebot')) return 'google';
|
||||
if (CRAWLER_REGEX.test(ua)) return 'generic';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提炼名片微官网 OpenGraph 元数据 (CardOgMeta)
|
||||
*/
|
||||
generateOgMeta(card: CardPortalDetail, baseUrl?: string): CardOgMeta {
|
||||
const base = (baseUrl || 'https://omnicard.ai').replace(/\/+$/, '');
|
||||
const name = (card.name || '').trim();
|
||||
const titlePart = card.title ? ` - ${card.title.trim()}` : '';
|
||||
const companyPart = card.company ? ` | ${card.company.trim()}` : '';
|
||||
const title =
|
||||
`${name}${titlePart}${companyPart}`.trim() ||
|
||||
'OmniCard AI - 智能数字名片';
|
||||
const description = (
|
||||
card.slogan ||
|
||||
(card.title && card.company
|
||||
? `${card.title} at ${card.company}`
|
||||
: 'OmniCard AI - 全球智能数字名片 & 移动微官网')
|
||||
).trim();
|
||||
const image = (
|
||||
card.avatarUrl || `${base}/static/default-card-og.png`
|
||||
).trim();
|
||||
const url = `${base}/p/${card.slug || card.id}`;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
url,
|
||||
type: 'profile',
|
||||
siteName: 'OmniCard AI',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化多语言代码为 OpenGraph 标准 ll_CC 格式
|
||||
*/
|
||||
formatOgLocale(locale?: string): string {
|
||||
if (!locale) return 'en_US';
|
||||
const trimmed = locale.trim();
|
||||
if (trimmed.includes('-')) {
|
||||
const [lang, region] = trimmed.split('-');
|
||||
return `${lang.toLowerCase()}_${(region || '').toUpperCase()}`;
|
||||
}
|
||||
if (trimmed.includes('_')) {
|
||||
const [lang, region] = trimmed.split('_');
|
||||
return `${lang.toLowerCase()}_${(region || '').toUpperCase()}`;
|
||||
}
|
||||
|
||||
const simple = trimmed.toLowerCase();
|
||||
const map: Record<string, string> = {
|
||||
en: 'en_US',
|
||||
zh: 'zh_CN',
|
||||
ar: 'ar_SA',
|
||||
ja: 'ja_JP',
|
||||
ko: 'ko_KR',
|
||||
fr: 'fr_FR',
|
||||
de: 'de_DE',
|
||||
es: 'es_ES',
|
||||
pt: 'pt_BR',
|
||||
ru: 'ru_RU',
|
||||
};
|
||||
return map[simple] || `${simple}_${simple.toUpperCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断语言代码是否为 RTL 从右到左书写
|
||||
*/
|
||||
isRtlLocale(locale?: string): boolean {
|
||||
if (!locale) return false;
|
||||
const lang = locale.split(/[-_]/)[0].toLowerCase();
|
||||
return RTL_LANGUAGES.has(lang);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成标准化 OpenGraph 与 Twitter Card 标签集合
|
||||
*/
|
||||
generateOpenGraphMeta(
|
||||
card?: Partial<CardProfile> | null,
|
||||
options?: SeoOptions
|
||||
): OpenGraphMeta {
|
||||
const siteName = sanitizeMetaText(options?.siteName) || DEFAULT_SITE_NAME;
|
||||
const name = sanitizeMetaText(card?.name);
|
||||
const title = sanitizeMetaText(card?.title);
|
||||
const company = sanitizeMetaText(card?.company);
|
||||
const slogan = sanitizeMetaText(card?.slogan);
|
||||
|
||||
// 1. 标题 (Title) 拼接逻辑
|
||||
let ogTitle = '';
|
||||
if (name) {
|
||||
if (title && company) {
|
||||
ogTitle = `${name} - ${title} | ${company}`;
|
||||
} else if (title) {
|
||||
ogTitle = `${name} - ${title}`;
|
||||
} else if (company) {
|
||||
ogTitle = `${name} | ${company}`;
|
||||
} else {
|
||||
ogTitle = name;
|
||||
}
|
||||
} else if (company) {
|
||||
ogTitle = company;
|
||||
} else {
|
||||
ogTitle = siteName;
|
||||
}
|
||||
|
||||
// 2. 描述 (Description) 拼接逻辑
|
||||
let ogDescription = '';
|
||||
if (slogan) {
|
||||
ogDescription = slogan;
|
||||
} else if (title && company) {
|
||||
ogDescription = `${title} @ ${company}`;
|
||||
} else if (title || company) {
|
||||
ogDescription = `${title}${title && company ? ' @ ' : ''}${company}`;
|
||||
} else if (options?.defaultDescription) {
|
||||
ogDescription = sanitizeMetaText(options.defaultDescription);
|
||||
} else {
|
||||
ogDescription = DEFAULT_DESCRIPTION;
|
||||
}
|
||||
|
||||
// 3. 图片 (Image)
|
||||
const ogImage =
|
||||
card?.avatarUrl ||
|
||||
options?.defaultImage ||
|
||||
(options?.baseUrl
|
||||
? `${options.baseUrl.replace(/\/$/, '')}/assets/og-default.png`
|
||||
: DEFAULT_OG_IMAGE);
|
||||
|
||||
// 4. URL
|
||||
let ogUrl = '';
|
||||
if (options?.currentUrl) {
|
||||
ogUrl = options.currentUrl;
|
||||
} else if (card?.url) {
|
||||
ogUrl = card.url;
|
||||
} else if (options?.baseUrl) {
|
||||
const base = options.baseUrl.replace(/\/$/, '');
|
||||
const path = card?.slug || card?.id;
|
||||
ogUrl = path ? `${base}/c/${path}` : base;
|
||||
} else {
|
||||
ogUrl = 'https://omnicard.ai';
|
||||
}
|
||||
|
||||
const ogType = options?.type || 'profile';
|
||||
const ogLocale = this.formatOgLocale(options?.locale);
|
||||
const twitterCard = options?.twitterCard || 'summary_large_image';
|
||||
|
||||
return {
|
||||
'og:title': ogTitle,
|
||||
'og:description': ogDescription,
|
||||
'og:image': ogImage,
|
||||
'og:url': ogUrl,
|
||||
'og:type': ogType,
|
||||
'og:locale': ogLocale,
|
||||
'og:site_name': siteName,
|
||||
'twitter:card': twitterCard,
|
||||
'twitter:title': ogTitle,
|
||||
'twitter:description': ogDescription,
|
||||
'twitter:image': ogImage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 OpenGraph 与 Twitter Card 标签注入到 HTML 模板中,并完成防 XSS 转义与 RTL 适配
|
||||
*/
|
||||
renderHtmlWithOgMeta(
|
||||
templateHtml: string,
|
||||
card?: Partial<CardProfile> | null,
|
||||
options?: SeoOptions
|
||||
): string {
|
||||
const meta = this.generateOpenGraphMeta(card, options);
|
||||
let html = templateHtml || '';
|
||||
|
||||
// 1. RTL 及语言属性注入
|
||||
const shouldBeRtl = options?.isRtl ?? this.isRtlLocale(options?.locale);
|
||||
const langCode = options?.locale
|
||||
? options.locale.split(/[-_]/)[0].toLowerCase()
|
||||
: '';
|
||||
|
||||
if (html.includes('<html')) {
|
||||
if (shouldBeRtl) {
|
||||
if (html.includes('dir="')) {
|
||||
html = html.replace(/dir="[^"]*"/, 'dir="rtl"');
|
||||
} else {
|
||||
html = html.replace('<html', '<html dir="rtl"');
|
||||
}
|
||||
if (langCode) {
|
||||
if (html.includes('lang="')) {
|
||||
html = html.replace(/lang="[^"]*"/, `lang="${langCode}"`);
|
||||
} else {
|
||||
html = html.replace('<html', `<html lang="${langCode}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 构造 Meta 标签集合 (统一转义属性值)
|
||||
const metaTags = [
|
||||
`<meta property="og:title" content="${escapeHtml(meta['og:title'])}" />`,
|
||||
`<meta property="og:description" content="${escapeHtml(
|
||||
meta['og:description']
|
||||
)}" />`,
|
||||
`<meta property="og:image" content="${escapeHtml(meta['og:image'])}" />`,
|
||||
`<meta property="og:url" content="${escapeHtml(meta['og:url'])}" />`,
|
||||
`<meta property="og:type" content="${escapeHtml(meta['og:type'])}" />`,
|
||||
`<meta property="og:locale" content="${escapeHtml(
|
||||
meta['og:locale']
|
||||
)}" />`,
|
||||
`<meta property="og:site_name" content="${escapeHtml(
|
||||
meta['og:site_name']
|
||||
)}" />`,
|
||||
`<meta name="twitter:card" content="${escapeHtml(
|
||||
meta['twitter:card']
|
||||
)}" />`,
|
||||
`<meta name="twitter:title" content="${escapeHtml(
|
||||
meta['twitter:title']
|
||||
)}" />`,
|
||||
`<meta name="twitter:description" content="${escapeHtml(
|
||||
meta['twitter:description']
|
||||
)}" />`,
|
||||
`<meta name="twitter:image" content="${escapeHtml(
|
||||
meta['twitter:image']
|
||||
)}" />`,
|
||||
];
|
||||
|
||||
const injectedMetaBlock = metaTags.join('\n ');
|
||||
const titleTag = `<title>${escapeHtml(meta['og:title'])}</title>`;
|
||||
|
||||
// 3. 处理 <title> 标签
|
||||
if (html.includes('<title>') && html.includes('</title>')) {
|
||||
html = html.replace(/<title>[\s\S]*?<\/title>/, titleTag);
|
||||
}
|
||||
|
||||
// 4. 将 Meta 块注入到 <head> 中
|
||||
if (html.includes('</head>')) {
|
||||
html = html.replace('</head>', ` ${injectedMetaBlock}\n</head>`);
|
||||
} else if (html.includes('<head>')) {
|
||||
html = html.replace(
|
||||
'<head>',
|
||||
`<head>\n ${injectedMetaBlock}\n ${titleTag}`
|
||||
);
|
||||
} else {
|
||||
// 兜底:若无标准 head 结构,直接前置追加
|
||||
html = `${injectedMetaBlock}\n${html}`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端渲染名片合规 HTML (包含 OpenGraph / Twitter Cards / JSON-LD)
|
||||
*/
|
||||
renderCardHtml(
|
||||
card: CardPortalDetail,
|
||||
options?: RenderCardHtmlOptions
|
||||
): string {
|
||||
const ogMeta = this.generateOgMeta(card, options?.baseUrl);
|
||||
const lang = card.suggestedLocale || 'en-US';
|
||||
|
||||
const safeTitle = escapeHtml(ogMeta.title);
|
||||
const safeDesc = escapeHtml(ogMeta.description);
|
||||
const safeImage = escapeHtml(ogMeta.image);
|
||||
const safeUrl = escapeHtml(ogMeta.url);
|
||||
const safeName = escapeHtml(card.name);
|
||||
const safeJobTitle = escapeHtml(card.title);
|
||||
const safeCompany = escapeHtml(card.company);
|
||||
const safeSlogan = escapeHtml(card.slogan);
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ProfilePage',
|
||||
mainEntity: {
|
||||
'@type': 'Person',
|
||||
name: card.name,
|
||||
jobTitle: card.title || undefined,
|
||||
worksFor: card.company
|
||||
? { '@type': 'Organization', name: card.company }
|
||||
: undefined,
|
||||
image: card.avatarUrl || undefined,
|
||||
email: card.email || undefined,
|
||||
telephone: card.phone || undefined,
|
||||
description: card.slogan || undefined,
|
||||
},
|
||||
};
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="${escapeHtml(lang)}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>${safeTitle}</title>
|
||||
<meta name="description" content="${safeDesc}" />
|
||||
<link rel="canonical" href="${safeUrl}" />
|
||||
|
||||
<!-- OpenGraph -->
|
||||
<meta property="og:type" content="profile" />
|
||||
<meta property="og:title" content="${safeTitle}" />
|
||||
<meta property="og:description" content="${safeDesc}" />
|
||||
<meta property="og:image" content="${safeImage}" />
|
||||
<meta property="og:url" content="${safeUrl}" />
|
||||
<meta property="og:site_name" content="OmniCard AI" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="${safeTitle}" />
|
||||
<meta name="twitter:description" content="${safeDesc}" />
|
||||
<meta name="twitter:image" content="${safeImage}" />
|
||||
|
||||
<!-- JSON-LD Structured Data -->
|
||||
<script type="application/ld+json">${JSON.stringify(jsonLd)}</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--primary: #0B5CFF;
|
||||
--bg: #FFFFFF;
|
||||
--surface: #F7F8FA;
|
||||
--border: #E3E7ED;
|
||||
--text: #101828;
|
||||
--text-2: #667085;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px 16px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background-color: var(--surface);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.card-preview {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
padding: 32px 24px;
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.avatar {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--border);
|
||||
margin: 0 auto 16px;
|
||||
display: block;
|
||||
}
|
||||
.name {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 4px;
|
||||
color: var(--text);
|
||||
}
|
||||
.title {
|
||||
font-size: 15px;
|
||||
color: var(--text-2);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.company {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.slogan {
|
||||
font-size: 14px;
|
||||
color: var(--text-2);
|
||||
line-height: 1.5;
|
||||
margin: 0 0 24px;
|
||||
font-style: italic;
|
||||
}
|
||||
.cta-button {
|
||||
display: inline-block;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
padding: 0 24px;
|
||||
background-color: var(--primary);
|
||||
color: #FFFFFF;
|
||||
text-decoration: none;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
.cta-button:hover {
|
||||
opacity: 0.92;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card-preview">
|
||||
${
|
||||
safeImage
|
||||
? `<img class="avatar" src="${safeImage}" alt="${safeName}" />`
|
||||
: ''
|
||||
}
|
||||
<h1 class="name">${safeName}</h1>
|
||||
${safeJobTitle ? `<div class="title">${safeJobTitle}</div>` : ''}
|
||||
${safeCompany ? `<div class="company">${safeCompany}</div>` : ''}
|
||||
${safeSlogan ? `<p class="slogan">${safeSlogan}</p>` : ''}
|
||||
${
|
||||
card.linkedin
|
||||
? `<div class="social"><a href="${escapeHtml(
|
||||
card.linkedin
|
||||
)}">LinkedIn</a></div>`
|
||||
: ''
|
||||
}
|
||||
<a class="cta-button" href="${safeUrl}">View Full Profile</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端渲染 404 名片下线或不存在 HTML 页面
|
||||
*/
|
||||
renderNotFoundHtml(message?: string): string {
|
||||
const safeMsg = escapeHtml(message || '名片不存在或已下线');
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>名片未找到 | OmniCard AI</title>
|
||||
<meta property="og:title" content="名片未找到 | OmniCard AI" />
|
||||
<meta property="og:description" content="${safeMsg}" />
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 40px 16px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #F7F8FA;
|
||||
color: #101828;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.box {
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #E3E7ED;
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
}
|
||||
h2 { margin: 0 0 12px; font-size: 20px; }
|
||||
p { margin: 0 0 20px; color: #667085; font-size: 14px; }
|
||||
a { color: #0B5CFF; text-decoration: none; font-weight: 600; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<h2>名片未找到</h2>
|
||||
<p>${safeMsg}</p>
|
||||
<a href="https://omnicard.ai">返回 OmniCard 首页</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户