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