chore: 以本地为准,上传全部并替换 GitHub

This commit is contained in:
卡若
2026-02-03 11:36:53 +08:00
parent 1219166526
commit b404bf546e
131 changed files with 37618 additions and 3930 deletions

656
app/api/portrait/route.ts Normal file
View File

@@ -0,0 +1,656 @@
/**
* 用户画像 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 })
}
}