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 次删除

查看文件

@@ -1,3 +1,4 @@
import axios from 'axios';
import {
CrmRadarPushService,
RadarPushPayload,
@@ -5,6 +6,9 @@ import {
} from '../../../src/modules/crm/service/push';
import { RadarTrack } from '../../../src/comm/types/omni';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
function makeTrack(overrides: Partial<RadarTrack> = {}): RadarTrack {
return {
id: 1,
@@ -35,9 +39,20 @@ describe('Task 35: 雷达 Push 通知内存渠道 (CrmRadarPushService)', () =>
];
let service: CrmRadarPushService;
const originalWebhookUrl = process.env.RADAR_PUSH_WEBHOOK_URL;
beforeEach(() => {
service = new CrmRadarPushService();
delete process.env.RADAR_PUSH_WEBHOOK_URL;
mockedAxios.post.mockReset();
});
afterEach(() => {
if (originalWebhookUrl === undefined) {
delete process.env.RADAR_PUSH_WEBHOOK_URL;
} else {
process.env.RADAR_PUSH_WEBHOOK_URL = originalWebhookUrl;
}
});
it('buildPayload 字段齐全且 event 为 radar.visitor', () => {
@@ -116,4 +131,87 @@ describe('Task 35: 雷达 Push 通知内存渠道 (CrmRadarPushService)', () =>
expect(received).toHaveLength(1);
expect(received[0].visitorId).toBe('vis_before');
});
describe('dispatch 外发结果', () => {
it('未配置 RADAR_PUSH_WEBHOOK_URL 时先通知内存监听者,返回 delivered:false channel:memory', async () => {
const received: RadarPushPayload[] = [];
const payload = service.buildPayload(
makeTrack({ cardId: 501, visitorId: 'vis_dispatch_mem' })
);
service.subscribe(501, p => {
received.push(p);
});
const result = await service.dispatch(payload);
expect(received).toHaveLength(1);
expect(received[0].visitorId).toBe('vis_dispatch_mem');
expect(result).toEqual({ delivered: false, channel: 'memory' });
expect(result.delivered).toBe(false);
expect(mockedAxios.post).not.toHaveBeenCalled();
});
it('公网 Webhook HTTP 2xx 才返回 delivered:true channel:webhook', async () => {
process.env.RADAR_PUSH_WEBHOOK_URL = 'https://hooks.example.com/radar';
mockedAxios.post.mockResolvedValue({ status: 204, data: {} } as never);
const payload = service.buildPayload(
makeTrack({ cardId: 502, visitorId: 'vis_ok' })
);
const result = await service.dispatch(payload);
expect(mockedAxios.post).toHaveBeenCalledWith(
'https://hooks.example.com/radar',
payload,
{ timeout: 5000 }
);
expect(result).toEqual({ delivered: true, channel: 'webhook' });
});
it('Webhook 请求失败不抛错,返回 delivered:false 且带 error', async () => {
process.env.RADAR_PUSH_WEBHOOK_URL = 'https://hooks.example.com/radar';
mockedAxios.post.mockRejectedValue(new Error('socket hang up'));
const payload = service.buildPayload(
makeTrack({ cardId: 503, visitorId: 'vis_fail' })
);
await expect(service.dispatch(payload)).resolves.toEqual({
delivered: false,
channel: 'webhook',
error: 'socket hang up',
});
});
it('非 2xx 不伪造 delivered:true', async () => {
process.env.RADAR_PUSH_WEBHOOK_URL = 'https://hooks.example.com/radar';
mockedAxios.post.mockResolvedValue({ status: 500, data: 'err' } as never);
const payload = service.buildPayload(makeTrack({ cardId: 504 }));
const result = await service.dispatch(payload);
expect(result.delivered).toBe(false);
expect(result.channel).toBe('webhook');
expect(result.error).toBeTruthy();
});
it.each([
'http://localhost/hook',
'http://127.0.0.1:9000/hook',
'http://10.1.2.3/hook',
'http://172.16.0.1/hook',
'http://172.31.255.1/hook',
'http://192.168.1.10/hook',
'ftp://hooks.example.com/radar',
'file:///etc/passwd',
])('拒绝内网或非 http(s) URL:%s,不发请求', async url => {
process.env.RADAR_PUSH_WEBHOOK_URL = url;
const payload = service.buildPayload(makeTrack({ cardId: 505 }));
const result = await service.dispatch(payload);
expect(mockedAxios.post).not.toHaveBeenCalled();
expect(result.delivered).toBe(false);
expect(result.error).toMatch(/内网|拒绝|http/i);
});
});
});