342 行
10 KiB
TypeScript
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;
|
|
}
|
|
}
|