feat: 完成全系统30个功能点开发与E2E全链路验收 (8轮调度32个子代理闭环)
- 模块一: 出海案例作品集、博客动态发布、人名发音语音条、RTL阿语多语言切换 - 模块二: AI顾问人设配置、行业术语库润色、RAG切片沙盒调试、流式会话与意向留资卡片 - 模块三: 全球访客雷达大屏、多维加权意向算分、外部CRM对接推送、团队公海池流转 - 模块四: 移动端NFC安全挂失、NFC动态路由规则配置、动态防伪验签、展会制卡Zip批量导出 - 模块五: 组织架构与名片分配、移动端个人中心设置、离职交接与历史线索迁移、安全审计日志 - 模块六: Stripe国际信用卡订阅Webhook、独立二级域名与SSL检测、坐席配额预警、多币种发票 - 模块七: 纸质名片OCR解析入库、国际商务日程RFC 5545 ICS日历、微官网离线SWR预加载、展会实时排行榜大屏 - 模块八: 全球出海SEO OpenGraph元标签注入与社交爬虫探测、30点端到端全链路业务流集成测试套件
这个提交包含在:
@@ -0,0 +1,31 @@
|
||||
import { CoolController, BaseController } from '@cool-midway/core';
|
||||
import { CardBentoEntity } from '../../entity/bento';
|
||||
import { CardBentoService } from '../../service/bento';
|
||||
|
||||
/**
|
||||
* 后台 Bento 标准 CRUD 配置
|
||||
*/
|
||||
export const adminCardBentoCurdOption = {
|
||||
api: ['add', 'delete', 'update', 'info', 'list', 'page'] as Array<
|
||||
'add' | 'delete' | 'update' | 'info' | 'list' | 'page'
|
||||
>,
|
||||
entity: CardBentoEntity,
|
||||
service: CardBentoService,
|
||||
pageQueryOp: {
|
||||
keyWordLikeFields: ['a.title', 'a.content'],
|
||||
fieldEq: ['a.cardId', 'a.type', 'a.status'],
|
||||
addOrderBy: { sortOrder: 'asc' as const },
|
||||
},
|
||||
serviceApis: [
|
||||
{ method: 'getBlocksByCardId', summary: '按名片获取有序 Bento' },
|
||||
{ method: 'batchSaveLayout', summary: '批量同步画布排版' },
|
||||
{ method: 'toggleStatus', summary: '切换块启用状态' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 名片模块-后台 Bento 画布
|
||||
* 路由前缀:/admin/card/bento
|
||||
*/
|
||||
@CoolController(adminCardBentoCurdOption)
|
||||
export class AdminCardBentoController extends BaseController {}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { CoolController, BaseController } from '@cool-midway/core';
|
||||
import { CardCasesEntity } from '../../entity/cases';
|
||||
import { CardCasesService } from '../../service/cases';
|
||||
|
||||
/**
|
||||
* 后台出海案例作品集标准 CRUD 配置
|
||||
*/
|
||||
export const adminCardCasesCurdOption = {
|
||||
api: ['add', 'delete', 'update', 'info', 'list', 'page'] as Array<
|
||||
'add' | 'delete' | 'update' | 'info' | 'list' | 'page'
|
||||
>,
|
||||
entity: CardCasesEntity,
|
||||
service: CardCasesService,
|
||||
pageQueryOp: {
|
||||
keyWordLikeFields: ['a.title', 'a.client', 'a.metrics'],
|
||||
fieldEq: ['a.cardId'],
|
||||
addOrderBy: { sortOrder: 'asc' as const },
|
||||
},
|
||||
serviceApis: [
|
||||
{ method: 'getCasesByCardId', summary: '按名片获取案例列表' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 名片模块-后台出海案例作品集管理
|
||||
* 路由前缀:/admin/card/cases
|
||||
*/
|
||||
@CoolController(adminCardCasesCurdOption)
|
||||
export class AdminCardCasesController extends BaseController {}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { CoolController, BaseController } from '@cool-midway/core';
|
||||
import { CardInfoEntity } from '../../entity/info';
|
||||
import { CardInfoService } from '../../service/info';
|
||||
|
||||
/**
|
||||
* 后台登录态插入参数
|
||||
*/
|
||||
interface AdminInsertCtx {
|
||||
admin?: {
|
||||
userId?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台名片标准 CRUD 配置
|
||||
*/
|
||||
export const adminCardInfoCurdOption = {
|
||||
api: ['add', 'delete', 'update', 'info', 'list', 'page'] as Array<
|
||||
'add' | 'delete' | 'update' | 'info' | 'list' | 'page'
|
||||
>,
|
||||
entity: CardInfoEntity,
|
||||
service: CardInfoService,
|
||||
insertParam: (ctx: AdminInsertCtx) => ({
|
||||
userId: ctx.admin?.userId,
|
||||
}),
|
||||
infoIgnoreProperty: ['remark', 'internalConfig'],
|
||||
pageQueryOp: {
|
||||
keyWordLikeFields: ['a.name', 'a.title', 'a.company', 'a.slug'],
|
||||
fieldEq: ['a.status', 'a.themeCode'],
|
||||
addOrderBy: { updateTime: 'desc' as const },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 名片模块-后台名片信息
|
||||
* 路由前缀:/admin/card/info
|
||||
*/
|
||||
@CoolController(adminCardInfoCurdOption)
|
||||
export class AdminCardInfoController extends BaseController {}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { CoolController, BaseController } from '@cool-midway/core';
|
||||
import { CardLocaleEntity } from '../../entity/locale';
|
||||
import { CardLocaleService } from '../../service/locale';
|
||||
|
||||
/**
|
||||
* 后台名片多语言标准 CRUD 配置
|
||||
*/
|
||||
export const adminCardLocaleCurdOption = {
|
||||
api: ['add', 'delete', 'update', 'info', 'list', 'page'] as Array<
|
||||
'add' | 'delete' | 'update' | 'info' | 'list' | 'page'
|
||||
>,
|
||||
entity: CardLocaleEntity,
|
||||
service: CardLocaleService,
|
||||
pageQueryOp: {
|
||||
fieldEq: ['a.cardId', 'a.locale'],
|
||||
},
|
||||
serviceApis: [
|
||||
{ method: 'upsert', summary: '按名片语言覆盖写入' },
|
||||
{ method: 'listByCardId', summary: '按名片列出多语分支' },
|
||||
{ method: 'getByCardIdAndLocale', summary: '按名片与语言获取' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 名片模块-后台内容级多语言
|
||||
* 路由前缀:/admin/card/locale
|
||||
*/
|
||||
@CoolController(adminCardLocaleCurdOption)
|
||||
export class AdminCardLocaleController extends BaseController {}
|
||||
@@ -6,6 +6,38 @@ import {
|
||||
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';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 访客端公开微官网与短链路由控制器 (免登录触达)
|
||||
@@ -17,6 +49,15 @@ 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
|
||||
@@ -26,7 +67,8 @@ export class OpenCardPortalController extends BaseController {
|
||||
async detail(
|
||||
@Query('id') id?: string | number,
|
||||
@Query('slug') slug?: string,
|
||||
@Query('slugOrId') slugOrId?: string
|
||||
@Query('slugOrId') slugOrId?: string,
|
||||
@Query('lang') lang?: string
|
||||
) {
|
||||
const target = slug || id || slugOrId;
|
||||
if (!target) {
|
||||
@@ -34,7 +76,10 @@ export class OpenCardPortalController extends BaseController {
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await this.cardPortalService.getPortalDetail(target);
|
||||
const data = await this.cardPortalService.getPortalDetail(
|
||||
target,
|
||||
this.detectSuggestedLocale(lang)
|
||||
);
|
||||
return this.ok(data);
|
||||
} catch (err: any) {
|
||||
return this.fail(
|
||||
@@ -44,25 +89,234 @@ export class OpenCardPortalController extends BaseController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出国际商务日程预约排期 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) {
|
||||
if (!slugOrId || slugOrId === 'detail') {
|
||||
async getBySlugOrId(
|
||||
@Param('slugOrId') slugOrId: string,
|
||||
@Query('lang') lang?: string
|
||||
) {
|
||||
if (
|
||||
!slugOrId ||
|
||||
slugOrId === 'detail' ||
|
||||
slugOrId === 'meeting-ics' ||
|
||||
slugOrId === 'preview-html'
|
||||
) {
|
||||
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);
|
||||
return this.ok(data);
|
||||
} catch (err: any) {
|
||||
return this.fail(
|
||||
err?.message || '获取名片微官网失败',
|
||||
err?.status || 1001
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Inject, Provide, Get, Query } from '@midwayjs/core';
|
||||
import {
|
||||
CoolController,
|
||||
BaseController,
|
||||
CoolTag,
|
||||
TagTypes,
|
||||
} from '@cool-midway/core';
|
||||
import { CardQrcodeService } from '../../service/qrcode';
|
||||
|
||||
/**
|
||||
* 访客端公开动态短链二维码下载(免登录)
|
||||
* 路由前缀: /open/card/qrcode
|
||||
*/
|
||||
@Provide()
|
||||
@CoolController()
|
||||
export class OpenCardQrcodeController extends BaseController {
|
||||
@Inject()
|
||||
cardQrcodeService: CardQrcodeService;
|
||||
|
||||
/**
|
||||
* 下载 SVG 二维码
|
||||
* 接口地址: GET /open/card/qrcode/svg?slugOrId=alex-zheng
|
||||
*/
|
||||
@CoolTag(TagTypes.IGNORE_TOKEN)
|
||||
@Get('/svg', { summary: '公开名片二维码 SVG' })
|
||||
async svg(@Query('slugOrId') slugOrId?: string) {
|
||||
const data = await this.cardQrcodeService.generateSvg(slugOrId);
|
||||
return this.ok(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载 PNG 二维码(dataURL)
|
||||
* 接口地址: GET /open/card/qrcode/png?slugOrId=alex-zheng
|
||||
*/
|
||||
@CoolTag(TagTypes.IGNORE_TOKEN)
|
||||
@Get('/png', { summary: '公开名片二维码 PNG' })
|
||||
async png(@Query('slugOrId') slugOrId?: string) {
|
||||
const data = await this.cardQrcodeService.generatePng(slugOrId);
|
||||
return this.ok(data);
|
||||
}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户