Files
users/app/api/portrait/route.ts

657 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 用户画像 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 })
}
}