feat(backend): 实现公开短链路由与秒级微官网详情 API (Task 08)

这个提交包含在:
wikiboss
2026-09-20 17:30:44 +08:00
父节点 2c3906b634
当前提交 7e0d9ee11b
共有 12 个文件被更改,包括 1935 次插入 和 1 次删除

查看文件

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