diff --git a/admin/src/types/omni.ts b/admin/src/types/omni.ts index 7240d11..02e9679 100644 --- a/admin/src/types/omni.ts +++ b/admin/src/types/omni.ts @@ -252,3 +252,78 @@ export interface TenantProfile { /** 统一品牌 VI 套件 */ 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; +} + +/** + * 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; +} + diff --git a/backend/src/comm/entity/base.ts b/backend/src/comm/entity/base.ts new file mode 100644 index 0000000..c0aa25c --- /dev/null +++ b/backend/src/comm/entity/base.ts @@ -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; +} diff --git a/backend/src/comm/types/omni.ts b/backend/src/comm/types/omni.ts index 7240d11..39f9b68 100644 --- a/backend/src/comm/types/omni.ts +++ b/backend/src/comm/types/omni.ts @@ -56,7 +56,13 @@ export interface CardProfile { /** * Bento Grid 网格块类型 */ -export type BentoBlockType = 'hero' | 'case' | 'article' | 'social' | 'video' | 'form'; +export type BentoBlockType = + | 'hero' + | 'case' + | 'article' + | 'social' + | 'video' + | 'form'; /** * Bento Grid 列跨度 (1列 / 2列满宽) @@ -252,3 +258,77 @@ export interface TenantProfile { /** 统一品牌 VI 套件 */ 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; +} + +/** + * 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; +} diff --git a/backend/src/modules/card/config.ts b/backend/src/modules/card/config.ts new file mode 100644 index 0000000..a82eac8 --- /dev/null +++ b/backend/src/modules/card/config.ts @@ -0,0 +1,14 @@ +import { ModuleConfig } from '@cool-midway/core'; + +/** + * 名片与移动微官网模块配置 + */ +export default () => { + return { + name: '名片与微官网模块', + description: '名片管理、Bento Grid 便当盒微官网与公开短链服务', + middlewares: [], + globalMiddlewares: [], + order: 10, + } as ModuleConfig; +}; diff --git a/backend/src/modules/card/controller/open/portal.ts b/backend/src/modules/card/controller/open/portal.ts new file mode 100644 index 0000000..075adb6 --- /dev/null +++ b/backend/src/modules/card/controller/open/portal.ts @@ -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 + ); + } + } +} diff --git a/backend/src/modules/card/entity/bento.ts b/backend/src/modules/card/entity/bento.ts new file mode 100644 index 0000000..cb888b5 --- /dev/null +++ b/backend/src/modules/card/entity/bento.ts @@ -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; + + @Index() + @Column({ + name: 'status', + comment: '状态 (0: 禁用/草稿, 1: 启用/有效)', + type: 'tinyint', + default: 1, + }) + status: number; + + @Column({ + name: 'internal_notes', + comment: '内部备注备忘 (敏感字段)', + type: 'text', + nullable: true, + }) + internalNotes?: string; +} diff --git a/backend/src/modules/card/entity/info.ts b/backend/src/modules/card/entity/info.ts new file mode 100644 index 0000000..b792039 --- /dev/null +++ b/backend/src/modules/card/entity/info.ts @@ -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; +} diff --git a/backend/src/modules/card/service/bento.ts b/backend/src/modules/card/service/bento.ts new file mode 100644 index 0000000..43263f5 --- /dev/null +++ b/backend/src/modules/card/service/bento.ts @@ -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; + + @Inject() + ctx: unknown; + + /** + * 按 cardId 获取有序的 Bento 网格块列表 + * 严格按照 sortOrder 从小到大升序排序 + * + * @param cardId 名片 ID + * @param options 过滤配置 (如过滤启用状态) + */ + async getBlocksByCardId( + cardId: number, + options?: GetBentoOptions + ): Promise { + const where: Record = { 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 { + return await this.cardBentoEntity.findOneBy({ id }); + } + + /** + * 保存或更新单个 Bento 块 + */ + async saveBlock(block: Partial): Promise { + 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> + ): Promise { + 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 { + await this.cardBentoEntity.delete(id); + } + + /** + * 清空指定名片下的所有 Bento 块 + */ + async deleteBlocksByCardId(cardId: number): Promise { + await this.cardBentoEntity.delete({ cardId }); + } + + /** + * 切换或指定 Bento 块的状态 (启用/禁用) + * + * @param id 块 ID + * @param targetStatus 可选的目标状态,若不传则在 0 和 1 间翻转 + */ + async toggleStatus( + id: number, + targetStatus?: number + ): Promise { + 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 + ): Partial { + const item: Partial = { ...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; + } +} diff --git a/backend/src/modules/card/service/portal.ts b/backend/src/modules/card/service/portal.ts new file mode 100644 index 0000000..ef2c9d4 --- /dev/null +++ b/backend/src/modules/card/service/portal.ts @@ -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; + + @InjectEntityModel(CardBentoEntity) + cardBentoEntity: Repository; + + @Inject() + ctx: unknown; + + /** + * 本地高速 L1 缓存 (支持秒开与高并发) + */ + private static localCache = new Map(); + + /** + * 默认缓存过期时长 (毫秒,默认 60 秒) + */ + private readonly defaultTtlMs = 60 * 1000; + + /** + * 获取微官网公开详情 (通过名片 ID 或自定义 Slug 短链) + * + * @param slugOrId 名片主键 ID 或 自定义短链标识 + * @returns 脱敏后的微官网聚合资产 + */ + async getPortalDetail(slugOrId: string | number): Promise { + 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 { + 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); + } + } +} diff --git a/backend/test/modules/card/bento.test.ts b/backend/test/modules/card/bento.test.ts new file mode 100644 index 0000000..9407990 --- /dev/null +++ b/backend/test/modules/card/bento.test.ts @@ -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[] = []) { + 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 { + 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 { + 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 { + 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 | Array> + ): Promise { + 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 { + 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 网格块不存在' + ); + }); + }); +}); diff --git a/backend/test/modules/card/entity.test.ts b/backend/test/modules/card/entity.test.ts new file mode 100644 index 0000000..2525a53 --- /dev/null +++ b/backend/test/modules/card/entity.test.ts @@ -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 = { + 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'); + }); + }); +}); diff --git a/backend/test/modules/card/portal.test.ts b/backend/test/modules/card/portal.test.ts new file mode 100644 index 0000000..169c6ef --- /dev/null +++ b/backend/test/modules/card/portal.test.ts @@ -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); + }); + }); +});