feat(card): 实现后台博客文章与动态发布中心及OpenGraph自动提炼 (Point 02)

这个提交包含在:
wikiboss
2026-09-20 22:40:43 +08:00
父节点 679ea8441e
当前提交 991c0bb6b5
共有 11 个文件被更改,包括 1635 次插入 和 2 次删除

查看文件

@@ -2,7 +2,7 @@
* OmniCard AI (CRMmp) - 全平台跨端核心数据契约与 TypeScript 类型体系
*
* 遵循出海 SaaS 规范、Bento Grid 便当盒美学与 RFC 6350 国际标准。
* 供 后端服务 (Cool-Admin) 与 管理后台 (Arco Pro React) 共享镜像复用。
* 供 OmniCard 后端与管理端共享镜像复用。
* 严格类型安全,杜绝 any。
*/
@@ -43,6 +43,12 @@ export interface CardProfile {
whatsapp?: string;
/** LinkedIn 个人/企业主页链接 */
linkedin?: string;
/** Telegram 账号或链接 */
telegram?: string;
/** X (Twitter) 账号或链接 */
twitter?: string;
/** 微信号 */
wechat?: string;
/** 所在地时区 (如 'UTC+8', 'America/New_York') */
timezone?: string;
/** 姓名双语发音音频 URL (RFC 6350 语音扩展) */
@@ -232,6 +238,34 @@ export interface NFCRecord {
lastScannedAt?: string | Date | null;
}
/**
* NFC 场景路由规则(按时段 / 国家把触卡导向目标名片)
*/
export interface NFCSceneRule {
/** 规则主键 */
id: string | number;
/** 所属企业租户 ID */
tenantId: string | number;
/** 绑定设备;空则对租户下全部设备生效 */
deviceId?: string | number | null;
/** 规则名称 */
name: string;
/** 命中后的场景模式 */
sceneMode: NFCSceneMode;
/** 命中后导向的名片 ID */
targetCardId: string | number;
/** 是否启用 */
enabled: boolean;
/** 生效开始时刻 HH:mm */
startTime?: string | null;
/** 生效结束时刻 HH:mm */
endTime?: string | null;
/** 生效星期 0-6(周日=0) */
weekdays?: number[] | null;
/** 地理国家码 ISO 3166-1 alpha-2 */
countryCode?: string | null;
}
// ==========================================
// 4. 企业租户与品牌 VI 契约 (Tenant & BrandKit)
// ==========================================
@@ -267,6 +301,28 @@ export interface TenantProfile {
brandKit: BrandKit;
}
export interface HandoverRequest {
cardId: string | number;
fromUserId: string | number;
toUserId: string | number;
}
export type DomainSslStatus = 'pending' | 'active' | 'failed';
export interface TenantDomain {
id?: string | number;
customDomain: string;
sslStatus: DomainSslStatus;
}
export interface BillingQuota {
planType: TenantPlanType;
seatsUsed: number;
seatsLimit: number;
cardsUsed: number;
cardsLimit: number;
}
// ==========================================
// 5. 公开微官网聚合门户契约 (Card Portal Public Data)
// ==========================================
@@ -325,6 +381,12 @@ export interface CardPortalDetail {
whatsapp?: string;
/** LinkedIn 个人/企业主页链接 */
linkedin?: string;
/** Telegram 账号或链接 */
telegram?: string;
/** X (Twitter) 账号或链接 */
twitter?: string;
/** 微信号 */
wechat?: string;
/** 所在地时区 */
timezone?: string;
/** 姓名双语发音音频 URL (RFC 6350 语音扩展) */
@@ -339,4 +401,113 @@ export interface CardPortalDetail {
brandKit?: BrandKit;
/** 缓存时间戳 (毫秒),供客户端首屏秒开与协商缓存 */
cachedAt?: number;
/** Geo / 浏览器语言自动建议(不进入名片内容缓存) */
suggestedLocale?: string;
}
// ==========================================
// 5. AI 顾问对话契约 (AI Concierge)
// ==========================================
export type AiChatRole = 'user' | 'assistant' | 'system';
export interface AiChatMessage {
role: AiChatRole;
content: string;
}
export interface AiChatRequest {
cardId: string | number;
locale?: string;
messages: AiChatMessage[];
}
export interface AiChatReply {
reply: string;
locale: string;
shouldCollectLead: boolean;
}
export interface AiKnowledgeDoc {
id?: string | number;
cardId: string | number;
title: string;
content: string;
locale?: string;
}
export type LocalizeTone = 'professional' | 'direct' | 'storytelling';
export interface LocalizeRequest {
text: string;
targetLocale: string;
tone?: LocalizeTone;
}
export interface LocalizeReply {
text: string;
locale: string;
tone: LocalizeTone;
}
export type CopilotChannel = 'email' | 'whatsapp';
export interface CopilotRequest {
leadId?: string | number;
cardId?: string | number;
name?: string;
locale?: string;
channel?: CopilotChannel;
}
export interface CopilotReply {
script: string;
diagnosis: string;
locale: string;
channel: CopilotChannel;
}
/**
* OpenGraph 社交元数据契约
*/
export interface ArticleOgMeta {
title: string;
description: string;
image: string;
}
/**
* 博客文章与动态契约
*/
export interface CardArticle {
id?: string | number;
cardId: string | number;
title: string;
summary?: string;
content: string;
coverImage?: string;
isPublished: boolean;
ogMeta?: ArticleOgMeta;
createTime?: Date | string;
updateTime?: Date | string;
}
/**
* 出海案例作品契约
*/
export interface CardCase {
id?: string | number;
tenantId?: string | number;
cardId: string | number;
title: string;
client?: string;
duration?: string;
metrics?: string;
coverImage?: string;
pdfUrl?: string;
description?: string;
sortOrder?: number;
createTime?: string | Date;
updateTime?: string | Date;
}

查看文件

@@ -0,0 +1,26 @@
import { CoolController, BaseController } from '@cool-midway/core';
import { CardArticleEntity } from '../../entity/article';
import { CardArticleService } from '../../service/article';
/**
* 后台文章标准 CRUD 配置
*/
export const adminCardArticleCurdOption = {
api: ['add', 'delete', 'update', 'info', 'list', 'page'] as Array<
'add' | 'delete' | 'update' | 'info' | 'list' | 'page'
>,
entity: CardArticleEntity,
service: CardArticleService,
pageQueryOp: {
keyWordLikeFields: ['a.title', 'a.summary'],
fieldEq: ['a.cardId', 'a.isPublished'],
addOrderBy: { updateTime: 'desc' as const },
},
};
/**
* 名片模块-后台文章与动态发布中心
* 路由前缀:/admin/card/article
*/
@CoolController(adminCardArticleCurdOption)
export class AdminCardArticleController extends BaseController {}

查看文件

@@ -0,0 +1,66 @@
import { BaseEntity, transformerJson } from '../../../comm/entity/base';
import { Column, Entity, Index } from 'typeorm';
import type { ArticleOgMeta, CardArticle } from '../../../comm/types/omni';
/**
* 名片博客文章与动态实体
*/
@Entity('card_article')
export class CardArticleEntity extends BaseEntity implements CardArticle {
@Index()
@Column({
name: 'card_id',
comment: '所属名片 ID',
type: 'bigint',
})
cardId: number;
@Column({
name: 'title',
comment: '文章标题',
length: 255,
})
title: string;
@Column({
name: 'summary',
comment: '文章摘要/简介',
type: 'text',
nullable: true,
})
summary?: string;
@Column({
name: 'content',
comment: '文章正文 (富文本或 Markdown)',
type: 'longtext',
nullable: true,
})
content: string;
@Column({
name: 'cover_image',
comment: '封面图片 URL',
length: 500,
nullable: true,
})
coverImage?: string;
@Index()
@Column({
name: 'is_published',
comment: '发布状态 (0: 未发布/草稿, 1: 已发布)',
type: 'boolean',
default: false,
})
isPublished: boolean;
@Column({
name: 'og_meta',
comment: 'OpenGraph 社交元数据 (title, description, image)',
type: 'json',
nullable: true,
transformer: transformerJson,
})
ogMeta?: ArticleOgMeta;
}

查看文件

@@ -0,0 +1,98 @@
import { Init, Provide } from '@midwayjs/core';
import { BaseService } from '@cool-midway/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { CardArticleEntity } from '../entity/article';
import type { ArticleOgMeta, CardArticle } from '../../../comm/types/omni';
/**
* 博客文章与动态服务
*/
@Provide()
export class CardArticleService extends BaseService {
@InjectEntityModel(CardArticleEntity)
cardArticleEntity: Repository<CardArticleEntity>;
@Init()
async init() {
await super.init();
this.setEntity(this.cardArticleEntity);
}
/**
* 自动提炼 OpenGraph 社交元数据
*/
generateOgMeta(article: Partial<CardArticle>): ArticleOgMeta {
const title = (article.title || '').trim();
// 1. 摘要处理:若未提供 summary,剥离正文 HTML 与 Markdown 语法,截取前 160 字符
let description = (article.summary || '').trim();
if (!description && article.content) {
const plainText = article.content
.replace(/<[^>]+>/g, '') // 剥离 HTML 标签
.replace(/!\[.*?\]\(.*?\)/g, '') // 移除 Markdown 图片语法
.replace(/\[(.*?)\]\(.*?\)/g, '$1') // 提取 Markdown 链接文本
.replace(/[#*`_~>-]/g, '') // 移除 Markdown 常用标记
.replace(/\s+/g, ' ') // 折叠多余空白
.trim();
description = plainText.slice(0, 160);
}
// 2. 封面图处理:若未提供 coverImage,尝试从正文中提取首张图片
let image = (article.coverImage || '').trim();
if (!image && article.content) {
// 匹配 HTML <img ... src="..." />
const htmlImgMatch = article.content.match(/<img[^>]+src=["']([^"']+)["']/i);
if (htmlImgMatch && htmlImgMatch[1]) {
image = htmlImgMatch[1].trim();
} else {
// 匹配 Markdown ![alt](url)
const mdImgMatch = article.content.match(/!\[.*?\]\((https?:\/\/[^\s)]+|\/[^\s)]+)\)/i);
if (mdImgMatch && mdImgMatch[1]) {
image = mdImgMatch[1].trim();
}
}
}
return {
title,
description,
image,
};
}
/**
* 发布上架文章
*/
async publish(id: number | string): Promise<CardArticleEntity> {
const article = await this.cardArticleEntity.findOneBy({ id: Number(id) } as any);
if (!article) {
throw new Error(`Article with id ${id} not found`);
}
article.isPublished = true;
return await this.cardArticleEntity.save(article);
}
/**
* 下架文章
*/
async unpublish(id: number | string): Promise<CardArticleEntity> {
const article = await this.cardArticleEntity.findOneBy({ id: Number(id) } as any);
if (!article) {
throw new Error(`Article with id ${id} not found`);
}
article.isPublished = false;
return await this.cardArticleEntity.save(article);
}
/**
* 保存前生命周期:自动提炼并补全 ogMeta
*/
async modifyBefore(data: Partial<CardArticleEntity>, type: 'add' | 'update'): Promise<void> {
if (type === 'add' || type === 'update') {
if (!data.ogMeta || !data.ogMeta.title || !data.ogMeta.description) {
data.ogMeta = this.generateOgMeta(data);
}
}
}
}

查看文件

@@ -0,0 +1,213 @@
import * as fs from 'fs';
import * as path from 'path';
import { BaseController } from '@cool-midway/core';
import { CardArticleEntity } from '../../../src/modules/card/entity/article';
import { CardArticleService } from '../../../src/modules/card/service/article';
import {
AdminCardArticleController,
adminCardArticleCurdOption,
} from '../../../src/modules/card/controller/admin/article';
describe('后台博客文章与动态发布中心 (Card Article CoolController & Service)', () => {
const controllerFile = path.join(
__dirname,
'../../../src/modules/card/controller/admin/article.ts'
);
const serviceFile = path.join(
__dirname,
'../../../src/modules/card/service/article.ts'
);
const entityFile = path.join(
__dirname,
'../../../src/modules/card/entity/article.ts'
);
describe('1. 路由与 CoolController CRUD 契约', () => {
it('文件路径应符合 /admin/card/article 自动路由与架构约定', () => {
expect(fs.existsSync(controllerFile)).toBe(true);
expect(fs.existsSync(serviceFile)).toBe(true);
expect(fs.existsSync(entityFile)).toBe(true);
expect(controllerFile.replace(/\\/g, '/')).toMatch(
/modules\/card\/controller\/admin\/article\.ts$/
);
});
it('控制器应继承 BaseController 并正确配置 entity 与 service', () => {
expect(AdminCardArticleController.prototype).toBeInstanceOf(BaseController);
expect(adminCardArticleCurdOption.entity).toBe(CardArticleEntity);
expect(adminCardArticleCurdOption.service).toBe(CardArticleService);
});
it('装饰器 api 必须完整包含 add/delete/update/info/list/page', () => {
expect(adminCardArticleCurdOption.api).toEqual([
'add',
'delete',
'update',
'info',
'list',
'page',
]);
});
it('分页查询 pageQueryOp 应支持标题搜索与发布状态筛选', () => {
const pageQueryOp = adminCardArticleCurdOption.pageQueryOp;
expect(pageQueryOp.keyWordLikeFields).toEqual(
expect.arrayContaining(['a.title', 'a.summary'])
);
expect(pageQueryOp.fieldEq).toEqual(
expect.arrayContaining(['a.cardId', 'a.isPublished'])
);
expect(pageQueryOp.addOrderBy).toEqual({ updateTime: 'desc' });
});
});
describe('2. OpenGraph 社交元数据自动提炼 (generateOgMeta)', () => {
let service: CardArticleService;
beforeEach(() => {
service = new CardArticleService();
});
it('完整文章入参时,应正确映射 title、summary 与 coverImage', () => {
const article = {
cardId: 101,
title: '出海战略新视界:2026 跨境数字名片白皮书',
summary: '本文深度剖析全球化商务场景下 NFC 与微官网结合的数字化获客新路径。',
content: '<p>正文内容...</p>',
coverImage: 'https://cdn.omnicard.ai/articles/cover-1.jpg',
isPublished: true,
};
const og = service.generateOgMeta(article);
expect(og.title).toBe(article.title);
expect(og.description).toBe(article.summary);
expect(og.image).toBe(article.coverImage);
});
it('缺失 summary 时,应自动从 content 提取剥离 HTML 标签的前 160 字符', () => {
const longHtml = `
<article>
<h1>一级标题</h1>
<p>在全球数字化浪潮席卷而来的今天,<strong>智能名片</strong>已经不仅是身份凭证,更是出海企业的第一道数字门面与线索转化漏斗。通过多语言自适应与 NFC 瞬时触达,销售代表可以在海外展会现场以极低摩擦完成商机留资与精准雷达跟进,大幅提升商机转化效率与客户体验。</p>
</article>
`;
const article = {
cardId: 101,
title: '出海营销指南',
content: longHtml,
isPublished: true,
};
const og = service.generateOgMeta(article);
expect(og.title).toBe('出海营销指南');
expect(og.description.length).toBeLessThanOrEqual(160);
expect(og.description).not.toContain('<p>');
expect(og.description).not.toContain('<strong>');
expect(og.description).toContain('在全球数字化浪潮席卷而来的今天');
});
it('缺失 coverImage 时,应自动从 content 中提取首张图片 URL', () => {
const contentWithImages = `
<div>
<p>前言段落</p>
<img src="https://cdn.omnicard.ai/upload/hero-img.png" alt="Hero" />
<p>次要段落</p>
<img src="https://cdn.omnicard.ai/upload/second.png" />
</div>
`;
const article = {
cardId: 102,
title: '图文资讯',
content: contentWithImages,
isPublished: false,
};
const og = service.generateOgMeta(article);
expect(og.image).toBe('https://cdn.omnicard.ai/upload/hero-img.png');
});
it('支持提取 Markdown 格式的图片语法 ![alt](url)', () => {
const mdContent = `
# 标题
这里是段落
![架构图](https://cdn.omnicard.ai/upload/architecture.webp)
`;
const article = {
cardId: 103,
title: 'Markdown 文章',
content: mdContent,
isPublished: true,
};
const og = service.generateOgMeta(article);
expect(og.image).toBe('https://cdn.omnicard.ai/upload/architecture.webp');
});
it('面对空对象或未定义字段时应安全回退,不抛出异常', () => {
const og = service.generateOgMeta({});
expect(og.title).toBe('');
expect(og.description).toBe('');
expect(og.image).toBe('');
});
});
describe('3. 文章发布、下架与保存前生命周期 (publish / unpublish / modifyBefore)', () => {
let service: CardArticleService;
let mockRepo: {
findOneBy: jest.Mock;
save: jest.Mock;
};
beforeEach(() => {
service = new CardArticleService();
mockRepo = {
findOneBy: jest.fn(),
save: jest.fn().mockImplementation(async entity => entity),
};
service.cardArticleEntity = mockRepo as any;
});
it('publish 应将 isPublished 置为 true 并保存', async () => {
const existing = {
id: 1,
title: '草稿文章',
isPublished: false,
} as CardArticleEntity;
mockRepo.findOneBy.mockResolvedValue(existing);
const result = await service.publish(1);
expect(mockRepo.findOneBy).toHaveBeenCalledWith({ id: 1 });
expect(existing.isPublished).toBe(true);
expect(mockRepo.save).toHaveBeenCalledWith(existing);
expect(result.isPublished).toBe(true);
});
it('unpublish 应将 isPublished 置为 false 并保存', async () => {
const existing = {
id: 2,
title: '已发布文章',
isPublished: true,
} as CardArticleEntity;
mockRepo.findOneBy.mockResolvedValue(existing);
const result = await service.unpublish(2);
expect(mockRepo.findOneBy).toHaveBeenCalledWith({ id: 2 });
expect(existing.isPublished).toBe(false);
expect(mockRepo.save).toHaveBeenCalledWith(existing);
expect(result.isPublished).toBe(false);
});
it('modifyBefore 应在保存时自动生成或补全 ogMeta', async () => {
const data: Partial<CardArticleEntity> = {
title: '动态资讯',
content: '<p>精彩资讯动态内容发布</p>',
};
await service.modifyBefore(data, 'add');
expect(data.ogMeta).toBeDefined();
expect(data.ogMeta?.title).toBe('动态资讯');
expect(data.ogMeta?.description).toBe('精彩资讯动态内容发布');
expect(data.ogMeta?.image).toBe('');
});
});
});