refactor: restructure project into 5 core modules
Organize project by 5 core modules based on requirement docs. Co-authored-by: null <4804959+fnvtk@users.noreply.github.com>
This commit is contained in:
BIN
lib/.DS_Store
vendored
BIN
lib/.DS_Store
vendored
Binary file not shown.
@@ -1,6 +1,4 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
export type UserStatus = '活跃' | '沉睡' | '流失风险'
|
||||
export type UserStatus = "活跃" | "沉睡" | "流失风险"
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
@@ -15,38 +13,196 @@ export interface User {
|
||||
lastActiveAt: string
|
||||
}
|
||||
|
||||
const familyNames = ['张','李','王','赵','刘','陈','杨','黄','周','吴','徐','孙','胡','朱','高','林','何','郭','马','罗']
|
||||
const givenNames = ['伟','芳','娜','敏','静','秀英','丽','强','磊','军','洋','艳','勇','杰','娟','涛','明','超','霞','平','俊','凯','佳','鑫','鹏','晨','倩','颖','梅','慧','雪','宇','涵','宁','璐','龙','震','航','璟','钰']
|
||||
const tagPool = ['高价值','近7日活跃','新客','回流','社群达人','潜在复购','高互动','低客单','私域粉','公众号粉']
|
||||
const statusPool: UserStatus[] = ['活跃','沉睡','流失风险']
|
||||
const familyNames = [
|
||||
"张",
|
||||
"李",
|
||||
"王",
|
||||
"赵",
|
||||
"刘",
|
||||
"陈",
|
||||
"杨",
|
||||
"黄",
|
||||
"周",
|
||||
"吴",
|
||||
"徐",
|
||||
"孙",
|
||||
"胡",
|
||||
"朱",
|
||||
"高",
|
||||
"林",
|
||||
"何",
|
||||
"郭",
|
||||
"马",
|
||||
"罗",
|
||||
]
|
||||
const givenNames = [
|
||||
"伟",
|
||||
"芳",
|
||||
"娜",
|
||||
"敏",
|
||||
"静",
|
||||
"秀英",
|
||||
"丽",
|
||||
"强",
|
||||
"磊",
|
||||
"军",
|
||||
"洋",
|
||||
"艳",
|
||||
"勇",
|
||||
"杰",
|
||||
"娟",
|
||||
"涛",
|
||||
"明",
|
||||
"超",
|
||||
"霞",
|
||||
"平",
|
||||
"俊",
|
||||
"凯",
|
||||
"佳",
|
||||
"鑫",
|
||||
"鹏",
|
||||
"晨",
|
||||
"倩",
|
||||
"颖",
|
||||
"梅",
|
||||
"慧",
|
||||
"雪",
|
||||
"宇",
|
||||
"涵",
|
||||
"宁",
|
||||
"璐",
|
||||
"龙",
|
||||
"震",
|
||||
"航",
|
||||
"璟",
|
||||
"钰",
|
||||
]
|
||||
const tagPool = [
|
||||
"高价值",
|
||||
"近7日活跃",
|
||||
"新客",
|
||||
"回流",
|
||||
"社群达人",
|
||||
"潜在复购",
|
||||
"高互动",
|
||||
"低客单",
|
||||
"私域粉",
|
||||
"公众号粉",
|
||||
]
|
||||
const statusPool: UserStatus[] = ["活跃", "沉睡", "流失风险"]
|
||||
|
||||
const avatars = [
|
||||
'/user-avatar-zhangsan.png',
|
||||
'/user-avatar-lisi.png',
|
||||
'/avatar-wanglei.png',
|
||||
'/generic-user-avatar.png',
|
||||
'/wechat-avatar-1.png',
|
||||
'/wechat-avatar-2.png',
|
||||
'/wechat-avatar-3.png',
|
||||
"/user-avatar-zhangsan.png",
|
||||
"/user-avatar-lisi.png",
|
||||
"/avatar-wanglei.png",
|
||||
"/generic-user-avatar.png",
|
||||
"/wechat-avatar-1.png",
|
||||
"/wechat-avatar-2.png",
|
||||
"/wechat-avatar-3.png",
|
||||
]
|
||||
|
||||
const rand = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min
|
||||
const pick = <T,>(arr: T[]) => arr[rand(0, arr.length - 1)]
|
||||
|
||||
function toPinyinLike(name: string) {
|
||||
const map: Record<string,string> = { '张':'zhang','李':'li','王':'wang','赵':'zhao','刘':'liu','陈':'chen','杨':'yang','黄':'huang','周':'zhou','吴':'wu','徐':'xu','孙':'sun','胡':'hu','朱':'zhu','高':'gao','林':'lin','何':'he','郭':'guo','马':'ma','罗':'luo' }
|
||||
return name.split('').map(c => map[c] ?? 'u').join('')
|
||||
const map: Record<string, string> = {
|
||||
张: "zhang",
|
||||
李: "li",
|
||||
王: "wang",
|
||||
赵: "zhao",
|
||||
刘: "liu",
|
||||
陈: "chen",
|
||||
杨: "yang",
|
||||
黄: "huang",
|
||||
周: "zhou",
|
||||
吴: "wu",
|
||||
徐: "xu",
|
||||
孙: "sun",
|
||||
胡: "hu",
|
||||
朱: "zhu",
|
||||
高: "gao",
|
||||
林: "lin",
|
||||
何: "he",
|
||||
郭: "guo",
|
||||
马: "ma",
|
||||
罗: "luo",
|
||||
伟: "wei",
|
||||
芳: "fang",
|
||||
娜: "na",
|
||||
敏: "min",
|
||||
静: "jing",
|
||||
秀英: "xiuying",
|
||||
丽: "li",
|
||||
强: "qiang",
|
||||
磊: "lei",
|
||||
军: "jun",
|
||||
洋: "yang",
|
||||
艳: "yan",
|
||||
勇: "yong",
|
||||
杰: "jie",
|
||||
娟: "juan",
|
||||
涛: "tao",
|
||||
明: "ming",
|
||||
超: "chao",
|
||||
霞: "xia",
|
||||
平: "ping",
|
||||
俊: "jun",
|
||||
凯: "kai",
|
||||
佳: "jia",
|
||||
鑫: "xin",
|
||||
鹏: "peng",
|
||||
晨: "chen",
|
||||
倩: "qian",
|
||||
颖: "ying",
|
||||
梅: "mei",
|
||||
慧: "hui",
|
||||
雪: "xue",
|
||||
宇: "yu",
|
||||
涵: "han",
|
||||
宁: "ning",
|
||||
璐: "lu",
|
||||
龙: "long",
|
||||
震: "zhen",
|
||||
航: "hang",
|
||||
璟: "jing",
|
||||
钰: "yu",
|
||||
}
|
||||
return name
|
||||
.split("")
|
||||
.map((c) => map[c] ?? "u")
|
||||
.join("")
|
||||
}
|
||||
|
||||
function randomPhone() {
|
||||
const prefixes = ['139','138','137','136','135','188','187','186','185','184','183','182','159','158','157','156','155']
|
||||
return `${pick(prefixes)}${rand(1000,9999)}${rand(1000,9999)}`
|
||||
const prefixes = [
|
||||
"139",
|
||||
"138",
|
||||
"137",
|
||||
"136",
|
||||
"135",
|
||||
"188",
|
||||
"187",
|
||||
"186",
|
||||
"185",
|
||||
"184",
|
||||
"183",
|
||||
"182",
|
||||
"159",
|
||||
"158",
|
||||
"157",
|
||||
"156",
|
||||
"155",
|
||||
]
|
||||
return `${pick(prefixes)}${rand(1000, 9999)}${rand(1000, 9999)}`
|
||||
}
|
||||
|
||||
function randomTags() {
|
||||
const count = rand(2,4)
|
||||
const count = rand(2, 4)
|
||||
const s = new Set<string>()
|
||||
while (s.size < count) s.add(pick(tagPool))
|
||||
return Array.from(s)
|
||||
}
|
||||
|
||||
function timeNearNow(daysSpan = 90) {
|
||||
const now = Date.now()
|
||||
const offset = rand(0, daysSpan * 86400000)
|
||||
@@ -58,11 +214,11 @@ let cache: User[] | null = null
|
||||
function seed(n = 120) {
|
||||
const list: User[] = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
const name = `${pick(familyNames)}${pick(givenNames)}${Math.random() < 0.2 ? pick(givenNames) : ''}`
|
||||
const email = `${toPinyinLike(name)}${rand(1,99)}@example.com`
|
||||
const name = `${pick(familyNames)}${pick(givenNames)}${Math.random() < 0.2 ? pick(givenNames) : ""}`
|
||||
const email = `${toPinyinLike(name)}${rand(1, 99)}@example.com`
|
||||
const phone = randomPhone()
|
||||
list.push({
|
||||
id: randomUUID(),
|
||||
id: `user_${Date.now()}_${i}`,
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
@@ -98,24 +254,25 @@ export function queryUsers(params: QueryParams) {
|
||||
|
||||
if (q && q.trim()) {
|
||||
const s = q.trim().toLowerCase()
|
||||
list = list.filter(u =>
|
||||
u.name.toLowerCase().includes(s) ||
|
||||
u.email.toLowerCase().includes(s) ||
|
||||
u.phone.includes(s) ||
|
||||
u.tags.some(t => t.toLowerCase().includes(s)),
|
||||
list = list.filter(
|
||||
(u) =>
|
||||
u.name.toLowerCase().includes(s) ||
|
||||
u.email.toLowerCase().includes(s) ||
|
||||
u.phone.includes(s) ||
|
||||
u.tags.some((t) => t.toLowerCase().includes(s)),
|
||||
)
|
||||
}
|
||||
|
||||
if (tags?.length) {
|
||||
list = list.filter(u => tags.every(t => u.tags.includes(t)))
|
||||
list = list.filter((u) => tags.every((t) => u.tags.includes(t)))
|
||||
}
|
||||
|
||||
if (status?.length) {
|
||||
const st = new Set(status)
|
||||
list = list.filter(u => st.has(u.status))
|
||||
list = list.filter((u) => st.has(u.status))
|
||||
}
|
||||
|
||||
list = list.filter(u => u.rfmScore >= rfmMin && u.rfmScore <= rfmMax)
|
||||
list = list.filter((u) => u.rfmScore >= rfmMin && u.rfmScore <= rfmMax)
|
||||
|
||||
const total = list.length
|
||||
const start = (page - 1) * pageSize
|
||||
@@ -123,7 +280,7 @@ export function queryUsers(params: QueryParams) {
|
||||
const data = list.slice(start, end)
|
||||
|
||||
// 列表行仅返回必要字段
|
||||
const thin = data.map(u => ({
|
||||
const thin = data.map((u) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
@@ -143,7 +300,7 @@ export function addUser(input: Partial<User>) {
|
||||
const email = input.email ?? `${toPinyinLike(name)}@example.com`
|
||||
const phone = input.phone ?? randomPhone()
|
||||
const u: User = {
|
||||
id: randomUUID(),
|
||||
id: `user_${Date.now()}_${rand(1000, 9999)}`,
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
@@ -159,5 +316,5 @@ export function addUser(input: Partial<User>) {
|
||||
}
|
||||
|
||||
export function getUserById(id: string) {
|
||||
return getUsersStore().find(u => u.id === id) ?? null
|
||||
return getUsersStore().find((u) => u.id === id) ?? null
|
||||
}
|
||||
|
||||
515
lib/mongodb.ts
515
lib/mongodb.ts
@@ -1,515 +0,0 @@
|
||||
/**
|
||||
* 神射手 MongoDB 连接器
|
||||
* 连接到卡若AI神射手后端数据库
|
||||
*
|
||||
* 数据库概览:
|
||||
* - KR.用户估值: 1436万条 - 统一画像、RFM评分
|
||||
* - KR_腾讯.QQ+手机: 7.05亿条 - QQ↔手机关联
|
||||
* - KR_微博.微博uid+手机: 2.17亿条 - 微博UID↔手机
|
||||
* - KR_京东.jd_com: 1.42亿条 - 京东用户
|
||||
* - KR_存客宝.用户资产统一视图: 21.6万条 - 存客宝用户
|
||||
*/
|
||||
|
||||
import { MongoClient, Db, Collection, Document } from 'mongodb'
|
||||
|
||||
// MongoDB 连接配置
|
||||
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://admin:admin123@localhost:27017/?authSource=admin'
|
||||
|
||||
// 数据库名称常量
|
||||
export const DB_NAMES = {
|
||||
KR: 'KR',
|
||||
KR_TENCENT: 'KR_腾讯',
|
||||
KR_WEIBO: 'KR_微博',
|
||||
KR_JD: 'KR_京东',
|
||||
KR_HOTEL: 'KR_酒店',
|
||||
KR_SF: 'KR_顺丰',
|
||||
KR_CKB: 'KR_存客宝',
|
||||
} as const
|
||||
|
||||
// 集合名称常量
|
||||
export const COLLECTION_NAMES = {
|
||||
USER_VALUATION: '用户估值',
|
||||
QQ_PHONE: 'QQ+手机',
|
||||
WEIBO_UID_PHONE: '微博uid+手机',
|
||||
JD_COM: 'jd_com',
|
||||
HOTEL_RECORDS: '酒店开房记录_2013年8月_2000万内',
|
||||
SF_EXPRESS: '顺丰快递数据',
|
||||
CKB_UNIFIED: '用户资产统一视图',
|
||||
} as const
|
||||
|
||||
// 全局客户端实例(连接池复用)
|
||||
let client: MongoClient | null = null
|
||||
let clientPromise: Promise<MongoClient> | null = null
|
||||
|
||||
/**
|
||||
* 获取 MongoDB 客户端(单例模式)
|
||||
*/
|
||||
export async function getMongoClient(): Promise<MongoClient> {
|
||||
if (client) {
|
||||
return client
|
||||
}
|
||||
|
||||
if (!clientPromise) {
|
||||
clientPromise = MongoClient.connect(MONGODB_URI, {
|
||||
maxPoolSize: 10,
|
||||
minPoolSize: 2,
|
||||
maxIdleTimeMS: 60000,
|
||||
serverSelectionTimeoutMS: 5000,
|
||||
connectTimeoutMS: 10000,
|
||||
})
|
||||
}
|
||||
|
||||
client = await clientPromise
|
||||
return client
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定数据库
|
||||
*/
|
||||
export async function getDatabase(dbName: string): Promise<Db> {
|
||||
const client = await getMongoClient()
|
||||
return client.db(dbName)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定集合
|
||||
*/
|
||||
export async function getCollection<T extends Document = Document>(
|
||||
dbName: string,
|
||||
collectionName: string
|
||||
): Promise<Collection<T>> {
|
||||
const db = await getDatabase(dbName)
|
||||
return db.collection<T>(collectionName)
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户估值文档类型(匹配实际数据库结构)
|
||||
*/
|
||||
export interface UserValuationDoc {
|
||||
_id?: any
|
||||
user_key?: string
|
||||
phone?: string
|
||||
phone_masked?: string
|
||||
name?: string
|
||||
email?: string
|
||||
email_masked?: string
|
||||
id_number_masked?: string
|
||||
gender?: string
|
||||
age_range?: string
|
||||
province?: string
|
||||
city?: string
|
||||
address?: string
|
||||
// 评分字段
|
||||
user_evaluation_score?: number // 用户估值分
|
||||
rfm_composite_score?: number
|
||||
rfm_r_score?: number
|
||||
rfm_f_score?: number
|
||||
rfm_m_score?: number
|
||||
user_level?: string
|
||||
// 来源和标签
|
||||
source_collections?: string[]
|
||||
source_channels?: string[]
|
||||
first_channel?: string
|
||||
merge_evidence?: string[]
|
||||
tags?: string[]
|
||||
// 数据质量
|
||||
data_quality?: {
|
||||
completeness?: number
|
||||
source_count?: number
|
||||
}
|
||||
// 时间
|
||||
computed_at?: Date
|
||||
created_at?: Date
|
||||
updated_at?: Date
|
||||
version?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* QQ+手机文档类型
|
||||
*/
|
||||
export interface QQPhoneDoc {
|
||||
_id?: any
|
||||
qq?: string
|
||||
phone?: string
|
||||
省份?: string
|
||||
地区?: string
|
||||
运营商?: string
|
||||
手机号评分?: number
|
||||
QQ号评分?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 存客宝用户资产文档类型
|
||||
*/
|
||||
export interface CKBUserAssetDoc {
|
||||
_id?: any
|
||||
user_key?: string
|
||||
phone?: string
|
||||
phone_masked?: string
|
||||
core_profile?: {
|
||||
name?: string
|
||||
province?: string
|
||||
city?: string
|
||||
address?: string
|
||||
email?: string
|
||||
}
|
||||
social_accounts?: {
|
||||
wechat?: string
|
||||
qq?: string
|
||||
}
|
||||
rfm_scores?: {
|
||||
composite_score?: number
|
||||
user_level?: string
|
||||
R?: number
|
||||
F?: number
|
||||
M?: number
|
||||
}
|
||||
user_evaluation_score?: number
|
||||
unified_tags?: string[]
|
||||
traffic_pool?: {
|
||||
pool_id?: string
|
||||
pool_name?: string
|
||||
}
|
||||
source_channels?: string[]
|
||||
}
|
||||
|
||||
// ========== 快捷查询函数 ==========
|
||||
|
||||
/**
|
||||
* 标准化手机号格式
|
||||
* 支持: 13800138000, +8613800138000, 8613800138000
|
||||
*/
|
||||
function normalizePhone(phone: string): string[] {
|
||||
const cleaned = phone.replace(/\D/g, '')
|
||||
const variants: string[] = []
|
||||
|
||||
if (cleaned.startsWith('86') && cleaned.length === 13) {
|
||||
// 8613800138000 -> 多种格式
|
||||
const base = cleaned.slice(2)
|
||||
variants.push(base, `+86${base}`, `86${base}`, cleaned)
|
||||
} else if (cleaned.length === 11 && cleaned.startsWith('1')) {
|
||||
// 13800138000 -> 多种格式
|
||||
variants.push(cleaned, `+86${cleaned}`, `86${cleaned}`)
|
||||
} else {
|
||||
variants.push(phone, cleaned)
|
||||
}
|
||||
|
||||
return [...new Set(variants)]
|
||||
}
|
||||
|
||||
/**
|
||||
* 按手机号查询用户估值
|
||||
*/
|
||||
export async function queryUserByPhone(phone: string): Promise<UserValuationDoc | null> {
|
||||
const coll = await getCollection<UserValuationDoc>(DB_NAMES.KR, COLLECTION_NAMES.USER_VALUATION)
|
||||
|
||||
// 尝试多种手机号格式
|
||||
const phoneVariants = normalizePhone(phone)
|
||||
|
||||
return coll.findOne({
|
||||
$or: phoneVariants.map(p => ({ phone: p }))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 按手机号查询QQ
|
||||
*/
|
||||
export async function queryQQByPhone(phone: string): Promise<QQPhoneDoc | null> {
|
||||
const coll = await getCollection<QQPhoneDoc>(DB_NAMES.KR_TENCENT, COLLECTION_NAMES.QQ_PHONE)
|
||||
return coll.findOne({ phone })
|
||||
}
|
||||
|
||||
/**
|
||||
* 按QQ号查询手机
|
||||
*/
|
||||
export async function queryPhoneByQQ(qq: string): Promise<QQPhoneDoc | null> {
|
||||
const coll = await getCollection<QQPhoneDoc>(DB_NAMES.KR_TENCENT, COLLECTION_NAMES.QQ_PHONE)
|
||||
return coll.findOne({ qq })
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨库完整画像查询(并行)
|
||||
*/
|
||||
export async function queryFullProfile(phone: string): Promise<{
|
||||
valuation: UserValuationDoc | null
|
||||
qq: QQPhoneDoc | null
|
||||
ckb: CKBUserAssetDoc | null
|
||||
}> {
|
||||
const [valuation, qq, ckb] = await Promise.all([
|
||||
queryUserByPhone(phone),
|
||||
queryQQByPhone(phone),
|
||||
(async () => {
|
||||
const coll = await getCollection<CKBUserAssetDoc>(DB_NAMES.KR_CKB, COLLECTION_NAMES.CKB_UNIFIED)
|
||||
return coll.findOne({ phone })
|
||||
})()
|
||||
])
|
||||
|
||||
return { valuation, qq, ckb }
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户列表查询(分页)
|
||||
*/
|
||||
export async function queryUserList(options: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
userLevel?: string
|
||||
minRfm?: number
|
||||
maxRfm?: number
|
||||
search?: string
|
||||
tags?: string[]
|
||||
}): Promise<{ data: UserValuationDoc[], total: number }> {
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
userLevel,
|
||||
minRfm,
|
||||
maxRfm,
|
||||
search,
|
||||
tags
|
||||
} = options
|
||||
|
||||
const coll = await getCollection<UserValuationDoc>(DB_NAMES.KR, COLLECTION_NAMES.USER_VALUATION)
|
||||
|
||||
// 构建查询条件
|
||||
const query: any = {}
|
||||
|
||||
if (userLevel) {
|
||||
query.user_level = userLevel
|
||||
}
|
||||
|
||||
if (minRfm !== undefined || maxRfm !== undefined) {
|
||||
query.rfm_composite_score = {}
|
||||
if (minRfm !== undefined) query.rfm_composite_score.$gte = minRfm
|
||||
if (maxRfm !== undefined) query.rfm_composite_score.$lte = maxRfm
|
||||
}
|
||||
|
||||
if (search) {
|
||||
query.$or = [
|
||||
{ phone: { $regex: search, $options: 'i' } },
|
||||
{ name: { $regex: search, $options: 'i' } },
|
||||
{ phone_masked: { $regex: search, $options: 'i' } }
|
||||
]
|
||||
}
|
||||
|
||||
if (tags && tags.length > 0) {
|
||||
query.tags = { $in: tags }
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
coll.find(query)
|
||||
.skip((page - 1) * pageSize)
|
||||
.limit(pageSize)
|
||||
.sort({ rfm_composite_score: -1 })
|
||||
.toArray(),
|
||||
coll.countDocuments(query)
|
||||
])
|
||||
|
||||
return { data, total }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库统计信息
|
||||
*/
|
||||
export async function getDatabaseStats(): Promise<{
|
||||
connected: boolean
|
||||
databases: { name: string, collections: number, documents: number, sizeGB: number }[]
|
||||
totalDocuments: number
|
||||
totalSizeGB: number
|
||||
}> {
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
const adminDb = client.db('admin')
|
||||
|
||||
// 获取数据库列表
|
||||
const dbs = await adminDb.admin().listDatabases()
|
||||
|
||||
const databases = []
|
||||
let totalDocuments = 0
|
||||
let totalSizeGB = 0
|
||||
|
||||
for (const db of dbs.databases) {
|
||||
if (db.name.startsWith('KR')) {
|
||||
const database = client.db(db.name)
|
||||
const collections = await database.listCollections().toArray()
|
||||
|
||||
let dbDocCount = 0
|
||||
for (const coll of collections) {
|
||||
try {
|
||||
const count = await database.collection(coll.name).estimatedDocumentCount()
|
||||
dbDocCount += count
|
||||
} catch (e) {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
const sizeGB = (db.sizeOnDisk || 0) / (1024 * 1024 * 1024)
|
||||
|
||||
databases.push({
|
||||
name: db.name,
|
||||
collections: collections.length,
|
||||
documents: dbDocCount,
|
||||
sizeGB: Math.round(sizeGB * 100) / 100
|
||||
})
|
||||
|
||||
totalDocuments += dbDocCount
|
||||
totalSizeGB += sizeGB
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
connected: true,
|
||||
databases,
|
||||
totalDocuments,
|
||||
totalSizeGB: Math.round(totalSizeGB * 100) / 100
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('MongoDB stats error:', error)
|
||||
return {
|
||||
connected: false,
|
||||
databases: [],
|
||||
totalDocuments: 0,
|
||||
totalSizeGB: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RFM 用户分组统计
|
||||
*/
|
||||
export async function getRFMGroupSummary(): Promise<{
|
||||
levels: { level: string, count: number, percentage: number }[]
|
||||
total: number
|
||||
}> {
|
||||
const coll = await getCollection<UserValuationDoc>(DB_NAMES.KR, COLLECTION_NAMES.USER_VALUATION)
|
||||
|
||||
const pipeline = [
|
||||
{
|
||||
$group: {
|
||||
_id: '$user_level',
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
},
|
||||
{
|
||||
$sort: { count: -1 }
|
||||
}
|
||||
]
|
||||
|
||||
const result = await coll.aggregate(pipeline).toArray()
|
||||
const total = result.reduce((sum, item) => sum + item.count, 0)
|
||||
|
||||
const levels = result.map(item => ({
|
||||
level: item._id || '未分类',
|
||||
count: item.count,
|
||||
percentage: Math.round((item.count / total) * 10000) / 100
|
||||
}))
|
||||
|
||||
return { levels, total }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有标签
|
||||
*/
|
||||
export async function getDistinctTags(): Promise<string[]> {
|
||||
const coll = await getCollection<UserValuationDoc>(DB_NAMES.KR, COLLECTION_NAMES.USER_VALUATION)
|
||||
const tags = await coll.distinct('tags')
|
||||
return tags.filter(Boolean) as string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能搜索 - 跨库查询
|
||||
*/
|
||||
export async function intelligentSearch(query: string, options: {
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<{
|
||||
users: UserValuationDoc[]
|
||||
total: number
|
||||
queryType: 'phone' | 'name' | 'qq' | 'email' | 'general'
|
||||
}> {
|
||||
const { limit = 20, offset = 0 } = options
|
||||
|
||||
// 识别查询类型
|
||||
let queryType: 'phone' | 'name' | 'qq' | 'email' | 'general' = 'general'
|
||||
const trimmed = query.trim()
|
||||
|
||||
if (/^1[3-9]\d{9}$/.test(trimmed)) {
|
||||
queryType = 'phone'
|
||||
} else if (/^\d{5,12}$/.test(trimmed)) {
|
||||
queryType = 'qq'
|
||||
} else if (/@/.test(trimmed)) {
|
||||
queryType = 'email'
|
||||
} else if (/^[\u4e00-\u9fa5]{2,4}$/.test(trimmed)) {
|
||||
queryType = 'name'
|
||||
}
|
||||
|
||||
const coll = await getCollection<UserValuationDoc>(DB_NAMES.KR, COLLECTION_NAMES.USER_VALUATION)
|
||||
|
||||
let filter: any = {}
|
||||
|
||||
switch (queryType) {
|
||||
case 'phone':
|
||||
// 使用多种格式匹配
|
||||
const phoneVariants = normalizePhone(trimmed)
|
||||
filter = { $or: phoneVariants.map(p => ({ phone: p })) }
|
||||
break
|
||||
case 'qq':
|
||||
// 先查QQ找手机,再查用户估值
|
||||
const qqResult = await queryPhoneByQQ(trimmed)
|
||||
if (qqResult?.phone) {
|
||||
const qqPhoneVariants = normalizePhone(qqResult.phone)
|
||||
filter = { $or: qqPhoneVariants.map(p => ({ phone: p })) }
|
||||
} else {
|
||||
filter = { _id: null } // 查不到
|
||||
}
|
||||
break
|
||||
case 'email':
|
||||
filter = { email: { $regex: trimmed, $options: 'i' } }
|
||||
break
|
||||
case 'name':
|
||||
filter = { name: trimmed }
|
||||
break
|
||||
default:
|
||||
filter = {
|
||||
$or: [
|
||||
{ phone: { $regex: trimmed, $options: 'i' } },
|
||||
{ name: { $regex: trimmed, $options: 'i' } },
|
||||
{ phone_masked: { $regex: trimmed, $options: 'i' } }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
coll.find(filter).skip(offset).limit(limit).toArray(),
|
||||
coll.countDocuments(filter)
|
||||
])
|
||||
|
||||
return { users, total, queryType }
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康检查
|
||||
*/
|
||||
export async function healthCheck(): Promise<{
|
||||
mongodb: boolean
|
||||
latencyMs: number
|
||||
error?: string
|
||||
}> {
|
||||
const start = Date.now()
|
||||
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
await client.db('admin').command({ ping: 1 })
|
||||
|
||||
return {
|
||||
mongodb: true,
|
||||
latencyMs: Date.now() - start
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
mongodb: false,
|
||||
latencyMs: Date.now() - start,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user