Skip to content

Commit 3f51a95

Browse files
committed
feat: add analyze apis
1 parent dfeb65c commit 3f51a95

6 files changed

Lines changed: 367 additions & 0 deletions

File tree

apps/core/src/common/interceptors/analyze.interceptor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export class AnalyzeInterceptor implements NestInterceptor {
116116
path: new URL(`http://a.com${url}`).pathname,
117117
country:
118118
request.headers['cf-ipcountry'] || request.headers['CF-IPCountry'],
119+
referer: request.headers.referer || request.headers.Referer,
119120
})
120121

121122
// ip access in redis

apps/core/src/modules/activity/activity.controller.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,19 @@ export class ActivityController {
151151
}
152152
}
153153

154+
@Get('/online-count')
155+
async getOnlineCount() {
156+
const roomInfo = await this.service.getAllRoomNames()
157+
const total = Object.values(roomInfo.roomCount).reduce(
158+
(acc: number, count: number) => acc + count,
159+
0,
160+
)
161+
return {
162+
total,
163+
rooms: roomInfo.roomCount,
164+
}
165+
}
166+
154167
@Auth()
155168
@Get('/reading/rank')
156169
async getReadingRangeRank(@Query() query: ActivityRangeDto) {

apps/core/src/modules/analyze/analyze.controller.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,18 @@ export class AnalyzeController {
160160
)
161161
}
162162

163+
@Get('/traffic-source')
164+
async getTrafficSource(@Query() query: AnalyzeDto) {
165+
const { from, to } = query
166+
return this.service.getTrafficSource(from, to)
167+
}
168+
169+
@Get('/device')
170+
async getDeviceDistribution(@Query() query: AnalyzeDto) {
171+
const { from, to } = query
172+
return this.service.getDeviceDistribution(from, to)
173+
}
174+
163175
@Delete('/')
164176
@HttpCode(204)
165177
async clearAnalyze(@Query() query: AnalyzeDto) {

apps/core/src/modules/analyze/analyze.model.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,8 @@ export class AnalyzeModel extends BaseModel {
3030
@prop()
3131
path?: string
3232

33+
@prop()
34+
referer?: string
35+
3336
timestamp: Date
3437
}

apps/core/src/modules/analyze/analyze.service.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,4 +291,185 @@ export class AnalyzeService {
291291

292292
return fromRedisIps
293293
}
294+
295+
async getDeviceDistribution(from?: Date, to?: Date) {
296+
from = from ?? new Date(Date.now() - 1000 * 24 * 3600 * 7)
297+
to = to ?? new Date()
298+
299+
const result = await this.analyzeModel.aggregate([
300+
{
301+
$match: {
302+
timestamp: {
303+
$gte: from,
304+
$lte: to,
305+
},
306+
},
307+
},
308+
{
309+
$project: {
310+
browser: { $ifNull: ['$ua.browser.name', 'Unknown'] },
311+
os: { $ifNull: ['$ua.os.name', 'Unknown'] },
312+
device: { $ifNull: ['$ua.device.type', 'desktop'] },
313+
},
314+
},
315+
{
316+
$facet: {
317+
browsers: [
318+
{ $group: { _id: '$browser', count: { $sum: 1 } } },
319+
{ $sort: { count: -1 } },
320+
{ $limit: 10 },
321+
],
322+
os: [
323+
{ $group: { _id: '$os', count: { $sum: 1 } } },
324+
{ $sort: { count: -1 } },
325+
{ $limit: 10 },
326+
],
327+
devices: [
328+
{ $group: { _id: '$device', count: { $sum: 1 } } },
329+
{ $sort: { count: -1 } },
330+
],
331+
},
332+
},
333+
])
334+
335+
const data = result[0] || { browsers: [], os: [], devices: [] }
336+
337+
const deviceTypeMap: Record<string, string> = {
338+
desktop: '桌面端',
339+
mobile: '移动端',
340+
tablet: '平板',
341+
unknown: '未知',
342+
}
343+
344+
return {
345+
browsers: data.browsers.map((item: any) => ({
346+
name: item._id || 'Unknown',
347+
value: item.count,
348+
})),
349+
os: data.os.map((item: any) => ({
350+
name: item._id || 'Unknown',
351+
value: item.count,
352+
})),
353+
devices: data.devices.map((item: any) => ({
354+
name: deviceTypeMap[item._id?.toLowerCase()] || item._id || '桌面端',
355+
value: item.count,
356+
})),
357+
}
358+
}
359+
360+
async getTrafficSource(from?: Date, to?: Date) {
361+
from = from ?? new Date(Date.now() - 1000 * 24 * 3600 * 7)
362+
to = to ?? new Date()
363+
364+
const result = await this.analyzeModel.aggregate([
365+
{
366+
$match: {
367+
timestamp: {
368+
$gte: from,
369+
$lte: to,
370+
},
371+
},
372+
},
373+
{
374+
$project: {
375+
referer: { $ifNull: ['$referer', ''] },
376+
},
377+
},
378+
{
379+
$group: {
380+
_id: '$referer',
381+
count: { $sum: 1 },
382+
},
383+
},
384+
{
385+
$sort: { count: -1 },
386+
},
387+
])
388+
389+
const categories: Record<string, number> = {
390+
direct: 0,
391+
search: 0,
392+
social: 0,
393+
other: 0,
394+
}
395+
396+
const searchEngines = [
397+
'google',
398+
'bing',
399+
'baidu',
400+
'sogou',
401+
'so.com',
402+
'360.cn',
403+
'yahoo',
404+
'duckduckgo',
405+
'yandex',
406+
]
407+
const socialNetworks = [
408+
'twitter',
409+
'x.com',
410+
'facebook',
411+
'weibo',
412+
'zhihu',
413+
'douban',
414+
'reddit',
415+
'linkedin',
416+
'instagram',
417+
'tiktok',
418+
'youtube',
419+
'bilibili',
420+
't.me',
421+
'telegram',
422+
'discord',
423+
]
424+
425+
const details: Array<{ source: string; count: number }> = []
426+
427+
for (const item of result) {
428+
const referer = (item._id as string).toLowerCase()
429+
const count = item.count as number
430+
431+
if (!referer || referer === '') {
432+
categories.direct += count
433+
continue
434+
}
435+
436+
let hostname = ''
437+
try {
438+
hostname = new URL(referer).hostname.toLowerCase()
439+
} catch {
440+
categories.other += count
441+
continue
442+
}
443+
444+
const isSearch = searchEngines.some((engine) => hostname.includes(engine))
445+
const isSocial = socialNetworks.some((network) =>
446+
hostname.includes(network),
447+
)
448+
449+
if (isSearch) {
450+
categories.search += count
451+
} else if (isSocial) {
452+
categories.social += count
453+
} else {
454+
categories.other += count
455+
}
456+
457+
const existing = details.find((d) => d.source === hostname)
458+
if (existing) {
459+
existing.count += count
460+
} else {
461+
details.push({ source: hostname, count })
462+
}
463+
}
464+
465+
return {
466+
categories: [
467+
{ name: '直接访问', value: categories.direct },
468+
{ name: '搜索引擎', value: categories.search },
469+
{ name: '社交媒体', value: categories.social },
470+
{ name: '其他来源', value: categories.other },
471+
].filter((c) => c.value > 0),
472+
details: details.sort((a, b) => b.count - a.count).slice(0, 10),
473+
}
474+
}
294475
}

scripts/seed-analyze-data.mjs

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
#!/usr/bin/env node
2+
/**
3+
* 向 Analyze 表插入 500 条测试数据
4+
* 运行: node scripts/seed-analyze-data.mjs
5+
*/
6+
7+
import { MongoClient } from 'mongodb'
8+
9+
const MONGO_URI = process.env.MONGO_CONNECTION || 'mongodb://localhost:27017/mx-space'
10+
const COLLECTION_NAME = 'analyzes'
11+
12+
// 模拟数据
13+
const browsers = [
14+
{ name: 'Chrome', version: '120.0.0' },
15+
{ name: 'Firefox', version: '121.0' },
16+
{ name: 'Safari', version: '17.0' },
17+
{ name: 'Edge', version: '120.0.0' },
18+
{ name: 'Opera', version: '105.0' },
19+
]
20+
21+
const osList = [
22+
{ name: 'Windows', version: '10' },
23+
{ name: 'Windows', version: '11' },
24+
{ name: 'macOS', version: '14.0' },
25+
{ name: 'Linux', version: '' },
26+
{ name: 'iOS', version: '17.0' },
27+
{ name: 'Android', version: '14' },
28+
]
29+
30+
const devices = [
31+
{ type: 'desktop', vendor: '', model: '' },
32+
{ type: 'desktop', vendor: '', model: '' },
33+
{ type: 'desktop', vendor: '', model: '' },
34+
{ type: 'mobile', vendor: 'Apple', model: 'iPhone' },
35+
{ type: 'mobile', vendor: 'Samsung', model: 'Galaxy' },
36+
{ type: 'tablet', vendor: 'Apple', model: 'iPad' },
37+
]
38+
39+
const paths = [
40+
'/posts/hello-world',
41+
'/posts/typescript-tips',
42+
'/posts/vue-best-practices',
43+
'/notes/1',
44+
'/notes/2',
45+
'/notes/3',
46+
'/pages/about',
47+
'/pages/links',
48+
'/',
49+
'/feed',
50+
'/sitemap.xml',
51+
]
52+
53+
const referers = [
54+
'', // 直接访问
55+
'',
56+
'',
57+
'https://www.google.com/search?q=blog',
58+
'https://www.google.com/search?q=vue',
59+
'https://www.baidu.com/s?wd=blog',
60+
'https://www.bing.com/search?q=typescript',
61+
'https://twitter.com/someuser/status/123',
62+
'https://x.com/someuser/status/456',
63+
'https://weibo.com/detail/123456',
64+
'https://www.zhihu.com/question/123456',
65+
'https://github.com/user/repo',
66+
'https://reddit.com/r/programming',
67+
'https://t.me/channel/123',
68+
]
69+
70+
const countries = ['CN', 'US', 'JP', 'KR', 'GB', 'DE', 'FR', 'SG', 'HK', 'TW', null]
71+
72+
function randomItem(arr) {
73+
return arr[Math.floor(Math.random() * arr.length)]
74+
}
75+
76+
function randomIP() {
77+
return `${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`
78+
}
79+
80+
function generateRecord(timestamp) {
81+
const browser = randomItem(browsers)
82+
const os = randomItem(osList)
83+
const device = randomItem(devices)
84+
85+
return {
86+
ip: randomIP(),
87+
ua: {
88+
browser: { name: browser.name, version: browser.version, major: browser.version.split('.')[0] },
89+
os: { name: os.name, version: os.version },
90+
device: { type: device.type, vendor: device.vendor, model: device.model },
91+
engine: { name: 'Blink', version: '120.0.0' },
92+
cpu: { architecture: 'amd64' },
93+
},
94+
path: randomItem(paths),
95+
referer: randomItem(referers),
96+
country: randomItem(countries),
97+
timestamp: new Date(timestamp),
98+
}
99+
}
100+
101+
async function main() {
102+
console.log('Connecting to MongoDB...')
103+
const client = new MongoClient(MONGO_URI)
104+
105+
try {
106+
await client.connect()
107+
console.log('Connected!')
108+
109+
const db = client.db()
110+
const collection = db.collection(COLLECTION_NAME)
111+
112+
// 生成过去 7 天的数据
113+
const now = Date.now()
114+
const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000
115+
116+
const records = []
117+
for (let i = 0; i < 500; i++) {
118+
const timestamp = sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
119+
records.push(generateRecord(timestamp))
120+
}
121+
122+
console.log(`Inserting ${records.length} records...`)
123+
const result = await collection.insertMany(records)
124+
console.log(`Inserted ${result.insertedCount} records successfully!`)
125+
126+
// 显示统计信息
127+
const stats = await collection.aggregate([
128+
{
129+
$facet: {
130+
total: [{ $count: 'count' }],
131+
byDevice: [
132+
{ $group: { _id: '$ua.device.type', count: { $sum: 1 } } },
133+
{ $sort: { count: -1 } },
134+
],
135+
byBrowser: [
136+
{ $group: { _id: '$ua.browser.name', count: { $sum: 1 } } },
137+
{ $sort: { count: -1 } },
138+
],
139+
},
140+
},
141+
]).toArray()
142+
143+
console.log('\n--- Statistics ---')
144+
console.log('Total records:', stats[0].total[0]?.count || 0)
145+
console.log('By device:', stats[0].byDevice)
146+
console.log('By browser:', stats[0].byBrowser)
147+
148+
} catch (err) {
149+
console.error('Error:', err)
150+
process.exit(1)
151+
} finally {
152+
await client.close()
153+
console.log('\nDone!')
154+
}
155+
}
156+
157+
main()

0 commit comments

Comments
 (0)