文件
OmniCard/backend/src/modules/card/service/portal.ts

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);
}
}
}