feat: 完成全系统30个功能点开发与E2E全链路验收 (8轮调度32个子代理闭环)

- 模块一: 出海案例作品集、博客动态发布、人名发音语音条、RTL阿语多语言切换
- 模块二: AI顾问人设配置、行业术语库润色、RAG切片沙盒调试、流式会话与意向留资卡片
- 模块三: 全球访客雷达大屏、多维加权意向算分、外部CRM对接推送、团队公海池流转
- 模块四: 移动端NFC安全挂失、NFC动态路由规则配置、动态防伪验签、展会制卡Zip批量导出
- 模块五: 组织架构与名片分配、移动端个人中心设置、离职交接与历史线索迁移、安全审计日志
- 模块六: Stripe国际信用卡订阅Webhook、独立二级域名与SSL检测、坐席配额预警、多币种发票
- 模块七: 纸质名片OCR解析入库、国际商务日程RFC 5545 ICS日历、微官网离线SWR预加载、展会实时排行榜大屏
- 模块八: 全球出海SEO OpenGraph元标签注入与社交爬虫探测、30点端到端全链路业务流集成测试套件
这个提交包含在:
wikiboss
2026-09-20 23:47:47 +08:00
父节点 acd10ffb59
当前提交 eec259a3a7
共有 276 个文件被更改,包括 54906 次插入 和 145 次删除

查看文件

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* 清除危险可执行标签 (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>`;
}
}