feat(backend): 实现公开短链路由与秒级微官网详情 API (Task 08)
这个提交包含在:
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 网格块类型
|
||||
*/
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户