feat(backend): 实现公开短链路由与秒级微官网详情 API (Task 08)

这个提交包含在:
wikiboss
2026-09-20 17:30:44 +08:00
父节点 2c3906b634
当前提交 7e0d9ee11b
共有 12 个文件被更改,包括 1935 次插入 和 1 次删除

查看文件

@@ -252,3 +252,78 @@ export interface TenantProfile {
/** 统一品牌 VI 套件 */ /** 统一品牌 VI 套件 */
brandKit: BrandKit; brandKit: BrandKit;
} }
// ==========================================
// 5. 公开微官网聚合门户契约 (Card Portal Public Data)
// ==========================================
/**
* BentoBlockPublic - 对外公开的 Bento 块契约 (已剔除内部运维与敏感字段)
*/
export interface BentoBlockPublic {
/** 网格块主键 ID */
id: number | string;
/** 所属名片 ID */
cardId: number | string;
/** 组件类型 */
type: BentoBlockType;
/** 栅格跨列 (1 | 2) */
colSpan: BentoColSpan;
/** 栅格跨行 (1 | 2) */
rowSpan: BentoRowSpan;
/** 排序序号 (从小到大升序排列) */
sortOrder: number;
/** 块标题 */
title?: string;
/** 块正文 / 描述 / 嵌入内容 */
content?: string;
/** 多媒体资源 URL (图片/视频封面/音频) */
mediaUrl?: string;
/** 目标跳转外链 URL */
linkUrl?: string;
/** 扩展元数据配置 (禁止 any) */
metaJson?: string | Record<string, unknown>;
}
/**
* CardPortalDetail - 对外公开的微官网名片详情聚合契约 (已脱敏员工账号及内部配置)
*/
export interface CardPortalDetail {
/** 名片主键 ID */
id: number | string;
/** 自定义个性化短链 */
slug?: string;
/** 姓名 */
name: string;
/** 职业头衔/职位 */
title: string;
/** 所属企业/组织名称 */
company: string;
/** 个人签名 / 品牌 Slogan */
slogan?: string;
/** 头像 / 形象照 URL */
avatarUrl?: string;
/** 国际电话号码 */
phone?: string;
/** 电子邮箱 */
email?: string;
/** WhatsApp 国际直达账号/号码 */
whatsapp?: string;
/** LinkedIn 个人/企业主页链接 */
linkedin?: string;
/** 所在地时区 */
timezone?: string;
/** 姓名双语发音音频 URL (RFC 6350 语音扩展) */
audioPronunciationUrl?: string;
/** 主题样式代码 */
themeCode?: string;
/** 名片生命周期状态 */
status: CardStatus;
/** 聚合关联的全部有效 Bento 块列表 (按 sortOrder 升序排列) */
bentoBlocks: BentoBlockPublic[];
/** 企业/团队标准品牌 VI (可选) */
brandKit?: BrandKit;
/** 缓存时间戳 (毫秒),供客户端首屏秒开与协商缓存 */
cachedAt?: number;
}

查看文件

@@ -0,0 +1,85 @@
import {
Index,
PrimaryGeneratedColumn,
Column,
DeleteDateColumn,
} from 'typeorm';
import * as moment from 'moment';
import { CoolBaseEntity } from '@cool-midway/core';
/**
* 时间转换器
*/
export const transformerTime = {
to(value?: Date | string | null) {
return value
? moment(value).format('YYYY-MM-DD HH:mm:ss')
: moment().format('YYYY-MM-DD HH:mm:ss');
},
from(value?: string | null) {
return value;
},
};
/**
* Json 转换器
*/
export const transformerJson = {
to: (value: unknown) => value,
from: (value: unknown) => {
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch (e) {
return value;
}
}
return value;
},
};
/**
* 通用实体基类 (BaseEntity)
* 提供主键自增、多租户隔离 (tenantId)、审计时间戳 (createTime/updateTime) 及软删除 (deletedAt)
*/
export abstract class BaseEntity extends CoolBaseEntity {
@PrimaryGeneratedColumn('increment', {
name: 'id',
comment: 'ID',
})
id: number;
@Index()
@Column({
name: 'tenant_id',
comment: '企业租户ID',
nullable: true,
})
tenantId: number;
@Index()
@Column({
name: 'create_time',
comment: '创建时间',
type: 'varchar',
transformer: transformerTime,
})
createTime: Date;
@Index()
@Column({
name: 'update_time',
comment: '更新时间',
type: 'varchar',
transformer: transformerTime,
})
updateTime: Date;
@Index()
@DeleteDateColumn({
name: 'deleted_at',
comment: '软删除时间',
nullable: true,
})
deletedAt?: Date | null;
}

查看文件

@@ -56,7 +56,13 @@ export interface CardProfile {
/** /**
* Bento Grid 网格块类型 * Bento Grid 网格块类型
*/ */
export type BentoBlockType = 'hero' | 'case' | 'article' | 'social' | 'video' | 'form'; export type BentoBlockType =
| 'hero'
| 'case'
| 'article'
| 'social'
| 'video'
| 'form';
/** /**
* Bento Grid 列跨度 (1列 / 2列满宽) * Bento Grid 列跨度 (1列 / 2列满宽)
@@ -252,3 +258,77 @@ export interface TenantProfile {
/** 统一品牌 VI 套件 */ /** 统一品牌 VI 套件 */
brandKit: BrandKit; brandKit: BrandKit;
} }
// ==========================================
// 5. 公开微官网聚合门户契约 (Card Portal Public Data)
// ==========================================
/**
* BentoBlockPublic - 对外公开的 Bento 块契约 (已剔除内部运维与敏感字段)
*/
export interface BentoBlockPublic {
/** 网格块主键 ID */
id: number | string;
/** 所属名片 ID */
cardId: number | string;
/** 组件类型 */
type: BentoBlockType;
/** 栅格跨列 (1 | 2) */
colSpan: BentoColSpan;
/** 栅格跨行 (1 | 2) */
rowSpan: BentoRowSpan;
/** 排序序号 (从小到大升序排列) */
sortOrder: number;
/** 块标题 */
title?: string;
/** 块正文 / 描述 / 嵌入内容 */
content?: string;
/** 多媒体资源 URL (图片/视频封面/音频) */
mediaUrl?: string;
/** 目标跳转外链 URL */
linkUrl?: string;
/** 扩展元数据配置 (禁止 any) */
metaJson?: string | Record<string, unknown>;
}
/**
* CardPortalDetail - 对外公开的微官网名片详情聚合契约 (已脱敏员工账号及内部配置)
*/
export interface CardPortalDetail {
/** 名片主键 ID */
id: number | string;
/** 自定义个性化短链 */
slug?: string;
/** 姓名 */
name: string;
/** 职业头衔/职位 */
title: string;
/** 所属企业/组织名称 */
company: string;
/** 个人签名 / 品牌 Slogan */
slogan?: string;
/** 头像 / 形象照 URL */
avatarUrl?: string;
/** 国际电话号码 */
phone?: string;
/** 电子邮箱 */
email?: string;
/** WhatsApp 国际直达账号/号码 */
whatsapp?: string;
/** LinkedIn 个人/企业主页链接 */
linkedin?: string;
/** 所在地时区 */
timezone?: string;
/** 姓名双语发音音频 URL (RFC 6350 语音扩展) */
audioPronunciationUrl?: string;
/** 主题样式代码 */
themeCode?: string;
/** 名片生命周期状态 */
status: CardStatus;
/** 聚合关联的全部有效 Bento 块列表 (按 sortOrder 升序排列) */
bentoBlocks: BentoBlockPublic[];
/** 企业/团队标准品牌 VI (可选) */
brandKit?: BrandKit;
/** 缓存时间戳 (毫秒),供客户端首屏秒开与协商缓存 */
cachedAt?: number;
}

查看文件

@@ -0,0 +1,14 @@
import { ModuleConfig } from '@cool-midway/core';
/**
* 名片与移动微官网模块配置
*/
export default () => {
return {
name: '名片与微官网模块',
description: '名片管理、Bento Grid 便当盒微官网与公开短链服务',
middlewares: [],
globalMiddlewares: [],
order: 10,
} as ModuleConfig;
};

查看文件

@@ -0,0 +1,68 @@
import { Inject, Provide, Get, Query, Param } from '@midwayjs/core';
import {
CoolController,
BaseController,
CoolTag,
TagTypes,
} from '@cool-midway/core';
import { CardPortalService } from '../../service/portal';
/**
* 访客端公开微官网与短链路由控制器 (免登录触达)
* 路由前缀: /open/card/portal
*/
@Provide()
@CoolController()
export class OpenCardPortalController extends BaseController {
@Inject()
cardPortalService: CardPortalService;
/**
* 获取公开名片微官网详情 (Query 方式)
* 接口地址: GET /open/card/portal/detail?id=123 或 ?slug=alex-zheng
*/
@CoolTag(TagTypes.IGNORE_TOKEN)
@Get('/detail', { summary: '公开微官网详情 (Query)' })
async detail(
@Query('id') id?: string | number,
@Query('slug') slug?: string,
@Query('slugOrId') slugOrId?: string
) {
const target = slug || id || slugOrId;
if (!target) {
return this.fail('缺少名片标识参数~', 1001);
}
try {
const data = await this.cardPortalService.getPortalDetail(target);
return this.ok(data);
} catch (err: any) {
return this.fail(
err?.message || '获取名片微官网失败',
err?.status || 1001
);
}
}
/**
* 根据自定义短链或 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') {
return this.fail('缺少名片标识参数~', 1001);
}
try {
const data = await this.cardPortalService.getPortalDetail(slugOrId);
return this.ok(data);
} catch (err: any) {
return this.fail(
err?.message || '获取名片微官网失败',
err?.status || 1001
);
}
}
}

查看文件

@@ -0,0 +1,116 @@
import { BaseEntity, transformerJson } from '../../../comm/entity/base';
import { Column, Entity, Index } from 'typeorm';
import type {
BentoBlock,
BentoBlockType,
BentoColSpan,
BentoRowSpan,
} from '../../../comm/types/omni';
export type { BentoBlock, BentoBlockType, BentoColSpan, BentoRowSpan };
/**
* Bento Grid 网格组件实体
* 映射微官网 Bento 布局系统中的每一个栅格组件块,严格实现 BentoBlock 跨端数据契约
*/
@Entity('card_bento')
export class CardBentoEntity extends BaseEntity implements BentoBlock {
@Index()
@Column({
name: 'card_id',
comment: '所属名片 ID',
type: 'bigint',
})
cardId: number;
@Column({
name: 'type',
comment: '组件类型 (hero, case, article, social, video, form)',
length: 32,
default: 'hero',
})
type: BentoBlockType;
@Column({
name: 'col_span',
comment: '跨列 (1 或 2 列)',
type: 'smallint',
default: 1,
})
colSpan: BentoColSpan;
@Column({
name: 'row_span',
comment: '跨行 (1 或 2 行)',
type: 'smallint',
default: 1,
})
rowSpan: BentoRowSpan;
@Index()
@Column({
name: 'sort_order',
comment: '排序序号 (从小到大升序)',
type: 'int',
default: 0,
})
sortOrder: number;
@Column({
name: 'title',
comment: '块标题',
length: 150,
nullable: true,
})
title?: string;
@Column({
name: 'content',
comment: '正文描述/嵌入内容',
type: 'text',
nullable: true,
})
content?: string;
@Column({
name: 'media_url',
comment: '媒体资源 URL (图片/视频封面/音频)',
length: 500,
nullable: true,
})
mediaUrl?: string;
@Column({
name: 'link_url',
comment: '目标跳转外链 URL',
length: 500,
nullable: true,
})
linkUrl?: string;
@Column({
name: 'meta_json',
comment: '扩展元数据 JSON',
type: 'json',
nullable: true,
transformer: transformerJson,
})
metaJson?: Record<string, unknown> | string;
@Index()
@Column({
name: 'status',
comment: '状态 (0: 禁用/草稿, 1: 启用/有效)',
type: 'tinyint',
default: 1,
})
status: number;
@Column({
name: 'internal_notes',
comment: '内部备注备忘 (敏感字段)',
type: 'text',
nullable: true,
})
internalNotes?: string;
}

查看文件

@@ -0,0 +1,139 @@
import { BaseEntity } from '../../../comm/entity/base';
import { Column, Entity, Index } from 'typeorm';
import { CardProfile, CardStatus } from '../../../comm/types/omni';
/**
* 名片基础信息实体
*/
@Entity('card_info')
export class CardInfoEntity extends BaseEntity implements CardProfile {
@Index({ unique: true })
@Column({
name: 'slug',
comment: '个性化短链标识 (Slug)',
length: 64,
nullable: true,
})
slug: string;
@Column({ name: 'name', comment: '姓名', length: 100 })
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',
length: 255,
nullable: true,
})
slogan: string;
@Column({
name: 'avatar_url',
comment: '头像 URL',
length: 500,
nullable: true,
})
avatarUrl: string;
@Column({
name: 'phone',
comment: '国际电话号码 (E.164)',
length: 50,
nullable: true,
})
phone: string;
@Column({ name: 'email', comment: '电子邮箱', length: 100, nullable: true })
email: string;
@Column({
name: 'whatsapp',
comment: 'WhatsApp 账号',
length: 100,
nullable: true,
})
whatsapp: string;
@Column({
name: 'linkedin',
comment: 'LinkedIn 主页链接',
length: 255,
nullable: true,
})
linkedin: string;
@Column({
name: 'timezone',
comment: '所在地时区',
length: 50,
nullable: true,
default: 'UTC+8',
})
timezone: string;
@Column({
name: 'audio_pronunciation_url',
comment: '姓名双语发音音频 URL',
length: 500,
nullable: true,
})
audioPronunciationUrl: string;
@Index()
@Column({
name: 'status',
comment: '名片状态 (draft: 草稿, published: 已发布, archived: 已归档)',
length: 20,
default: 'draft',
})
status: CardStatus;
@Column({
name: 'theme_code',
comment: '主题样式代码',
length: 50,
nullable: true,
default: 'corporate_navy',
})
themeCode: string;
@Index()
@Column({
name: 'user_id',
comment: '关联系统用户 ID (敏感字段)',
nullable: true,
})
userId: number;
@Column({
name: 'remark',
comment: '后台运营备注 (敏感字段)',
type: 'text',
nullable: true,
})
remark: string;
@Column({
name: 'internal_config',
comment: '系统租户内部配置 (敏感字段)',
type: 'text',
nullable: true,
})
internalConfig: string;
}

查看文件

@@ -0,0 +1,194 @@
import { Inject, Provide } from '@midwayjs/core';
import { BaseService, CoolCommException } from '@cool-midway/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { CardBentoEntity } from '../entity/bento';
import type { BentoColSpan, BentoRowSpan } from '../../../comm/types/omni';
/**
* Bento 查询过滤选项
*/
export interface GetBentoOptions {
/** 指定状态过滤: 0 禁用, 1 启用 */
status?: number;
}
/**
* Bento Grid 便当盒组件树服务
* 提供微官网组件树的有序解析、批量网格排版同步、状态切换与增删改查
*/
@Provide()
export class CardBentoService extends BaseService {
@InjectEntityModel(CardBentoEntity)
cardBentoEntity: Repository<CardBentoEntity>;
@Inject()
ctx: unknown;
/**
* 按 cardId 获取有序的 Bento 网格块列表
* 严格按照 sortOrder 从小到大升序排序
*
* @param cardId 名片 ID
* @param options 过滤配置 (如过滤启用状态)
*/
async getBlocksByCardId(
cardId: number,
options?: GetBentoOptions
): Promise<CardBentoEntity[]> {
const where: Record<string, unknown> = { cardId };
if (options?.status !== undefined) {
where.status = options.status;
}
return await this.cardBentoEntity.find({
where,
order: {
sortOrder: 'ASC',
id: 'ASC',
},
});
}
/**
* 根据主键 ID 获取单个 Bento 块
*/
async getBlockById(id: number): Promise<CardBentoEntity | null> {
return await this.cardBentoEntity.findOneBy({ id });
}
/**
* 保存或更新单个 Bento 块
*/
async saveBlock(block: Partial<CardBentoEntity>): Promise<CardBentoEntity> {
const normalized = this.normalizeBlock(block);
return await this.cardBentoEntity.save(normalized);
}
/**
* 批量保存更新 Bento 网格布局
* 支持微官网可视化拖拽器的全量画布同步(包含增、改、删旧块以及重排 sortOrder)
*
* @param cardId 名片 ID
* @param blocks 前端提交的 Bento 块列表 (按画板排序)
*/
async batchSaveLayout(
cardId: number,
blocks: Array<Partial<CardBentoEntity>>
): Promise<CardBentoEntity[]> {
if (!cardId) {
throw new CoolCommException('名片 ID 不能为空');
}
// 1. 查询当前数据库中该名片下的所有块
const existingBlocks = await this.cardBentoEntity.find({
where: { cardId },
});
// 2. 收集本次提交中带有 ID 的旧块集合
const incomingIds = blocks
.map(b => (b.id ? Number(b.id) : null))
.filter((id): id is number => id !== null);
// 3. 差量计算:找出需要删除的废弃块
const idsToDelete = existingBlocks
.filter(eb => !incomingIds.includes(Number(eb.id)))
.map(eb => Number(eb.id));
if (idsToDelete.length > 0) {
await this.cardBentoEntity.delete(idsToDelete);
}
// 4. 规范化并保存本次提交的所有块
const entitiesToSave = blocks.map((block, index) => {
const normalized = this.normalizeBlock(block);
normalized.cardId = cardId;
// 按照数组顺序校准排序序号
normalized.sortOrder = index;
return normalized;
});
if (entitiesToSave.length > 0) {
await this.cardBentoEntity.save(entitiesToSave);
}
// 5. 返回最新的有序块列表
return await this.getBlocksByCardId(cardId);
}
/**
* 删除指定的单个 Bento 块
*/
async deleteBlock(id: number): Promise<void> {
await this.cardBentoEntity.delete(id);
}
/**
* 清空指定名片下的所有 Bento 块
*/
async deleteBlocksByCardId(cardId: number): Promise<void> {
await this.cardBentoEntity.delete({ cardId });
}
/**
* 切换或指定 Bento 块的状态 (启用/禁用)
*
* @param id 块 ID
* @param targetStatus 可选的目标状态,若不传则在 0 和 1 间翻转
*/
async toggleStatus(
id: number,
targetStatus?: number
): Promise<CardBentoEntity> {
const block = await this.cardBentoEntity.findOneBy({ id });
if (!block) {
throw new CoolCommException('Bento 网格块不存在');
}
if (targetStatus !== undefined) {
block.status = targetStatus;
} else {
block.status = block.status === 1 ? 0 : 1;
}
return await this.cardBentoEntity.save(block);
}
/**
* 内部规整函数:约束 colSpan(1~2)、rowSpan(1~2) 及安全处理 metaJson
*/
private normalizeBlock(
block: Partial<CardBentoEntity>
): Partial<CardBentoEntity> {
const item: Partial<CardBentoEntity> = { ...block };
// 约束 colSpan 在 1~2 之间
if (item.colSpan !== undefined) {
const col = Number(item.colSpan);
item.colSpan = Math.min(
2,
Math.max(1, isNaN(col) ? 1 : col)
) as BentoColSpan;
}
// 约束 rowSpan 在 1~2 之间
if (item.rowSpan !== undefined) {
const row = Number(item.rowSpan);
item.rowSpan = Math.min(
2,
Math.max(1, isNaN(row) ? 1 : row)
) as BentoRowSpan;
}
// 处理 metaJson 为对象结构
if (typeof item.metaJson === 'string') {
try {
item.metaJson = JSON.parse(item.metaJson);
} catch {
item.metaJson = {};
}
}
return item;
}
}

查看文件

@@ -0,0 +1,222 @@
import { Inject, Provide } from '@midwayjs/core';
import { BaseService, CoolCommException } from '@cool-midway/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { CardInfoEntity } from '../entity/info';
import { CardBentoEntity } from '../entity/bento';
import {
BentoBlockPublic,
BentoBlockType,
BentoColSpan,
BentoRowSpan,
CardPortalDetail,
} from '../../../comm/types/omni';
/**
* 本地内存缓存项
*/
interface CacheItem {
data: CardPortalDetail;
expiresAt: number;
}
/**
* 访客端公开微官网门户服务
*
* 核心特性:
* 1. 免登录无鉴权查询已发布名片
* 2. 自动聚合全部有效 Bento 组件块 (按 sortOrder 升序排列)
* 3. 严格数据安全脱敏 (剔除员工 userId、后台 remark、内部配置 internalConfig 等)
* 4. 高并发多级缓存加速与首屏秒开优化 (<500ms)
*/
@Provide()
export class CardPortalService extends BaseService {
@InjectEntityModel(CardInfoEntity)
cardInfoEntity: Repository<CardInfoEntity>;
@InjectEntityModel(CardBentoEntity)
cardBentoEntity: Repository<CardBentoEntity>;
@Inject()
ctx: unknown;
/**
* 本地高速 L1 缓存 (支持秒开与高并发)
*/
private static localCache = new Map<string, CacheItem>();
/**
* 默认缓存过期时长 (毫秒,默认 60 秒)
*/
private readonly defaultTtlMs = 60 * 1000;
/**
* 获取微官网公开详情 (通过名片 ID 或自定义 Slug 短链)
*
* @param slugOrId 名片主键 ID 或 自定义短链标识
* @returns 脱敏后的微官网聚合资产
*/
async getPortalDetail(slugOrId: string | number): Promise<CardPortalDetail> {
if (slugOrId === undefined || slugOrId === null || slugOrId === '') {
throw new CoolCommException('参数错误:缺少名片标识', 400);
}
const queryKey = String(slugOrId).trim();
if (!queryKey) {
throw new CoolCommException('参数错误:缺少名片标识', 400);
}
// 1. 尝试从高速缓存读取
const cached = this.getFromCache(queryKey);
if (cached) {
return cached;
}
// 2. 判定查询维度 (ID 优先或 Slug 优先)
const isNumeric = /^\d+$/.test(queryKey);
let card: CardInfoEntity | null = null;
if (isNumeric) {
card = await this.cardInfoEntity.findOne({
where: [{ id: Number(queryKey) }, { slug: queryKey }],
});
} else {
card = await this.cardInfoEntity.findOne({
where: { slug: queryKey },
});
}
// 3. 实体存在性与发布状态检查
if (!card) {
throw new CoolCommException('名片不存在或已下线', 404);
}
if (card.status !== 'published') {
throw new CoolCommException('名片未发布或已下线', 404);
}
// 4. 查询关联的有效 Bento 组件块列表 (status = 1)
const rawBlocks = await this.cardBentoEntity.find({
where: {
cardId: Number(card.id),
status: 1,
},
order: {
sortOrder: 'ASC',
id: 'ASC',
} as any,
});
// 5. 数据脱敏与公开资产结构清洗 (剔除 internalNotes 与内部 status)
const bentoBlocks: BentoBlockPublic[] = (rawBlocks || [])
.sort(
(a, b) =>
(a.sortOrder ?? 0) - (b.sortOrder ?? 0) || Number(a.id) - Number(b.id)
)
.map(b => ({
id: b.id,
cardId: b.cardId,
type: b.type as BentoBlockType,
colSpan: b.colSpan as BentoColSpan,
rowSpan: b.rowSpan as BentoRowSpan,
sortOrder: b.sortOrder,
title: b.title,
content: b.content,
mediaUrl: b.mediaUrl,
linkUrl: b.linkUrl,
metaJson: b.metaJson,
}));
// 6. 构造对外聚合模型 (敏感信息 userId、remark、internalConfig 严禁输出)
const portalDetail: CardPortalDetail = {
id: card.id,
slug: card.slug,
name: card.name,
title: card.title,
company: card.company,
slogan: card.slogan,
avatarUrl: card.avatarUrl,
phone: card.phone,
email: card.email,
whatsapp: card.whatsapp,
linkedin: card.linkedin,
timezone: card.timezone,
audioPronunciationUrl: card.audioPronunciationUrl,
themeCode: card.themeCode,
status: card.status,
bentoBlocks,
cachedAt: Date.now(),
};
// 7. 写入多键关联高速缓存
this.setToCache(portalDetail);
return portalDetail;
}
/**
* 清除名片相关的微官网缓存 (供名片/Bento 编辑发布时调用)
*
* @param idOrSlug 名片 ID 或 Slug
*/
async clearPortalCache(idOrSlug: string | number): Promise<void> {
const key = String(idOrSlug).trim();
CardPortalService.localCache.delete(`id:${key}`);
CardPortalService.localCache.delete(`slug:${key}`);
// 同时遍历清除关联包含的缓存
for (const [cacheKey, item] of CardPortalService.localCache.entries()) {
if (
String(item.data.id) === key ||
(item.data.slug && item.data.slug === key)
) {
CardPortalService.localCache.delete(cacheKey);
}
}
}
/**
* 清除所有微官网本地缓存 (供全量重置或单测环境隔离使用)
*/
static clearAllCache(): void {
CardPortalService.localCache.clear();
}
/**
* 从缓存中读取数据 (包含过期检查)
*/
private getFromCache(key: string): CardPortalDetail | null {
const now = Date.now();
const tryKeys = [`id:${key}`, `slug:${key}`];
for (const k of tryKeys) {
const item = CardPortalService.localCache.get(k);
if (item) {
if (item.expiresAt > now) {
return item.data;
}
// 过期自动清理
CardPortalService.localCache.delete(k);
}
}
return null;
}
/**
* 写入缓存,支持 ID 与 Slug 双重检索命中
*/
private setToCache(detail: CardPortalDetail): void {
const expiresAt = Date.now() + this.defaultTtlMs;
const item: CacheItem = {
data: detail,
expiresAt,
};
if (detail.id !== undefined && detail.id !== null) {
CardPortalService.localCache.set(`id:${detail.id}`, item);
}
if (detail.slug) {
CardPortalService.localCache.set(`slug:${detail.slug}`, item);
}
}
}

查看文件

@@ -0,0 +1,428 @@
import { CardBentoEntity } from '../../../src/modules/card/entity/bento';
import { CardBentoService } from '../../../src/modules/card/service/bento';
import {
BentoBlockType,
BentoColSpan,
BentoRowSpan,
} from '../../../src/comm/types/omni';
/**
* 模拟 TypeORM Repository,支持在无需真实数据库连接的环境下进行高保真单元测试
*/
class MockCardBentoRepository {
private data: CardBentoEntity[] = [];
private currentId = 1;
constructor(initialData: Partial<CardBentoEntity>[] = []) {
this.data = initialData.map(item => this.createEntity(item));
if (this.data.length > 0) {
this.currentId = Math.max(...this.data.map(d => Number(d.id) || 0)) + 1;
}
}
private createEntity(item: Partial<CardBentoEntity>): CardBentoEntity {
const entity = new CardBentoEntity();
Object.assign(entity, {
id: item.id || this.currentId++,
createTime: item.createTime || new Date(),
updateTime: new Date(),
status: item.status !== undefined ? item.status : 1,
colSpan: item.colSpan !== undefined ? item.colSpan : 1,
rowSpan: item.rowSpan !== undefined ? item.rowSpan : 1,
sortOrder: item.sortOrder !== undefined ? item.sortOrder : 0,
...item,
});
return entity;
}
async find(options?: any): Promise<CardBentoEntity[]> {
let result = [...this.data];
if (options?.where) {
const where = options.where;
result = result.filter(item => {
for (const key of Object.keys(where)) {
if (where[key] !== undefined && item[key] !== where[key]) {
return false;
}
}
return true;
});
}
if (options?.order) {
const orderKeys = Object.keys(options.order);
result.sort((a, b) => {
for (const key of orderKeys) {
const dir = (options.order[key] || 'ASC').toUpperCase();
if (a[key] < b[key]) return dir === 'ASC' ? -1 : 1;
if (a[key] > b[key]) return dir === 'ASC' ? 1 : -1;
}
return 0;
});
}
return result.map(item => ({ ...item } as CardBentoEntity));
}
async findOneBy(where: any): Promise<CardBentoEntity | null> {
const found = this.data.find(item => {
for (const key of Object.keys(where)) {
if (where[key] !== undefined && item[key] !== where[key]) {
return false;
}
}
return true;
});
return found ? ({ ...found } as CardBentoEntity) : null;
}
async save(
entityOrEntities: Partial<CardBentoEntity> | Array<Partial<CardBentoEntity>>
): Promise<any> {
if (Array.isArray(entityOrEntities)) {
const savedList: CardBentoEntity[] = [];
for (const item of entityOrEntities) {
savedList.push(await this.save(item));
}
return savedList;
}
const item = entityOrEntities;
if (item.id) {
const index = this.data.findIndex(d => d.id === item.id);
if (index !== -1) {
this.data[index] = {
...this.data[index],
...item,
updateTime: new Date(),
} as CardBentoEntity;
return { ...this.data[index] };
}
}
const newEntity = this.createEntity(item);
this.data.push(newEntity);
return { ...newEntity };
}
async delete(condition: any): Promise<{ affected?: number }> {
const initialLen = this.data.length;
if (typeof condition === 'number' || typeof condition === 'string') {
this.data = this.data.filter(d => d.id !== Number(condition));
} else if (Array.isArray(condition)) {
const idSet = new Set(condition.map(id => Number(id)));
this.data = this.data.filter(d => !idSet.has(Number(d.id)));
} else if (condition && typeof condition === 'object') {
this.data = this.data.filter(item => {
for (const key of Object.keys(condition)) {
if (condition[key] !== undefined && item[key] === condition[key]) {
return false;
}
}
return true;
});
}
return { affected: initialLen - this.data.length };
}
async update(criteria: any, partialEntity: any): Promise<void> {
const id = typeof criteria === 'object' ? criteria.id : criteria;
const target = this.data.find(d => d.id === Number(id));
if (target) {
Object.assign(target, partialEntity, { updateTime: new Date() });
}
}
// 辅助获取内部数据快照进行校验
getSnapshot(): CardBentoEntity[] {
return [...this.data];
}
}
describe('后端微官网模块 (modules/card) - Bento Grid 便当盒组件树服务 TDD 测试', () => {
let service: CardBentoService;
let mockRepo: MockCardBentoRepository;
beforeEach(() => {
mockRepo = new MockCardBentoRepository([
{
id: 1,
cardId: 100,
type: 'hero',
colSpan: 2,
rowSpan: 1,
sortOrder: 1,
title: '个人简介与身份卡',
content: '全栈出海专家',
status: 1,
},
{
id: 2,
cardId: 100,
type: 'social',
colSpan: 1,
rowSpan: 1,
sortOrder: 3,
title: '社交媒体矩阵',
content: 'WhatsApp & LinkedIn',
status: 1,
},
{
id: 3,
cardId: 100,
type: 'case',
colSpan: 2,
rowSpan: 2,
sortOrder: 2,
title: '新能源出海案例',
content: '中东客户成功交付',
status: 0, // 禁用
},
{
id: 4,
cardId: 200, // 另一张名片
type: 'hero',
colSpan: 2,
rowSpan: 1,
sortOrder: 1,
title: '其他名片块',
status: 1,
},
]);
service = new CardBentoService();
// 注入 mock repo
service.cardBentoEntity = mockRepo as any;
});
describe('1. 实体与契约类型约束 (Entity & Contract Verification)', () => {
it('应正确创建 CardBentoEntity 实例且具有默认属性与字段', () => {
const entity = new CardBentoEntity();
entity.cardId = 101;
entity.type = 'article';
entity.colSpan = 1;
entity.rowSpan = 1;
entity.sortOrder = 0;
entity.title = '技术洞察';
entity.status = 1;
expect(entity.cardId).toBe(101);
expect(entity.type).toBe('article');
expect(entity.colSpan).toBe(1);
expect(entity.rowSpan).toBe(1);
expect(entity.sortOrder).toBe(0);
expect(entity.status).toBe(1);
});
it('所有 BentoBlockType 支持的核心类型必须有效', () => {
const validTypes: BentoBlockType[] = [
'hero',
'case',
'article',
'social',
'video',
'form',
];
validTypes.forEach(t => {
const entity = new CardBentoEntity();
entity.type = t;
expect(entity.type).toBe(t);
});
});
});
describe('2. 获取有序 Bento 网格块列表 (getBlocksByCardId)', () => {
it('按 cardId 获取时应严格按照 sortOrder 升序排列', async () => {
const blocks = await service.getBlocksByCardId(100);
expect(blocks).toHaveLength(3);
expect(blocks[0].id).toBe(1); // sortOrder: 1
expect(blocks[1].id).toBe(3); // sortOrder: 2
expect(blocks[2].id).toBe(2); // sortOrder: 3
});
it('支持按 status 进行状态过滤(例如仅获取已启用的组件)', async () => {
const enabledBlocks = await service.getBlocksByCardId(100, {
status: 1,
});
expect(enabledBlocks).toHaveLength(2);
expect(enabledBlocks.map(b => b.id)).toEqual([1, 2]);
const disabledBlocks = await service.getBlocksByCardId(100, {
status: 0,
});
expect(disabledBlocks).toHaveLength(1);
expect(disabledBlocks[0].id).toBe(3);
});
it('查询不存在的名片时应安全返回空数组', async () => {
const blocks = await service.getBlocksByCardId(9999);
expect(blocks).toEqual([]);
});
});
describe('3. 批量保存与更新 Bento 网格布局 (batchSaveLayout)', () => {
it('应支持全量布局保存:更新已有块、新增新块、并自动清理废弃块', async () => {
const newLayout = [
{
id: 2, // 已有块,更新排序
type: 'social' as BentoBlockType,
colSpan: 1 as BentoColSpan,
rowSpan: 1 as BentoRowSpan,
title: '更新后的社交媒体',
},
{
// 新块,无 ID
type: 'video' as BentoBlockType,
colSpan: 2 as BentoColSpan,
rowSpan: 2 as BentoRowSpan,
title: '企业出海宣传片',
mediaUrl: 'https://cdn.omnicard.ai/video/intro.mp4',
},
];
const saved = await service.batchSaveLayout(100, newLayout);
expect(saved).toHaveLength(2);
// 检查自动按数组下标重新编号 sortOrder
expect(saved[0].sortOrder).toBe(0);
expect(saved[0].id).toBe(2);
expect(saved[0].title).toBe('更新后的社交媒体');
expect(saved[1].sortOrder).toBe(1);
expect(saved[1].type).toBe('video');
expect(saved[1].mediaUrl).toBe('https://cdn.omnicard.ai/video/intro.mp4');
// 验证名片 100 下未在本次提交中的旧块(id: 1 与 id: 3)已被清理
const remaining100 = await service.getBlocksByCardId(100);
expect(remaining100).toHaveLength(2);
const remainingIds = remaining100.map(b => b.id);
expect(remainingIds).toContain(2);
expect(remainingIds).not.toContain(1);
expect(remainingIds).not.toContain(3);
// 其他名片 (cardId: 200) 的数据不受任何影响
const otherCardBlocks = await service.getBlocksByCardId(200);
expect(otherCardBlocks).toHaveLength(1);
expect(otherCardBlocks[0].id).toBe(4);
});
it('栅格尺寸必须被严格约束在 colSpan 1~2, rowSpan 1~2', async () => {
const abnormalLayout = [
{
type: 'hero' as BentoBlockType,
colSpan: 5 as any, // 超出上限
rowSpan: 0 as any, // 低于下限
title: '边界测试1',
},
{
type: 'case' as BentoBlockType,
colSpan: -1 as any, // 低于下限
rowSpan: 10 as any, // 超出上限
title: '边界测试2',
},
];
const saved = await service.batchSaveLayout(100, abnormalLayout);
expect(saved[0].colSpan).toBe(2);
expect(saved[0].rowSpan).toBe(1);
expect(saved[1].colSpan).toBe(1);
expect(saved[1].rowSpan).toBe(2);
});
it('应正确处理 metaJson(支持字符串自动解析为对象与直接对象赋值)', async () => {
const layoutWithMeta = [
{
type: 'form' as BentoBlockType,
colSpan: 2 as BentoColSpan,
rowSpan: 1 as BentoRowSpan,
title: '外贸留资表单',
metaJson: JSON.stringify({
fields: ['name', 'email', 'country', 'requirement'],
buttonText: 'Get Quote',
}) as any,
},
{
type: 'social' as BentoBlockType,
colSpan: 1 as BentoColSpan,
rowSpan: 1 as BentoRowSpan,
title: '社媒',
metaJson: {
platforms: ['whatsapp', 'wechat'],
},
},
];
const saved = await service.batchSaveLayout(100, layoutWithMeta);
expect(typeof saved[0].metaJson).toBe('object');
expect((saved[0].metaJson as any).buttonText).toBe('Get Quote');
expect((saved[1].metaJson as any).platforms).toEqual(['whatsapp', 'wechat']);
});
});
describe('4. 单块增删改查与状态切换 (Single Block Operations & Toggle)', () => {
it('getBlockById 能根据主键返回对应块或 null', async () => {
const block = await service.getBlockById(1);
expect(block).not.toBeNull();
expect(block?.id).toBe(1);
expect(block?.title).toBe('个人简介与身份卡');
const nonExistent = await service.getBlockById(99999);
expect(nonExistent).toBeNull();
});
it('saveBlock 能够单独新增或更新单个块', async () => {
const updated = await service.saveBlock({
id: 1,
title: '全新身份标签',
colSpan: 2,
});
expect(updated.title).toBe('全新身份标签');
const created = await service.saveBlock({
cardId: 100,
type: 'article',
title: '出海白皮书',
colSpan: 1,
rowSpan: 1,
});
expect(created.id).toBeDefined();
expect(created.title).toBe('出海白皮书');
});
it('deleteBlock 应能成功删除单个块', async () => {
await service.deleteBlock(1);
const block = await service.getBlockById(1);
expect(block).toBeNull();
});
it('deleteBlocksByCardId 应能彻底清空指定名片的所有 Bento 块', async () => {
await service.deleteBlocksByCardId(100);
const blocks100 = await service.getBlocksByCardId(100);
expect(blocks100).toHaveLength(0);
// 其他名片不受影响
const blocks200 = await service.getBlocksByCardId(200);
expect(blocks200).toHaveLength(1);
});
it('toggleStatus 应支持显式传参设置状态,也支持无参时在 0 和 1 间翻转', async () => {
// 块 1 初始 status = 1
const toggled1 = await service.toggleStatus(1);
expect(toggled1.status).toBe(0);
const toggled2 = await service.toggleStatus(1);
expect(toggled2.status).toBe(1);
// 显式指定状态
const explicit = await service.toggleStatus(1, 0);
expect(explicit.status).toBe(0);
// 不存在的块应抛出友好错误
await expect(service.toggleStatus(99999)).rejects.toThrow(
'Bento 网格块不存在'
);
});
});
});

查看文件

@@ -0,0 +1,166 @@
import { getMetadataArgsStorage } from 'typeorm';
import { BaseEntity, transformerTime } from '../../../src/comm/entity/base';
import { CardInfoEntity } from '../../../src/modules/card/entity/info';
import { CardProfile, CardStatus } from '../../../src/comm/types/omni';
describe('后端统一实体基类与名片实体 (Entity & BaseEntity)', () => {
const metadata = getMetadataArgsStorage();
const matchEntity = (target: any) =>
target === BaseEntity || target === CardInfoEntity || (target && target.prototype instanceof BaseEntity);
describe('1. 通用实体基类 (comm/entity/base.ts)', () => {
it('BaseEntity 应定义标准字段: id, tenantId, createTime, updateTime, deletedAt', () => {
const columns = metadata.columns.filter(c => matchEntity(c.target));
const columnNames = columns.map(c => c.propertyName);
expect(columnNames).toContain('id');
expect(columnNames).toContain('tenantId');
expect(columnNames).toContain('createTime');
expect(columnNames).toContain('updateTime');
expect(columnNames).toContain('deletedAt');
});
it('基类字段的数据库列名应严格遵循下划线命名规范', () => {
const columns = metadata.columns.filter(c => matchEntity(c.target));
const tenantIdCol = columns.find(c => c.propertyName === 'tenantId');
expect(tenantIdCol?.options.name).toBe('tenant_id');
const createTimeCol = columns.find(c => c.propertyName === 'createTime');
expect(createTimeCol?.options.name).toBe('create_time');
const updateTimeCol = columns.find(c => c.propertyName === 'updateTime');
expect(updateTimeCol?.options.name).toBe('update_time');
const deletedAtCol = columns.find(c => c.propertyName === 'deletedAt');
expect(deletedAtCol?.options.name).toBe('deleted_at');
});
it('deletedAt 必须被标记为软删除列或具备软删除特性', () => {
const deleteDateCols = metadata.columns.filter(
c => matchEntity(c.target) && c.propertyName === 'deletedAt'
);
expect(deleteDateCols.length).toBeGreaterThan(0);
expect(deleteDateCols[0].mode).toBe('deleteDate');
});
it('基类索引应正确声明在 tenantId 上以保障多租户隔离查询性能', () => {
const indexes = metadata.indices.filter(i => matchEntity(i.target));
const tenantIndex = indexes.find(i => {
if (Array.isArray(i.columns)) {
return i.columns.includes('tenantId') || i.columns.includes('tenant_id');
}
if (typeof i.columns === 'function') {
return (i.columns as any)({ tenantId: true });
}
return false;
});
expect(tenantIndex).toBeDefined();
});
it('transformerTime 应保证时间格式化正确', () => {
const formatted = transformerTime.to('2026-09-20 12:00:00');
expect(formatted).toBe('2026-09-20 12:00:00');
const currentFormatted = transformerTime.to(undefined);
expect(currentFormatted).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
});
});
describe('2. 名片实体 (modules/card/entity/info.ts)', () => {
it('实体表名必须为 card_info', () => {
const table = metadata.tables.find(t => t.target === CardInfoEntity);
expect(table).toBeDefined();
expect(table?.name).toBe('card_info');
});
it('应完整包含 CardProfile 契约与扩展字段,且全部使用下划线数据库列名', () => {
const columns = metadata.columns.filter(c => c.target === CardInfoEntity);
const expectedColumnMappings: Record<string, string> = {
userId: 'user_id',
name: 'name',
title: 'title',
company: 'company',
slogan: 'slogan',
avatarUrl: 'avatar_url',
phone: 'phone',
email: 'email',
whatsapp: 'whatsapp',
linkedin: 'linkedin',
timezone: 'timezone',
audioPronunciationUrl: 'audio_pronunciation_url',
status: 'status',
themeCode: 'theme_code',
slug: 'slug',
};
for (const [prop, dbCol] of Object.entries(expectedColumnMappings)) {
const col = columns.find(c => c.propertyName === prop);
expect(col).toBeDefined();
expect(col?.options.name || col?.propertyName).toBe(dbCol);
}
});
it('slug 字段必须设置唯一索引 (unique: true) 用于短链与 NFC 极速路由', () => {
const indexes = metadata.indices.filter(i => i.target === CardInfoEntity);
const slugIndex = indexes.find(i => {
const matchProp = Array.isArray(i.columns) && (i.columns.includes('slug') || (i.columns as any[])[0] === 'slug');
return matchProp && i.unique === true;
});
expect(slugIndex).toBeDefined();
expect(slugIndex?.unique).toBe(true);
});
it('userId 必须设置索引以优化个人名片列表查询', () => {
const indexes = metadata.indices.filter(i => i.target === CardInfoEntity);
const userIndex = indexes.find(i => {
if (Array.isArray(i.columns)) {
return i.columns.includes('userId') || i.columns.includes('user_id');
}
return false;
});
expect(userIndex).toBeDefined();
});
it('默认值设置应合理且符合规范 (status=\'draft\', themeCode=\'corporate_navy\', timezone=\'UTC+8\')', () => {
const columns = metadata.columns.filter(c => c.target === CardInfoEntity);
const statusCol = columns.find(c => c.propertyName === 'status');
expect(statusCol?.options.default).toBe('draft');
const themeCol = columns.find(c => c.propertyName === 'themeCode');
expect(themeCol?.options.default).toBe('corporate_navy');
const tzCol = columns.find(c => c.propertyName === 'timezone');
expect(tzCol?.options.default).toBe('UTC+8');
});
it('CardInfoEntity 实例应能赋值并完全满足 CardProfile 契约接口', () => {
const card = new CardInfoEntity();
card.id = 1;
card.tenantId = 100;
card.userId = 10;
card.name = 'Alex Zhang';
card.title = 'Global BD Director';
card.company = 'OmniCard Global Ltd.';
card.slogan = 'Connecting Global Business Seamlessly';
card.avatarUrl = 'https://cdn.omnicard.ai/avatar/alex.jpg';
card.phone = '+86 13800138000';
card.email = 'alex@omnicard.ai';
card.whatsapp = '+8613800138000';
card.linkedin = 'https://linkedin.com/in/alex-zhang';
card.timezone = 'UTC+8';
card.audioPronunciationUrl = 'https://cdn.omnicard.ai/audio/alex.mp3';
card.status = 'published';
card.themeCode = 'tech_dark';
card.slug = 'alex-zhang-bd';
// 类型断言与赋值检查
const profile: CardProfile = card;
expect(profile.name).toBe('Alex Zhang');
expect(profile.status).toBe('published');
expect(profile.themeCode).toBe('tech_dark');
expect(card.slug).toBe('alex-zhang-bd');
});
});
});

查看文件

@@ -0,0 +1,347 @@
import { CardPortalService } from '../../../src/modules/card/service/portal';
import { OpenCardPortalController } from '../../../src/modules/card/controller/open/portal';
import { CardInfoEntity } from '../../../src/modules/card/entity/info';
import { CardBentoEntity } from '../../../src/modules/card/entity/bento';
import { CoolCommException } from '@cool-midway/core';
describe('Task 08: 后端公开短链路由与秒级微官网详情 API (Card Portal & Open Router)', () => {
let cardPortalService: CardPortalService;
let portalController: OpenCardPortalController;
// 模拟名片数据仓储
const mockCardData: CardInfoEntity[] = [
{
id: 101,
tenantId: 1,
slug: 'alex-zheng',
name: 'Alex Zheng',
title: 'VP of Global Expansion',
company: 'OmniCard Global Inc.',
slogan: 'Empowering Global Business Connections',
avatarUrl: 'https://cdn.omnicard.ai/avatars/alex.png',
phone: '+86-13800138000',
email: 'alex@omnicard.ai',
whatsapp: '+8613800138000',
linkedin: 'https://linkedin.com/in/alex-zheng',
timezone: 'Asia/Shanghai',
audioPronunciationUrl: 'https://cdn.omnicard.ai/audio/alex.mp3',
status: 'published',
themeCode: 'corporate_navy',
// 敏感内部数据
userId: 9999,
remark: '重点出海客户核心高管,年费 VIP 专属',
internalConfig: '{"rateLimit": 1000, "debug": true, "internalTenantSecret": "sk_live_xyz"}',
createTime: new Date(),
updateTime: new Date(),
} as CardInfoEntity,
{
id: 102,
tenantId: 1,
slug: 'draft-user',
name: 'Draft User',
title: 'Engineer',
company: 'OmniCard',
status: 'draft', // 未发布草稿
userId: 8888,
remark: '草稿名片,禁止外网访问',
createTime: new Date(),
updateTime: new Date(),
} as CardInfoEntity,
{
id: 103,
tenantId: 1,
slug: 'archived-user',
name: 'Archived User',
title: 'Advisor',
company: 'OmniCard',
status: 'archived', // 归档下线
userId: 7777,
remark: '离职已下线',
createTime: new Date(),
updateTime: new Date(),
} as CardInfoEntity,
];
// 模拟 Bento 组件块数据
const mockBentoData: CardBentoEntity[] = [
{
id: 1,
cardId: 101,
type: 'article',
colSpan: 1,
rowSpan: 1,
sortOrder: 3,
title: '出海 SaaS 落地指南',
content: '深度解读全球数字名片与微官网搭建...',
status: 1, // 有效
internalNotes: '文章需由法务团队二次审核', // 敏感
} as CardBentoEntity,
{
id: 2,
cardId: 101,
type: 'hero',
colSpan: 2,
rowSpan: 1,
sortOrder: 1,
title: '关于我',
content: '10 年全球出海商务拓展经验',
status: 1, // 有效
internalNotes: '置顶 Hero 块',
} as CardBentoEntity,
{
id: 3,
cardId: 101,
type: 'case',
colSpan: 1,
rowSpan: 1,
sortOrder: 2,
title: '成功案例:NFC 智能会展',
content: '帮数百家品牌在 GITEX 达成合作',
status: 1, // 有效
internalNotes: '含未公开客户名称',
} as CardBentoEntity,
{
id: 4,
cardId: 101,
type: 'form',
colSpan: 2,
rowSpan: 1,
sortOrder: 4,
title: '内部调查问卷 (草稿/已禁用)',
status: 0, // 禁用/草稿,严禁返回
internalNotes: '已下线的测试表单',
} as CardBentoEntity,
];
let cardFindOneSpy: jest.Mock;
let bentoFindSpy: jest.Mock;
beforeEach(() => {
// 构造具备监控能力的 Mock Repository
cardFindOneSpy = jest.fn().mockImplementation(async options => {
const where = options?.where;
if (!where) return null;
if (Array.isArray(where)) {
for (const w of where) {
const matched = mockCardData.find(c => {
if (w.id !== undefined && c.id === Number(w.id)) return true;
if (w.slug !== undefined && c.slug === String(w.slug)) return true;
return false;
});
if (matched) return { ...matched };
}
return null;
}
return (
mockCardData.find(c => {
if (where.id !== undefined && c.id === Number(where.id)) return true;
if (where.slug !== undefined && c.slug === String(where.slug)) return true;
return false;
}) ?? null
);
});
bentoFindSpy = jest.fn().mockImplementation(async options => {
const cardId = options?.where?.cardId;
const status = options?.where?.status;
return mockBentoData
.filter(b => b.cardId === cardId && (status === undefined || b.status === status))
.map(b => ({ ...b }));
});
const mockCardRepo: any = {
findOne: cardFindOneSpy,
};
const mockBentoRepo: any = {
find: bentoFindSpy,
};
CardPortalService.clearAllCache();
cardPortalService = new CardPortalService();
cardPortalService.cardInfoEntity = mockCardRepo;
cardPortalService.cardBentoEntity = mockBentoRepo;
portalController = new OpenCardPortalController();
portalController.cardPortalService = cardPortalService;
});
describe('1. 核心查询与聚合逻辑 (Detail by ID & Slug)', () => {
it('应根据名片 ID 正确获取已发布名片详情并自动聚合有效 Bento 块列表', async () => {
const detail = await cardPortalService.getPortalDetail(101);
expect(detail).toBeDefined();
expect(detail.id).toBe(101);
expect(detail.name).toBe('Alex Zheng');
expect(detail.slug).toBe('alex-zheng');
expect(detail.company).toBe('OmniCard Global Inc.');
expect(detail.status).toBe('published');
// 验证 Bento 列表聚合
expect(detail.bentoBlocks).toBeDefined();
expect(detail.bentoBlocks.length).toBe(3); // 剔除了 status = 0 的块
// 验证排序:必须严格按照 sortOrder 升序排列 (1, 2, 3)
expect(detail.bentoBlocks[0].sortOrder).toBe(1);
expect(detail.bentoBlocks[0].type).toBe('hero');
expect(detail.bentoBlocks[1].sortOrder).toBe(2);
expect(detail.bentoBlocks[1].type).toBe('case');
expect(detail.bentoBlocks[2].sortOrder).toBe(3);
expect(detail.bentoBlocks[2].type).toBe('article');
});
it('应根据自定义短链 (Slug) 正确查询名片与微官网数据', async () => {
const detail = await cardPortalService.getPortalDetail('alex-zheng');
expect(detail).toBeDefined();
expect(detail.slug).toBe('alex-zheng');
expect(detail.id).toBe(101);
expect(detail.title).toBe('VP of Global Expansion');
expect(detail.bentoBlocks.length).toBe(3);
});
it('数字字符串类型的 ID 也应兼容处理', async () => {
const detail = await cardPortalService.getPortalDetail('101');
expect(detail.id).toBe(101);
expect(detail.name).toBe('Alex Zheng');
});
});
describe('2. 敏感信息脱敏与资产过滤安全性 (Data Privacy & Security)', () => {
it('必须严格脱敏员工账号 userId、后台备注 remark、租户内部配置 internalConfig 等敏感字段', async () => {
const detail: any = await cardPortalService.getPortalDetail(101);
// 验证名片主表敏感信息已完全剔除
expect(detail.userId).toBeUndefined();
expect(detail.remark).toBeUndefined();
expect(detail.internalConfig).toBeUndefined();
expect(detail.createTime).toBeUndefined();
expect(detail.updateTime).toBeUndefined();
// 验证 Bento 组件块敏感信息已完全剔除
for (const block of detail.bentoBlocks) {
expect((block as any).internalNotes).toBeUndefined();
expect((block as any).status).toBeUndefined(); // status 内部状态无需向公网访客暴露
}
});
it('仅返回对外公开展示的商务形象资产与必要交互字段', async () => {
const detail = await cardPortalService.getPortalDetail(101);
expect(detail).toHaveProperty('id');
expect(detail).toHaveProperty('slug');
expect(detail).toHaveProperty('name');
expect(detail).toHaveProperty('title');
expect(detail).toHaveProperty('company');
expect(detail).toHaveProperty('slogan');
expect(detail).toHaveProperty('avatarUrl');
expect(detail).toHaveProperty('phone');
expect(detail).toHaveProperty('email');
expect(detail).toHaveProperty('whatsapp');
expect(detail).toHaveProperty('linkedin');
expect(detail).toHaveProperty('timezone');
expect(detail).toHaveProperty('audioPronunciationUrl');
expect(detail).toHaveProperty('themeCode');
expect(detail).toHaveProperty('status');
expect(detail).toHaveProperty('bentoBlocks');
});
});
describe('3. 名片状态拦截与防泄露 (Status Interception & 404)', () => {
it('查询草稿状态 (draft) 名片时应拒绝并抛出 404 异常', async () => {
await expect(cardPortalService.getPortalDetail(102)).rejects.toThrow(CoolCommException);
await expect(cardPortalService.getPortalDetail('draft-user')).rejects.toThrow(
/未发布或已下线/
);
});
it('查询已归档下线 (archived) 名片时应拒绝并抛出 404 异常', async () => {
await expect(cardPortalService.getPortalDetail(103)).rejects.toThrow(CoolCommException);
await expect(cardPortalService.getPortalDetail('archived-user')).rejects.toThrow(
/未发布或已下线/
);
});
it('查询不存在的名片 ID 或 Slug 时应抛出 404 异常', async () => {
await expect(cardPortalService.getPortalDetail(99999)).rejects.toThrow(CoolCommException);
await expect(cardPortalService.getPortalDetail('not-exist-slug')).rejects.toThrow(
/不存在或已下线/
);
});
it('缺少名片标识参数时应抛出 400 参数异常', async () => {
await expect(cardPortalService.getPortalDetail('')).rejects.toThrow(CoolCommException);
await expect(cardPortalService.getPortalDetail(null as any)).rejects.toThrow(/缺少名片标识/);
});
});
describe('4. 高并发秒开缓存支持 (< 500ms Caching Optimization)', () => {
it('相同 ID 或 Slug 的多次连续请求应直接命中缓存,无需重复扫描底层存储', async () => {
// 首次查询:触发存储查询
const detail1 = await cardPortalService.getPortalDetail('alex-zheng');
expect(detail1).toBeDefined();
expect(cardFindOneSpy).toHaveBeenCalledTimes(1);
expect(bentoFindSpy).toHaveBeenCalledTimes(1);
// 第二次查询:命中缓存,调用次数保持为 1
const detail2 = await cardPortalService.getPortalDetail('alex-zheng');
expect(detail2).toEqual(detail1);
expect(cardFindOneSpy).toHaveBeenCalledTimes(1);
expect(bentoFindSpy).toHaveBeenCalledTimes(1);
// 通过 ID 查询同一名片也能命中 ID 关联缓存
const detailById = await cardPortalService.getPortalDetail(101);
expect(detailById.id).toBe(101);
expect(cardFindOneSpy).toHaveBeenCalledTimes(1);
// 数据中附带毫秒级缓存时间戳
expect(detail1.cachedAt).toBeDefined();
expect(typeof detail1.cachedAt).toBe('number');
});
it('当名片发布或编辑后,调用 clearPortalCache 应正确失效缓存以重新加载', async () => {
await cardPortalService.getPortalDetail(101);
expect(cardFindOneSpy).toHaveBeenCalledTimes(1);
// 主动清除缓存
await cardPortalService.clearPortalCache(101);
// 再次查询:重新穿透到底层查询
await cardPortalService.getPortalDetail(101);
expect(cardFindOneSpy).toHaveBeenCalledTimes(2);
expect(bentoFindSpy).toHaveBeenCalledTimes(2);
});
});
describe('5. 控制器路由与出参规范 (OpenCardPortalController)', () => {
it('GET /open/card/portal/detail 应支持 query 入参并返回统一 success 响应', async () => {
const resById = await portalController.detail(101, undefined, undefined);
expect(resById.code).toBe(1000);
expect(resById.message).toBe('success');
expect((resById as any).data.id).toBe(101);
expect((resById as any).data.name).toBe('Alex Zheng');
const resBySlug = await portalController.detail(undefined, 'alex-zheng', undefined);
expect(resBySlug.code).toBe(1000);
expect((resBySlug as any).data.slug).toBe('alex-zheng');
const resBySlugOrId = await portalController.detail(undefined, undefined, 'alex-zheng');
expect(resBySlugOrId.code).toBe(1000);
expect((resBySlugOrId as any).data.slug).toBe('alex-zheng');
});
it('GET /open/card/portal/:slugOrId 路径路由应正确转发并返回详情', async () => {
const res = await portalController.getBySlugOrId('alex-zheng');
expect(res.code).toBe(1000);
expect(res.message).toBe('success');
expect((res as any).data.slug).toBe('alex-zheng');
expect((res as any).data.bentoBlocks.length).toBe(3);
});
it('未传递有效参数时控制器应友好响应错误', async () => {
const res = await portalController.detail(undefined, undefined, undefined);
expect(res.code).not.toBe(1000);
});
});
});