feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口

补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。

Co-authored-by: Cursor <cursoragent@cursor.com>
这个提交包含在:
wikiboss
2026-09-23 18:01:47 +08:00
父节点 8ea9c25386
当前提交 62e0ae4435
共有 227 个文件被更改,包括 21855 次插入 和 5264 次删除

查看文件

@@ -0,0 +1,90 @@
import { CoolCommException } from '@cool-midway/core';
import { AdminCardInfoController } from '../../../src/modules/card/controller/admin/info';
import { CardInfoService } from '../../../src/modules/card/service/info';
/**
* R35:生产环境禁止 POST /admin/card/info/seed 写入演示名片
*/
describe('AdminCardInfoController seed 生产守卫', () => {
const originalNodeEnv = process.env.NODE_ENV;
afterEach(() => {
process.env.NODE_ENV = originalNodeEnv;
});
function createController(env: {
nodeEnv?: string;
midwayEnv?: string;
}): {
controller: AdminCardInfoController;
seedMockCards: jest.Mock;
} {
if (env.nodeEnv !== undefined) {
process.env.NODE_ENV = env.nodeEnv;
}
const seedMockCards = jest.fn().mockResolvedValue(6);
const controller = new AdminCardInfoController();
controller.cardInfoService = {
seedMockCards,
} as unknown as CardInfoService;
controller.ok = jest.fn((data: unknown) => ({
code: 1000,
data,
})) as never;
if (env.midwayEnv !== undefined) {
(controller as { app?: { getEnv: () => string } }).app = {
getEnv: () => env.midwayEnv as string,
};
}
return { controller, seedMockCards };
}
it('NODE_ENV=production 时应抛明确错误且不调用 seedMockCards', async () => {
const { controller, seedMockCards } = createController({
nodeEnv: 'production',
});
await expect(controller.seed()).rejects.toThrow(CoolCommException);
await expect(controller.seed()).rejects.toThrow(
/生产|production|禁止|不可用|不允许/i
);
expect(seedMockCards).not.toHaveBeenCalled();
expect(controller.ok).not.toHaveBeenCalled();
});
it('Midway getEnv()=prod 时应拒绝且不播种', async () => {
const { controller, seedMockCards } = createController({
nodeEnv: 'local',
midwayEnv: 'prod',
});
await expect(controller.seed()).rejects.toThrow(CoolCommException);
expect(seedMockCards).not.toHaveBeenCalled();
});
it('非生产环境应调用 seedMockCards 并正常返回', async () => {
const { controller, seedMockCards } = createController({
nodeEnv: 'local',
midwayEnv: 'local',
});
const result = await controller.seed();
expect(seedMockCards).toHaveBeenCalledTimes(1);
expect(controller.ok).toHaveBeenCalled();
expect(result).toEqual({
code: 1000,
data: expect.objectContaining({ count: 6 }),
});
});
it('unittest 环境允许播种(本地测试)', async () => {
const { controller, seedMockCards } = createController({
nodeEnv: 'unittest',
midwayEnv: 'unittest',
});
await controller.seed();
expect(seedMockCards).toHaveBeenCalledTimes(1);
});
});

查看文件

@@ -58,6 +58,69 @@ describe('Point 62 (P1): 展会名片双向互换与 CRM 互加好友验证', ()
const outcome = await service.confirmExchange(invite.id, 402);
expect(outcome.status).toBe('ACCEPTED');
expect(outcome.isDualSynced).toBe(true);
expect(invite.persisted).toBe(false);
expect(outcome.persisted).toBe(false);
});
it('仅内存 Map 保存时,创建与确认结果均应带 persisted:false', async () => {
// 无仓库注入:不得假装已入库
expect((service as any).exchangeEntity).toBeUndefined();
const invite = await service.initiateExchange({
senderCardId: 501,
recipientCardId: 602,
});
expect(invite.persisted).toBe(false);
const outcome = await service.confirmExchange(invite.id, 602);
expect(outcome.status).toBe('ACCEPTED');
expect(outcome.persisted).toBe(false);
});
it('有仓库且写入成功时,创建与确认结果均应带 persisted:true', async () => {
let nextId = 1001;
const store = new Map<number, any>();
(service as any).exchangeEntity = {
save: async (row: any) => {
if (!row.id) {
row.id = nextId++;
}
store.set(Number(row.id), { ...row });
return row;
},
findOne: async ({ where }: { where: { id?: number } }) => {
if (where?.id == null) return null;
const hit = store.get(Number(where.id));
return hit ? { ...hit } : null;
},
};
const invite = await service.initiateExchange({
senderCardId: 701,
recipientCardId: 802,
});
expect(invite.persisted).toBe(true);
expect(invite.id).toBe('1001');
const outcome = await service.confirmExchange(invite.id, 802);
expect(outcome.status).toBe('ACCEPTED');
expect(outcome.persisted).toBe(true);
});
it('找不到邀约时应报错,不得伪造成功互换', async () => {
await expect(
service.confirmExchange('xchg_missing_nowhere', 402)
).rejects.toThrow(/未找到该名片互换邀约/);
});
it('有仓库但库中与内存均无此邀约时,确认应失败而非造一条成功互换', async () => {
(service as any).exchangeEntity = {
findOne: async () => null,
save: async (row: any) => row,
};
await expect(service.confirmExchange('99999', 402)).rejects.toThrow(
/未找到该名片互换邀约/
);
});
});
});

查看文件

@@ -343,6 +343,41 @@ describe('Task 08: 后端公开短链路由与秒级微官网详情 API (Card Po
const res = await portalController.detail(undefined, undefined, undefined);
expect(res.code).not.toBe(1000);
});
describe('GET /open/card/portal/mock-cards (ALLOW_PORTAL_MOCK)', () => {
const prevAllow = process.env.ALLOW_PORTAL_MOCK;
afterEach(() => {
if (prevAllow === undefined) {
delete process.env.ALLOW_PORTAL_MOCK;
} else {
process.env.ALLOW_PORTAL_MOCK = prevAllow;
}
});
it('默认(未开启 ALLOW_PORTAL_MOCK)应返回空数组,不含演示姓名', async () => {
delete process.env.ALLOW_PORTAL_MOCK;
const res = await portalController.mockCards();
expect(res.code).toBe(1000);
expect(Array.isArray((res as any).data)).toBe(true);
expect((res as any).data).toEqual([]);
const payload = JSON.stringify((res as any).data);
expect(payload).not.toContain('Alex Zheng');
expect(payload).not.toContain('Marcus Weber');
});
it('ALLOW_PORTAL_MOCK=1 时才返回原演示名片清单', async () => {
process.env.ALLOW_PORTAL_MOCK = '1';
const res = await portalController.mockCards();
expect(res.code).toBe(1000);
const list = (res as any).data;
expect(Array.isArray(list)).toBe(true);
expect(list.length).toBeGreaterThanOrEqual(6);
expect(list[0].slug).toBe('alex-zheng');
expect(list[0].name).toBe('Alex Zheng');
expect(list[0].bentoBlocksCount).toBeGreaterThanOrEqual(5);
});
});
});
describe('6. suggestedLocale 浅拷贝且不污染缓存 (T18)', () => {

查看文件

@@ -166,16 +166,18 @@ describe('Point 29-A: SEO & OpenGraph 社交预览元标签注入引擎', () =>
expect(metaNull['og:description']).toBe(
'OmniCard AI - Global Digital Business Card & Micro Portal'
);
expect(metaNull['og:image']).toBe('https://omnicard.ai/assets/og-default.png');
// 无真实头像/封面时不得填假 CDN / 占位图
expect(metaNull['og:image']).toBe('');
expect(metaNull['twitter:image']).toBe('');
expect(metaNull['og:type']).toBe('profile');
expect(metaNull['twitter:card']).toBe('summary_large_image');
const metaUndefined = seoService.generateOpenGraphMeta(undefined, {
siteName: 'Custom Brand',
defaultImage: 'https://example.com/fallback.jpg',
});
expect(metaUndefined['og:title']).toBe('Custom Brand');
expect(metaUndefined['og:image']).toBe('https://example.com/fallback.jpg');
expect(metaUndefined['og:image']).toBe('');
expect(metaUndefined['twitter:image']).toBe('');
});
it('当 template 不含标准 <head> 标签时,renderHtmlWithOgMeta 能够安全兜底注入', () => {
@@ -185,4 +187,112 @@ describe('Point 29-A: SEO & OpenGraph 社交预览元标签注入引擎', () =>
expect(result).toContain('<div>Raw Body Content</div>');
});
});
describe('6. 无真实图片时 og:image / twitter:image 必须为空(禁止假图)', () => {
it('generateOgMeta:无 avatarUrl 时 image 为空,不得回退到 default-card-og / og-default', () => {
const og = seoService.generateOgMeta(
{
id: 1,
name: 'No Avatar',
title: 'Engineer',
company: 'Omni',
status: 'active' as any,
bentoBlocks: [],
},
'https://omnicard.ai'
);
expect(og.image).toBe('');
expect(og.image).not.toMatch(/og-default|default-card-og|placeholder|example\.com|unsplash|picsum/i);
});
it('generateOgMeta:仅接受真实 http(s) 头像,相对路径与非 http 协议视为无图', () => {
const relative = seoService.generateOgMeta({
id: 2,
name: 'Rel',
title: 'T',
company: 'C',
avatarUrl: '/static/avatar.png',
status: 'active' as any,
bentoBlocks: [],
});
expect(relative.image).toBe('');
const blank = seoService.generateOgMeta({
id: 2,
name: 'Blank',
title: 'T',
company: 'C',
avatarUrl: ' ',
status: 'active' as any,
bentoBlocks: [],
});
expect(blank.image).toBe('');
const dataUri = seoService.generateOgMeta({
id: 2,
name: 'Data',
title: 'T',
company: 'C',
avatarUrl: 'data:image/png;base64,AAAA',
status: 'active' as any,
bentoBlocks: [],
});
expect(dataUri.image).toBe('');
const real = seoService.generateOgMeta({
id: 3,
name: 'Https',
title: 'T',
company: 'C',
avatarUrl: 'https://cdn.omnicard.ai/avatars/real.jpg',
status: 'active' as any,
bentoBlocks: [],
});
expect(real.image).toBe('https://cdn.omnicard.ai/avatars/real.jpg');
});
it('generateOpenGraphMeta:无头像时 og:image / twitter:image 为空,忽略假 defaultImage 占位', () => {
const meta = seoService.generateOpenGraphMeta(
{ name: 'Empty Pic', title: 'CTO', company: 'X' },
{
baseUrl: 'https://omnicard.ai',
defaultImage: 'https://omnicard.ai/assets/og-default.png',
}
);
expect(meta['og:image']).toBe('');
expect(meta['twitter:image']).toBe('');
expect(JSON.stringify(meta)).not.toMatch(
/og-default|default-card-og|placeholder|picsum|unsplash/i
);
});
it('renderHtmlWithOgMeta:无真实图片时不输出 og:image / twitter:image 标签', () => {
const template = `<!DOCTYPE html><html><head><title>t</title></head><body></body></html>`;
const html = seoService.renderHtmlWithOgMeta(
template,
{ name: 'No Image Card' },
{ baseUrl: 'https://omnicard.ai' }
);
expect(html).not.toContain('property="og:image"');
expect(html).not.toContain('name="twitter:image"');
expect(html).not.toMatch(/og-default|default-card-og/i);
});
it('renderCardHtml:无头像时 og:image / twitter:image content 为空', () => {
const html = seoService.renderCardHtml(
{
id: 9,
name: 'Blank',
title: 'Dev',
company: 'ACME',
status: 'active' as any,
bentoBlocks: [],
},
{ baseUrl: 'https://omnicard.ai' }
);
expect(html).toContain('<meta property="og:image" content="" />');
expect(html).toContain('<meta name="twitter:image" content="" />');
expect(html).not.toMatch(/og-default|default-card-og|placeholder\.com|example\.com\/.*\.(png|jpe?g)/i);
});
});
});

查看文件

@@ -0,0 +1,132 @@
import * as fs from 'fs';
import * as path from 'path';
import { CoolCommException } from '@cool-midway/core';
import { CardInfoEntity } from '../../../src/modules/card/entity/info';
import { CardInfoService } from '../../../src/modules/card/service/info';
import { AdminCardInfoController } from '../../../src/modules/card/controller/admin/info';
/**
* 内存 Repository,仅覆盖 findOneBy
*/
class MemoryCardInfoRepo {
private data: CardInfoEntity[] = [];
constructor(initialData: Array<Partial<CardInfoEntity>> = []) {
this.data = initialData.map(item => {
const entity = new CardInfoEntity();
Object.assign(entity, item);
return entity;
});
}
async findOneBy(
where: Partial<Pick<CardInfoEntity, 'id'>>
): Promise<CardInfoEntity | null> {
const found = this.data.find(item => {
if (where.id === undefined) {
return false;
}
return Number(item.id) === Number(where.id);
});
return found ? ({ ...found } as CardInfoEntity) : null;
}
}
describe('后台名片 vCard 导出 API', () => {
const controllerFile = path.join(
__dirname,
'../../../src/modules/card/controller/admin/info.ts'
);
const serviceFile = path.join(
__dirname,
'../../../src/modules/card/service/info.ts'
);
describe('1. 路由与方法约定', () => {
it('admin info 控制器应声明 /vcard 导出接口', () => {
expect(fs.existsSync(controllerFile)).toBe(true);
const content = fs.readFileSync(controllerFile, 'utf-8');
expect(content).toMatch(/@Post\(\s*['"]\/vcard['"]/);
expect(content).toMatch(/exportVCard|exportVcard/);
});
it('CardInfoService 应提供 exportVCard 方法', () => {
expect(fs.existsSync(serviceFile)).toBe(true);
const content = fs.readFileSync(serviceFile, 'utf-8');
expect(content).toContain('exportVCard');
expect(content).toContain('generateVCard');
expect(content).not.toContain("FN:假");
});
});
describe('2. exportVCard 业务', () => {
let service: CardInfoService;
beforeEach(() => {
service = new CardInfoService();
service.cardInfoEntity = new MemoryCardInfoRepo([
{
id: 42,
name: 'Alex Morgan',
title: 'CTO',
company: 'OmniCard AI',
phone: '+86-13800138000',
email: 'alex@omnicard.ai',
slug: 'alex-morgan',
status: 'published',
},
]) as unknown as CardInfoService['cardInfoEntity'];
});
it('名片不存在时应抛出 CoolCommException,不得返回写死姓名', async () => {
await expect(service.exportVCard(99999)).rejects.toThrow(CoolCommException);
await expect(service.exportVCard(99999)).rejects.toThrow(/不存在|not found/i);
});
it('名片存在时返回的 vcf 必须含 BEGIN:VCARD 且包含真实姓名', async () => {
const vcf = await service.exportVCard(42);
expect(vcf).toContain('BEGIN:VCARD');
expect(vcf).toContain('END:VCARD');
expect(vcf).toContain('FN:Alex Morgan');
expect(vcf).not.toContain('假姓名');
});
});
describe('3. 控制器委派', () => {
it('vcard 接口应按 id 调用 service.exportVCard 并 ok 返回', async () => {
const controller = new AdminCardInfoController();
const exportVCard = jest.fn().mockResolvedValue(
'BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Alex Morgan\r\nEND:VCARD\r\n'
);
controller.cardInfoService = {
exportVCard,
} as unknown as CardInfoService;
controller.ok = jest.fn((data: unknown) => ({ code: 1000, data })) as never;
const result = await controller.vcard({ id: 42 });
expect(exportVCard).toHaveBeenCalledWith(42);
expect(controller.ok).toHaveBeenCalled();
const okArg = (controller.ok as jest.Mock).mock.calls[0][0] as {
vcard?: string;
filename?: string;
};
expect(okArg.vcard).toContain('BEGIN:VCARD');
expect(okArg.filename).toMatch(/\.vcf$/);
expect(result).toEqual({
code: 1000,
data: okArg,
});
});
it('缺少 id 时应抛 CoolCommException', async () => {
const controller = new AdminCardInfoController();
controller.cardInfoService = {
exportVCard: jest.fn(),
} as unknown as CardInfoService;
await expect(controller.vcard({})).rejects.toThrow(CoolCommException);
await expect(controller.vcard({ id: 0 })).rejects.toThrow(CoolCommException);
});
});
});