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:
v0
2026-01-31 04:18:24 +00:00
parent b4aa620c93
commit 22e725887a
74 changed files with 4219 additions and 9234 deletions

BIN
lib/.DS_Store vendored

Binary file not shown.

View File

@@ -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
}

View File

@@ -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'
}
}
}