223 行
6.2 KiB
TypeScript
223 行
6.2 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|