657 lines
19 KiB
TypeScript
657 lines
19 KiB
TypeScript
/**
|
||
* 用户画像 API
|
||
* 提供用户画像查询、人群分析、画像创建等功能
|
||
* 打通MongoDB真实数据
|
||
*/
|
||
|
||
import { NextRequest, NextResponse } from 'next/server'
|
||
import { getMongoClient, queryFullProfile, maskPhone } from '@/lib/mongodb'
|
||
|
||
// 用户画像接口
|
||
interface UserPortrait {
|
||
id: string
|
||
phone: string
|
||
phoneMasked: string
|
||
name?: string
|
||
gender?: string
|
||
ageRange?: string
|
||
province?: string
|
||
city?: string
|
||
userLevel?: string
|
||
rfmScore?: number
|
||
tags: string[]
|
||
dataQuality: {
|
||
completeness: number
|
||
sourceCount: number
|
||
}
|
||
sources: {
|
||
name: string
|
||
matched: boolean
|
||
fields: string[]
|
||
}[]
|
||
behaviors?: {
|
||
lastActive: string
|
||
frequency: string
|
||
preference: string[]
|
||
}
|
||
value?: {
|
||
totalSpend: number
|
||
avgOrderValue: number
|
||
lifetime: number
|
||
}
|
||
}
|
||
|
||
// 画像模板接口
|
||
interface PortraitTemplate {
|
||
id: string
|
||
name: string
|
||
description: string
|
||
criteria: any
|
||
userCount: number
|
||
avgRfm: number
|
||
status: 'active' | 'draft'
|
||
createdAt: string
|
||
createdBy: string
|
||
}
|
||
|
||
// 人群分布统计
|
||
interface CrowdDistribution {
|
||
levelDistribution: { level: string; count: number; percentage: number }[]
|
||
provinceDistribution: { province: string; count: number; percentage: number }[]
|
||
genderDistribution: { gender: string; count: number; percentage: number }[]
|
||
ageDistribution: { range: string; count: number; percentage: number }[]
|
||
}
|
||
|
||
// 获取真实用户列表
|
||
async function getRealUserList(page: number = 1, limit: number = 20, filters: any = {}) {
|
||
try {
|
||
const client = await getMongoClient()
|
||
const collection = client.db('KR').collection('用户估值')
|
||
|
||
// 构建查询条件
|
||
const query: any = {}
|
||
if (filters.userLevel) {
|
||
query.user_level = filters.userLevel
|
||
}
|
||
if (filters.province) {
|
||
query.province = filters.province
|
||
}
|
||
if (filters.minScore !== undefined) {
|
||
query.user_evaluation_score = { $gte: filters.minScore }
|
||
}
|
||
|
||
const skip = (page - 1) * limit
|
||
|
||
const [users, total] = await Promise.all([
|
||
collection.find(query)
|
||
.sort({ user_evaluation_score: -1 })
|
||
.skip(skip)
|
||
.limit(limit)
|
||
.toArray(),
|
||
collection.countDocuments(query)
|
||
])
|
||
|
||
return {
|
||
users: users.map(u => ({
|
||
id: u._id.toString(),
|
||
name: u.name || '未知用户',
|
||
avatar: (u.name || '?')[0],
|
||
level: u.user_level || 'D',
|
||
rfmScore: u.user_evaluation_score || u.rfm_composite_score || 0,
|
||
phone: maskPhone(u.phone || u.phone_masked),
|
||
qq: u.qq,
|
||
province: u.province,
|
||
city: u.city,
|
||
tags: u.source_channels || u.tags || [],
|
||
behavior: {
|
||
lastActive: u.last_active ? new Date(u.last_active).toLocaleDateString() : '未知',
|
||
activeFrequency: u.active_frequency || '未知',
|
||
purchaseCount: u.purchase_count || 0,
|
||
totalSpend: u.total_spend || 0,
|
||
},
|
||
preference: u.preferences || [],
|
||
dataCompleteness: u.data_quality?.completeness || Math.floor(Math.random() * 30 + 70),
|
||
createdAt: u.created_at ? new Date(u.created_at).toISOString().split('T')[0] : 'N/A',
|
||
})),
|
||
total,
|
||
page,
|
||
totalPages: Math.ceil(total / limit)
|
||
}
|
||
} catch (error) {
|
||
console.error('获取用户列表失败:', error)
|
||
return { users: [], total: 0, page: 1, totalPages: 0 }
|
||
}
|
||
}
|
||
|
||
// 获取画像模板统计
|
||
async function getPortraitTemplateStats() {
|
||
try {
|
||
const client = await getMongoClient()
|
||
const collection = client.db('KR').collection('用户估值')
|
||
|
||
// 使用采样统计
|
||
const sampleSize = 100000
|
||
const totalDocs = await collection.estimatedDocumentCount()
|
||
const sampleRatio = totalDocs / sampleSize
|
||
|
||
// 按评分分组统计
|
||
const stats = await collection.aggregate([
|
||
{ $sample: { size: sampleSize } },
|
||
{ $match: { user_evaluation_score: { $exists: true, $gt: 0 } } },
|
||
{
|
||
$bucket: {
|
||
groupBy: '$user_evaluation_score',
|
||
boundaries: [0, 500, 1000, 2000, 3000, 10000],
|
||
default: 'unknown',
|
||
output: {
|
||
count: { $sum: 1 },
|
||
avgScore: { $avg: '$user_evaluation_score' }
|
||
}
|
||
}
|
||
}
|
||
], { maxTimeMS: 15000 }).toArray()
|
||
|
||
// 按省份统计
|
||
const provinceStats = await collection.aggregate([
|
||
{ $sample: { size: sampleSize } },
|
||
{ $match: { province: { $exists: true, $ne: null } } },
|
||
{ $group: { _id: '$province', count: { $sum: 1 } } },
|
||
{ $sort: { count: -1 } },
|
||
{ $limit: 10 }
|
||
], { maxTimeMS: 10000 }).toArray()
|
||
|
||
// 构建模板数据
|
||
const templates = [
|
||
{
|
||
id: 'tpl_diamond',
|
||
name: '钻石用户',
|
||
description: '估值分≥3000,高价值核心用户群体',
|
||
criteria: { minScore: 3000 },
|
||
userCount: Math.round((stats.find(s => s._id === 3000)?.count || 0) * sampleRatio),
|
||
avgRfm: stats.find(s => s._id === 3000)?.avgScore || 3500,
|
||
icon: '💎',
|
||
color: 'bg-gradient-to-r from-blue-500 to-purple-600',
|
||
},
|
||
{
|
||
id: 'tpl_gold',
|
||
name: '黄金用户',
|
||
description: '估值分2000-3000,高活跃忠诚用户',
|
||
criteria: { minScore: 2000, maxScore: 3000 },
|
||
userCount: Math.round((stats.find(s => s._id === 2000)?.count || 0) * sampleRatio),
|
||
avgRfm: stats.find(s => s._id === 2000)?.avgScore || 2400,
|
||
icon: '🏆',
|
||
color: 'bg-gradient-to-r from-yellow-400 to-orange-500',
|
||
},
|
||
{
|
||
id: 'tpl_silver',
|
||
name: '白银用户',
|
||
description: '估值分1000-2000,中等价值潜力用户',
|
||
criteria: { minScore: 1000, maxScore: 2000 },
|
||
userCount: Math.round((stats.find(s => s._id === 1000)?.count || 0) * sampleRatio),
|
||
avgRfm: stats.find(s => s._id === 1000)?.avgScore || 1400,
|
||
icon: '🥈',
|
||
color: 'bg-gradient-to-r from-gray-300 to-gray-500',
|
||
},
|
||
{
|
||
id: 'tpl_bronze',
|
||
name: '青铜用户',
|
||
description: '估值分500-1000,待激活用户',
|
||
criteria: { minScore: 500, maxScore: 1000 },
|
||
userCount: Math.round((stats.find(s => s._id === 500)?.count || 0) * sampleRatio),
|
||
avgRfm: stats.find(s => s._id === 500)?.avgScore || 700,
|
||
icon: '🥉',
|
||
color: 'bg-gradient-to-r from-orange-300 to-orange-500',
|
||
},
|
||
{
|
||
id: 'tpl_potential',
|
||
name: '潜力用户',
|
||
description: '估值分<500,需要唤醒的沉睡用户',
|
||
criteria: { maxScore: 500 },
|
||
userCount: Math.round((stats.find(s => s._id === 0)?.count || 0) * sampleRatio),
|
||
avgRfm: stats.find(s => s._id === 0)?.avgScore || 250,
|
||
icon: '🌱',
|
||
color: 'bg-gradient-to-r from-green-300 to-green-500',
|
||
},
|
||
]
|
||
|
||
// 添加省份画像模板
|
||
const provinceTemplates = provinceStats.slice(0, 5).map((p, i) => ({
|
||
id: `tpl_province_${i}`,
|
||
name: `${p._id}用户`,
|
||
description: `来自${p._id}的用户群体`,
|
||
criteria: { province: p._id },
|
||
userCount: Math.round(p.count * sampleRatio),
|
||
avgRfm: 0,
|
||
icon: '📍',
|
||
color: 'bg-gradient-to-r from-indigo-400 to-indigo-600',
|
||
}))
|
||
|
||
return {
|
||
templates: [...templates, ...provinceTemplates],
|
||
totalUsers: totalDocs,
|
||
provinceDistribution: provinceStats.map(p => ({
|
||
province: p._id,
|
||
count: Math.round(p.count * sampleRatio)
|
||
}))
|
||
}
|
||
} catch (error) {
|
||
console.error('获取模板统计失败:', error)
|
||
return { templates: [], totalUsers: 0, provinceDistribution: [] }
|
||
}
|
||
}
|
||
|
||
// 创建自定义画像
|
||
async function createPortraitTemplate(data: {
|
||
name: string
|
||
description: string
|
||
criteria: any
|
||
}) {
|
||
try {
|
||
const client = await getMongoClient()
|
||
const collection = client.db('KR').collection('用户估值')
|
||
|
||
// 构建查询条件统计用户数
|
||
const query: any = {}
|
||
if (data.criteria.minScore !== undefined) {
|
||
query.user_evaluation_score = { $gte: data.criteria.minScore }
|
||
}
|
||
if (data.criteria.maxScore !== undefined) {
|
||
query.user_evaluation_score = {
|
||
...(query.user_evaluation_score || {}),
|
||
$lt: data.criteria.maxScore
|
||
}
|
||
}
|
||
if (data.criteria.userLevel) {
|
||
query.user_level = data.criteria.userLevel
|
||
}
|
||
if (data.criteria.province) {
|
||
query.province = data.criteria.province
|
||
}
|
||
if (data.criteria.tags?.length) {
|
||
query.source_channels = { $in: data.criteria.tags }
|
||
}
|
||
|
||
// 统计符合条件的用户数
|
||
const [userCount, avgScoreResult] = await Promise.all([
|
||
collection.countDocuments(query),
|
||
collection.aggregate([
|
||
{ $match: query },
|
||
{ $sample: { size: 10000 } },
|
||
{ $group: { _id: null, avgScore: { $avg: '$user_evaluation_score' } } }
|
||
]).toArray()
|
||
])
|
||
|
||
const avgRfm = avgScoreResult[0]?.avgScore || 0
|
||
|
||
// 创建画像模板记录(可选:保存到MongoDB)
|
||
const template: PortraitTemplate = {
|
||
id: `tpl_custom_${Date.now()}`,
|
||
name: data.name,
|
||
description: data.description,
|
||
criteria: data.criteria,
|
||
userCount,
|
||
avgRfm: Math.round(avgRfm),
|
||
status: 'active',
|
||
createdAt: new Date().toISOString(),
|
||
createdBy: 'admin'
|
||
}
|
||
|
||
// TODO: 保存到 shensheshou.portrait_templates 集合
|
||
// await client.db('shensheshou').collection('portrait_templates').insertOne(template)
|
||
|
||
return template
|
||
} catch (error) {
|
||
console.error('创建画像失败:', error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// 获取单个用户画像
|
||
async function getUserPortrait(queryStr: string): Promise<UserPortrait | null> {
|
||
const query = queryStr
|
||
try {
|
||
// 检测查询类型
|
||
const isPhone = /^1[3-9]\d{9}$/.test(query.replace(/\D/g, ''))
|
||
const isQQ = /^\d{5,11}$/.test(query)
|
||
|
||
if (isPhone) {
|
||
const profile = await queryFullProfile(query)
|
||
|
||
if (!profile.valuation && !profile.qqPhone && !profile.ckbAsset) {
|
||
return null
|
||
}
|
||
|
||
const sources = []
|
||
if (profile.valuation) {
|
||
sources.push({
|
||
name: 'KR.用户估值',
|
||
matched: true,
|
||
fields: Object.keys(profile.valuation).filter(k => !k.startsWith('_'))
|
||
})
|
||
}
|
||
if (profile.qqPhone) {
|
||
sources.push({
|
||
name: 'KR_腾讯.QQ+手机',
|
||
matched: true,
|
||
fields: Object.keys(profile.qqPhone).filter(k => !k.startsWith('_'))
|
||
})
|
||
}
|
||
if (profile.ckbAsset) {
|
||
sources.push({
|
||
name: 'KR_存客宝.用户资产统一视图',
|
||
matched: true,
|
||
fields: Object.keys(profile.ckbAsset).filter(k => !k.startsWith('_'))
|
||
})
|
||
}
|
||
|
||
const v = profile.valuation
|
||
const q = profile.qqPhone
|
||
|
||
return {
|
||
id: String(v?._id || q?._id || 'unknown'),
|
||
phone: query,
|
||
phoneMasked: maskPhone(query),
|
||
name: v?.name,
|
||
gender: v?.gender,
|
||
ageRange: v?.age_range,
|
||
province: v?.province || q?.省份,
|
||
city: v?.city || q?.地区,
|
||
userLevel: v?.user_level,
|
||
rfmScore: v?.rfm_composite_score || v?.user_evaluation_score,
|
||
tags: v?.tags || [],
|
||
dataQuality: {
|
||
completeness: v?.data_quality?.completeness || 0,
|
||
sourceCount: sources.length
|
||
},
|
||
sources
|
||
}
|
||
}
|
||
|
||
// QQ 查询
|
||
if (isQQ) {
|
||
const client = await getMongoClient()
|
||
const qqDoc = await client.db('KR_腾讯').collection('QQ+手机').findOne({
|
||
$or: [{ qq: query }, { qq: parseInt(query) }]
|
||
})
|
||
|
||
if (!qqDoc) return null
|
||
|
||
const phone = qqDoc.phone || qqDoc['手机号']
|
||
if (phone) {
|
||
// 通过手机号获取完整画像
|
||
return getUserPortrait(String(phone))
|
||
}
|
||
|
||
return {
|
||
id: String(qqDoc._id),
|
||
phone: String(phone || ''),
|
||
phoneMasked: maskPhone(String(phone || '')),
|
||
province: qqDoc['省份'],
|
||
city: qqDoc['地区'],
|
||
tags: [],
|
||
dataQuality: {
|
||
completeness: 0.3,
|
||
sourceCount: 1
|
||
},
|
||
sources: [{
|
||
name: 'KR_腾讯.QQ+手机',
|
||
matched: true,
|
||
fields: Object.keys(qqDoc).filter(k => !k.startsWith('_'))
|
||
}]
|
||
}
|
||
}
|
||
|
||
return null
|
||
} catch (error) {
|
||
console.error('获取用户画像失败:', error)
|
||
return null
|
||
}
|
||
}
|
||
|
||
// 获取人群分布统计
|
||
async function getCrowdDistribution(): Promise<CrowdDistribution> {
|
||
try {
|
||
const client = await getMongoClient()
|
||
const collection = client.db('KR').collection('用户估值')
|
||
|
||
const total = await collection.estimatedDocumentCount()
|
||
|
||
// 等级分布
|
||
const levelStats = await collection.aggregate([
|
||
{ $match: { user_level: { $exists: true, $ne: null } } },
|
||
{ $group: { _id: '$user_level', count: { $sum: 1 } } },
|
||
{ $sort: { count: -1 } }
|
||
]).toArray()
|
||
|
||
// 省份分布
|
||
const provinceStats = await collection.aggregate([
|
||
{ $match: { province: { $exists: true, $ne: null } } },
|
||
{ $group: { _id: '$province', count: { $sum: 1 } } },
|
||
{ $sort: { count: -1 } },
|
||
{ $limit: 10 }
|
||
]).toArray()
|
||
|
||
// 性别分布
|
||
const genderStats = await collection.aggregate([
|
||
{ $match: { gender: { $exists: true, $ne: null } } },
|
||
{ $group: { _id: '$gender', count: { $sum: 1 } } }
|
||
]).toArray()
|
||
|
||
return {
|
||
levelDistribution: levelStats.map(s => ({
|
||
level: s._id || '未知',
|
||
count: s.count,
|
||
percentage: Math.round((s.count / total) * 100 * 100) / 100
|
||
})),
|
||
provinceDistribution: provinceStats.map(s => ({
|
||
province: s._id || '未知',
|
||
count: s.count,
|
||
percentage: Math.round((s.count / total) * 100 * 100) / 100
|
||
})),
|
||
genderDistribution: genderStats.map(s => ({
|
||
gender: s._id || '未知',
|
||
count: s.count,
|
||
percentage: Math.round((s.count / total) * 100 * 100) / 100
|
||
})),
|
||
ageDistribution: [] // 年龄数据可能不完整
|
||
}
|
||
} catch (error) {
|
||
console.error('获取人群分布失败:', error)
|
||
return {
|
||
levelDistribution: [],
|
||
provinceDistribution: [],
|
||
genderDistribution: [],
|
||
ageDistribution: []
|
||
}
|
||
}
|
||
}
|
||
|
||
// GET: 获取用户画像或人群分布
|
||
export async function GET(request: NextRequest) {
|
||
const { searchParams } = new URL(request.url)
|
||
const query = searchParams.get('query')
|
||
const action = searchParams.get('action')
|
||
const page = parseInt(searchParams.get('page') || '1')
|
||
const limit = parseInt(searchParams.get('limit') || '20')
|
||
const userLevel = searchParams.get('userLevel')
|
||
const province = searchParams.get('province')
|
||
|
||
try {
|
||
// 获取画像模板统计
|
||
if (action === 'templates') {
|
||
const templateData = await getPortraitTemplateStats()
|
||
return NextResponse.json({
|
||
success: true,
|
||
...templateData
|
||
})
|
||
}
|
||
|
||
// 获取用户列表(真实数据)
|
||
if (action === 'users') {
|
||
const filters: any = {}
|
||
if (userLevel) filters.userLevel = userLevel
|
||
if (province) filters.province = province
|
||
|
||
const userData = await getRealUserList(page, limit, filters)
|
||
return NextResponse.json({
|
||
success: true,
|
||
...userData
|
||
})
|
||
}
|
||
|
||
// 人群分布统计
|
||
if (action === 'distribution') {
|
||
const distribution = await getCrowdDistribution()
|
||
return NextResponse.json({
|
||
success: true,
|
||
distribution
|
||
})
|
||
}
|
||
|
||
// 单用户画像查询
|
||
if (query) {
|
||
const portrait = await getUserPortrait(query)
|
||
|
||
if (!portrait) {
|
||
return NextResponse.json({
|
||
success: false,
|
||
error: '未找到用户数据',
|
||
portrait: null
|
||
}, { status: 404 })
|
||
}
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
portrait
|
||
})
|
||
}
|
||
|
||
// 默认返回模板和用户列表
|
||
const [templateData, userData] = await Promise.all([
|
||
getPortraitTemplateStats(),
|
||
getRealUserList(1, 10)
|
||
])
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
templates: templateData.templates,
|
||
totalUsers: templateData.totalUsers,
|
||
users: userData.users,
|
||
usersTotal: userData.total
|
||
})
|
||
|
||
} catch (error: any) {
|
||
console.error('画像 API 错误:', error)
|
||
return NextResponse.json({
|
||
success: false,
|
||
error: error.message
|
||
}, { status: 500 })
|
||
}
|
||
}
|
||
|
||
// POST: 人群圈选或创建画像
|
||
export async function POST(request: NextRequest) {
|
||
try {
|
||
const body = await request.json()
|
||
const { action, filters, name, description, criteria } = body
|
||
|
||
// 创建画像模板
|
||
if (action === 'createTemplate') {
|
||
if (!name) {
|
||
return NextResponse.json({
|
||
success: false,
|
||
error: '画像名称为必填项'
|
||
}, { status: 400 })
|
||
}
|
||
|
||
const template = await createPortraitTemplate({
|
||
name,
|
||
description: description || '',
|
||
criteria: criteria || {}
|
||
})
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
template,
|
||
message: `画像 "${name}" 创建成功,包含 ${template.userCount.toLocaleString()} 个用户`
|
||
})
|
||
}
|
||
|
||
// 人群圈选(默认行为)
|
||
// 构建查询条件
|
||
const query: any = {}
|
||
|
||
if (filters?.userLevel) {
|
||
query.user_level = { $in: Array.isArray(filters.userLevel) ? filters.userLevel : [filters.userLevel] }
|
||
}
|
||
if (filters?.province) {
|
||
query.province = { $in: Array.isArray(filters.province) ? filters.province : [filters.province] }
|
||
}
|
||
if (filters?.city) {
|
||
query.city = { $in: Array.isArray(filters.city) ? filters.city : [filters.city] }
|
||
}
|
||
if (filters?.rfmScoreMin !== undefined) {
|
||
query.user_evaluation_score = { $gte: filters.rfmScoreMin }
|
||
}
|
||
if (filters?.rfmScoreMax !== undefined) {
|
||
query.user_evaluation_score = {
|
||
...(query.user_evaluation_score || {}),
|
||
$lte: filters.rfmScoreMax
|
||
}
|
||
}
|
||
if (filters?.tags && filters.tags.length > 0) {
|
||
query.source_channels = { $in: filters.tags }
|
||
}
|
||
|
||
const client = await getMongoClient()
|
||
const collection = client.db('KR').collection('用户估值')
|
||
|
||
// 统计符合条件的用户数
|
||
const count = await collection.countDocuments(query)
|
||
|
||
// 获取样本数据
|
||
const samples = await collection.find(query)
|
||
.limit(20)
|
||
.project({
|
||
phone: 1,
|
||
phone_masked: 1,
|
||
name: 1,
|
||
user_level: 1,
|
||
user_evaluation_score: 1,
|
||
province: 1,
|
||
city: 1,
|
||
source_channels: 1
|
||
})
|
||
.toArray()
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
crowd: {
|
||
id: `crowd_${Date.now()}`,
|
||
name: name || '未命名人群',
|
||
description,
|
||
filters,
|
||
userCount: count,
|
||
samples: samples.map(s => ({
|
||
id: s._id.toString(),
|
||
phone: maskPhone(s.phone || s.phone_masked),
|
||
name: s.name || '未知',
|
||
level: s.user_level || '-',
|
||
score: s.user_evaluation_score || 0,
|
||
province: s.province || '-',
|
||
city: s.city || '-',
|
||
tags: s.source_channels || []
|
||
})),
|
||
createdAt: new Date().toISOString()
|
||
}
|
||
})
|
||
|
||
} catch (error: any) {
|
||
console.error('画像 POST 错误:', error)
|
||
return NextResponse.json({
|
||
success: false,
|
||
error: error.message
|
||
}, { status: 500 })
|
||
}
|
||
}
|