文件
OmniCard/backend/src/modules/card/controller/open/portal.ts

342 行
10 KiB
TypeScript

import { Inject, Provide, Get, Query, Param } from '@midwayjs/core';
import {
CoolController,
BaseController,
CoolTag,
TagTypes,
} from '@cool-midway/core';
import { CardPortalService } from '../../service/portal';
import { CardCalendarService } from '../../service/calendar';
import { CardSeoService } from '../../service/seo';
import {
GeoHeaderMap,
resolveSuggestedLocale,
} from '../../../../comm/i18n/geo';
import { MOCK_CARDS_DATA } from '../../mock/seed-cards';
export interface PortalRequestContext {
headers?: GeoHeaderMap;
set?: (key: string, value: string) => void;
status?: number;
body?: unknown;
get?: (field: string) => string | undefined;
}
export interface MeetingIcsQuery {
summary?: string;
title?: string;
startTime?: string;
endTime?: string;
start?: string;
end?: string;
description?: string;
location?: string;
organizerName?: string;
organizerEmail?: string;
organizer?: string;
uid?: string;
url?: string;
slug?: string;
id?: string | number;
}
/**
* 访客端公开微官网与短链路由控制器 (免登录触达)
* 路由前缀: /open/card/portal
*/
@Provide()
@CoolController()
export class OpenCardPortalController extends BaseController {
@Inject()
cardPortalService: CardPortalService;
@Inject()
cardCalendarService: CardCalendarService;
@Inject()
cardSeoService: CardSeoService;
@Inject()
ctx: PortalRequestContext;
/**
* 获取公开名片微官网详情 (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,
@Query('lang') lang?: string
) {
const target = slug || id || slugOrId;
if (!target) {
return this.fail('缺少名片标识参数~', 1001);
}
try {
const data = await this.cardPortalService.getPortalDetail(
target,
this.detectSuggestedLocale(lang)
);
return this.ok(data);
} catch (err: any) {
return this.fail(
err?.message || '获取名片微官网失败',
err?.status || 1001
);
}
}
/**
* 获取全部高保真名片预览清单 (供前端预览体验与免登录快速切换)
* 接口地址: GET /open/card/portal/mock-cards
*/
@CoolTag(TagTypes.IGNORE_TOKEN)
@Get('/mock-cards', { summary: '获取高保真名片预览清单' })
async mockCards() {
return this.ok(
MOCK_CARDS_DATA.map(item => ({
...item.card,
bentoBlocksCount: item.bentoBlocks.length,
casesCount: item.cases.length,
articlesCount: item.articles.length,
}))
);
}
/**
* 导出国际商务日程预约排期 ICS 日历订阅文件
* 接口地址: GET /open/card/portal/meeting-ics
*/
@CoolTag(TagTypes.IGNORE_TOKEN)
@Get('/meeting-ics', { summary: '导出会议排期 ICS 日历订阅文件' })
async meetingIcs(
@Query() query?: MeetingIcsQuery | string,
@Query('summary') summaryParam?: string,
@Query('startTime') startTimeParam?: string,
@Query('endTime') endTimeParam?: string,
@Query('description') descriptionParam?: string,
@Query('location') locationParam?: string,
@Query('organizerName') organizerNameParam?: string,
@Query('organizerEmail') organizerEmailParam?: string,
@Query('uid') uidParam?: string
) {
let summary: string | undefined;
let startTime: string | undefined;
let endTime: string | undefined;
let description: string | undefined;
let location: string | undefined;
let organizerName: string | undefined;
let organizerEmail: string | undefined;
let uid: string | undefined;
let url: string | undefined;
let organizer: string | undefined;
if (typeof query === 'object' && query !== null) {
summary = query.summary || query.title;
startTime = query.startTime || query.start;
endTime = query.endTime || query.end;
description = query.description;
location = query.location;
organizerName = query.organizerName;
organizerEmail = query.organizerEmail;
organizer = query.organizer;
uid = query.uid;
url = query.url;
} else if (typeof query === 'string') {
summary = query;
if (
summaryParam &&
(summaryParam.includes('-') ||
summaryParam.includes('T') ||
!isNaN(Number(summaryParam)))
) {
startTime = summaryParam;
endTime = startTimeParam;
description = endTimeParam;
location = descriptionParam;
} else {
summary = query || summaryParam;
startTime = startTimeParam;
endTime = endTimeParam;
description = descriptionParam;
location = locationParam;
}
organizerName = organizerNameParam;
organizerEmail = organizerEmailParam;
uid = uidParam;
}
if (!summary || !startTime || !endTime) {
return this.fail('缺少会议标题或排期时间参数~', 1001);
}
try {
const ics = this.cardCalendarService.generateIcsEvent({
summary,
startTime,
endTime,
description,
location,
organizer,
organizerName,
organizerEmail,
uid,
url,
});
if (this.ctx && typeof this.ctx.set === 'function') {
this.ctx.set('Content-Type', 'text/calendar; charset=utf-8');
const safeFilename = encodeURIComponent(
summary.slice(0, 30).replace(/[/\\?%*:|"<>]/g, '_')
);
this.ctx.set(
'Content-Disposition',
`attachment; filename="meeting-${safeFilename || 'schedule'}.ics"`
);
this.ctx.body = ics;
}
return ics;
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : '生成日历预约文件失败';
return this.fail(message, 1001);
}
}
/**
* 社交爬虫与网页预览 HTML 渲染 (免登录服务端渲染短链预览)
* 接口地址: GET /open/card/portal/preview-html/:slug
*/
@CoolTag(TagTypes.IGNORE_TOKEN)
@Get('/preview-html/:slug', { summary: '社交爬虫与网页预览 HTML 渲染' })
async previewHtml(@Param('slug') slug: string, @Query('lang') lang?: string) {
if (!slug) {
if (this.ctx && typeof this.ctx.set === 'function') {
this.ctx.set('Content-Type', 'text/html; charset=utf-8');
}
if (this.ctx) this.ctx.status = 400;
return this.cardSeoService.renderNotFoundHtml('缺少名片标识参数~');
}
try {
const data = await this.cardPortalService.getPortalDetail(
slug,
this.detectSuggestedLocale(lang)
);
const userAgent = this.getRequestHeader('user-agent');
const isCrawler = this.cardSeoService.isCrawler(userAgent);
const crawlerName = this.cardSeoService.detectCrawler(userAgent);
const html = this.cardSeoService.renderCardHtml(data, {
isCrawler,
crawlerName,
});
if (this.ctx && typeof this.ctx.set === 'function') {
this.ctx.set('Content-Type', 'text/html; charset=utf-8');
}
if (this.ctx) {
this.ctx.status = 200;
this.ctx.body = html;
}
return html;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '名片不存在或已下线';
const status =
typeof err === 'object' && err !== null && 'status' in err
? Number((err as { status?: number }).status) || 404
: 404;
if (this.ctx && typeof this.ctx.set === 'function') {
this.ctx.set('Content-Type', 'text/html; charset=utf-8');
}
if (this.ctx) {
this.ctx.status = status;
this.ctx.body = this.cardSeoService.renderNotFoundHtml(message);
}
return this.cardSeoService.renderNotFoundHtml(message);
}
}
/**
* 根据自定义短链或 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,
@Query('lang') lang?: string
) {
if (
!slugOrId ||
slugOrId === 'detail' ||
slugOrId === 'meeting-ics' ||
slugOrId === 'preview-html' ||
slugOrId === 'mock-cards'
) {
return this.fail('缺少名片标识参数~', 1001);
}
// 社交爬虫探测:若检测到社交机器人 UA,自动透明转为返回服务端渲染 HTML
const userAgent = this.getRequestHeader('user-agent');
if (this.cardSeoService && this.cardSeoService.isCrawler(userAgent)) {
return (await this.previewHtml(slugOrId, lang)) as unknown as ReturnType<
BaseController['ok']
>;
}
try {
const data = await this.cardPortalService.getPortalDetail(
slugOrId,
this.detectSuggestedLocale(lang)
);
return this.ok(data);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '获取名片微官网失败';
const status =
typeof err === 'object' && err !== null && 'status' in err
? Number((err as { status?: number }).status) || 1001
: 1001;
return this.fail(message, status);
}
}
/**
* 从 query.lang 与请求头探测建议语言
*/
private detectSuggestedLocale(lang?: string): string {
return resolveSuggestedLocale({
lang,
headers: this.ctx?.headers,
});
}
/**
* 安全获取请求头字段
*/
private getRequestHeader(name: string): string | undefined {
if (!this.ctx) return undefined;
if (typeof this.ctx.get === 'function') {
const val = this.ctx.get(name);
if (val) return val;
}
const headers = this.ctx.headers;
if (!headers) return undefined;
const target = name.toLowerCase();
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === target) {
if (Array.isArray(value)) return value[0];
return value;
}
}
return undefined;
}
}