feat(backend): 实现公开短链路由与秒级微官网详情 API (Task 08)
这个提交包含在:
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
85
backend/src/comm/entity/base.ts
普通文件
85
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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户