chore: 以本地为准,上传全部并替换 GitHub
This commit is contained in:
315
app/api/ai-chat/route.ts
Normal file
315
app/api/ai-chat/route.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* AI 智能助手 API
|
||||
* 支持自然语言查询用户数据
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getMongoClient, intelligentSearch, queryFullProfile, getDatabaseStats } from "@/lib/mongodb"
|
||||
|
||||
// 消息类型
|
||||
interface ChatMessage {
|
||||
role: "user" | "assistant" | "system"
|
||||
content: string
|
||||
timestamp?: string
|
||||
data?: any
|
||||
}
|
||||
|
||||
// 解析用户意图
|
||||
function parseIntent(message: string): { type: string; query?: string; params?: any } {
|
||||
const msg = message.trim().toLowerCase()
|
||||
|
||||
// 查询手机号
|
||||
const phoneMatch = message.match(/(?:查|查询|搜索|找)?[::\s]*(\+?86)?1[3-9]\d{9}/g)
|
||||
if (phoneMatch) {
|
||||
const phone = phoneMatch[0].replace(/[查询搜索找::\s]/g, '').replace(/^\+?86/, '')
|
||||
return { type: "query_phone", query: phone }
|
||||
}
|
||||
|
||||
// 查询 QQ
|
||||
const qqMatch = message.match(/(?:qq|QQ)[::\s]*(\d{5,11})|(\d{5,11})\s*(?:qq|QQ)/i)
|
||||
if (qqMatch) {
|
||||
return { type: "query_qq", query: qqMatch[1] || qqMatch[2] }
|
||||
}
|
||||
|
||||
// 系统状态
|
||||
if (msg.includes("状态") || msg.includes("统计") || msg.includes("总量")) {
|
||||
return { type: "system_status" }
|
||||
}
|
||||
|
||||
// RFM 分析
|
||||
if (msg.includes("rfm") || msg.includes("估值") || msg.includes("价值")) {
|
||||
return { type: "rfm_analysis" }
|
||||
}
|
||||
|
||||
// 高价值用户
|
||||
if (msg.includes("高价值") || msg.includes("top") || msg.includes("排行")) {
|
||||
const limitMatch = msg.match(/(\d+)/)
|
||||
return { type: "high_value_users", params: { limit: limitMatch ? parseInt(limitMatch[1]) : 10 } }
|
||||
}
|
||||
|
||||
// 帮助
|
||||
if (msg.includes("帮助") || msg.includes("help") || msg === "?") {
|
||||
return { type: "help" }
|
||||
}
|
||||
|
||||
// 通用搜索
|
||||
return { type: "search", query: message }
|
||||
}
|
||||
|
||||
// 格式化用户数据
|
||||
function formatUserData(user: any): string {
|
||||
if (!user) return "未找到用户信息"
|
||||
|
||||
const lines: string[] = []
|
||||
|
||||
if (user.name) lines.push(`👤 姓名: ${user.name}`)
|
||||
if (user.phone_masked) lines.push(`📱 手机: ${user.phone_masked}`)
|
||||
if (user.qq) lines.push(`💬 QQ: ${user.qq}`)
|
||||
if (user.gender) lines.push(`⚧ 性别: ${user.gender}`)
|
||||
if (user.age_range) lines.push(`📅 年龄段: ${user.age_range}`)
|
||||
if (user.province || user.city) lines.push(`📍 地区: ${user.province || ''}${user.city || ''}`)
|
||||
if (user.evaluation_score || user.user_evaluation_score) {
|
||||
lines.push(`⭐ 估值分: ${user.evaluation_score || user.user_evaluation_score}`)
|
||||
}
|
||||
if (user.user_level) lines.push(`🏆 等级: ${user.user_level}`)
|
||||
if (user.carrier) lines.push(`📶 运营商: ${user.carrier}`)
|
||||
if (user.tags && user.tags.length > 0) lines.push(`🏷️ 标签: ${user.tags.join(', ')}`)
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// 处理 AI 聊天
|
||||
async function processChat(message: string): Promise<ChatMessage> {
|
||||
const startTime = Date.now()
|
||||
const intent = parseIntent(message)
|
||||
|
||||
try {
|
||||
switch (intent.type) {
|
||||
case "query_phone": {
|
||||
const result = await queryFullProfile(intent.query!)
|
||||
if (result.valuation || result.qqPhone) {
|
||||
const user = {
|
||||
...result.valuation,
|
||||
qq: result.qqPhone?.qq,
|
||||
carrier: result.qqPhone?.运营商
|
||||
}
|
||||
return {
|
||||
role: "assistant",
|
||||
content: `🎯 手机号 ${intent.query} 查询结果:\n\n${formatUserData(user)}\n\n⏱️ 查询耗时: ${Date.now() - startTime}ms`,
|
||||
data: result
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: `❌ 未找到手机号 ${intent.query} 的相关信息\n\n💡 提示: 请检查手机号是否正确(11位数字)`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case "query_qq": {
|
||||
const client = await getMongoClient()
|
||||
const qqDb = client.db("KR_腾讯")
|
||||
let qqDoc = await qqDb.collection("QQ+手机").findOne({ qq: intent.query })
|
||||
if (!qqDoc) {
|
||||
qqDoc = await qqDb.collection("QQ+手机").findOne({ qq: parseInt(intent.query!) })
|
||||
}
|
||||
|
||||
if (qqDoc) {
|
||||
const phone = qqDoc.phone || qqDoc.手机号
|
||||
let userInfo = `🎯 QQ ${intent.query} 查询结果:\n\n`
|
||||
userInfo += `💬 QQ: ${qqDoc.qq}\n`
|
||||
userInfo += `📱 手机: ${phone ? phone.toString().replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : '未知'}\n`
|
||||
userInfo += `📊 QQ评分: ${qqDoc.QQ号评分 || 'N/A'}\n`
|
||||
userInfo += `📊 手机评分: ${qqDoc.手机号评分 || 'N/A'}\n`
|
||||
userInfo += `📍 地区: ${qqDoc.省份 || ''}${qqDoc.地区 || ''}\n`
|
||||
userInfo += `📶 运营商: ${qqDoc.运营商 || 'N/A'}\n`
|
||||
userInfo += `\n⏱️ 查询耗时: ${Date.now() - startTime}ms`
|
||||
|
||||
return {
|
||||
role: "assistant",
|
||||
content: userInfo,
|
||||
data: qqDoc
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: `❌ 未找到 QQ ${intent.query} 的相关信息`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case "system_status": {
|
||||
const stats = await getDatabaseStats()
|
||||
return {
|
||||
role: "assistant",
|
||||
content: `📊 神射手系统状态\n\n` +
|
||||
`🟢 连接状态: ${stats.connected ? '正常' : '异常'}\n` +
|
||||
`📁 数据库数: ${stats.databases?.length || 0} 个\n` +
|
||||
`📄 总记录数: ${(stats.totalDocuments / 1e8).toFixed(2)} 亿条\n` +
|
||||
`💾 总数据量: ${(stats.totalSize / 1e9).toFixed(2)} GB\n` +
|
||||
`⏱️ 响应延迟: ${stats.latency}ms`,
|
||||
data: stats
|
||||
}
|
||||
}
|
||||
|
||||
case "rfm_analysis": {
|
||||
const client = await getMongoClient()
|
||||
const db = client.db("KR")
|
||||
const pipeline = [
|
||||
{ $group: { _id: "$user_level", count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } }
|
||||
]
|
||||
const results = await db.collection("用户估值").aggregate(pipeline).toArray()
|
||||
|
||||
let content = `📈 RFM 用户分布统计\n\n`
|
||||
let total = 0
|
||||
results.forEach(r => {
|
||||
const level = r._id || '未分级'
|
||||
content += `${level}: ${(r.count / 10000).toFixed(1)}万\n`
|
||||
total += r.count
|
||||
})
|
||||
content += `\n📊 总用户数: ${(total / 10000).toFixed(1)}万`
|
||||
content += `\n⏱️ 统计耗时: ${Date.now() - startTime}ms`
|
||||
|
||||
return {
|
||||
role: "assistant",
|
||||
content,
|
||||
data: results
|
||||
}
|
||||
}
|
||||
|
||||
case "high_value_users": {
|
||||
const limit = intent.params?.limit || 10
|
||||
const client = await getMongoClient()
|
||||
const db = client.db("KR")
|
||||
const users = await db.collection("用户估值")
|
||||
.find({ user_evaluation_score: { $exists: true } })
|
||||
.sort({ user_evaluation_score: -1 })
|
||||
.limit(limit)
|
||||
.toArray()
|
||||
|
||||
let content = `🏆 高价值用户 TOP${limit}\n\n`
|
||||
users.forEach((u, i) => {
|
||||
const phone = u.phone?.replace(/(\+?86)?(\d{3})\d{4}(\d{4})/, '$2****$3') || '***'
|
||||
content += `${i + 1}. ${u.name || '未知'} (${phone}) - 估值: ${u.user_evaluation_score}\n`
|
||||
})
|
||||
content += `\n⏱️ 查询耗时: ${Date.now() - startTime}ms`
|
||||
|
||||
return {
|
||||
role: "assistant",
|
||||
content,
|
||||
data: users
|
||||
}
|
||||
}
|
||||
|
||||
case "help": {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: `🎯 神射手 AI 助手使用指南\n\n` +
|
||||
`📱 查询手机号:\n "查 13800138000" 或 "13800138000"\n\n` +
|
||||
`💬 查询 QQ:\n "28533368 qq" 或 "qq 28533368"\n\n` +
|
||||
`📊 系统状态:\n "系统状态" 或 "统计"\n\n` +
|
||||
`📈 RFM 分析:\n "RFM分析" 或 "用户估值"\n\n` +
|
||||
`🏆 高价值用户:\n "高价值用户 TOP10"\n\n` +
|
||||
`💡 数据覆盖: 20亿+用户,207GB数据`
|
||||
}
|
||||
}
|
||||
|
||||
case "search":
|
||||
default: {
|
||||
// 尝试智能搜索
|
||||
const result = await intelligentSearch(intent.query || message, { limit: 5 })
|
||||
if (result.total > 0) {
|
||||
let content = `🔍 搜索结果 (共${result.total}条)\n\n`
|
||||
result.users.slice(0, 5).forEach((u, i) => {
|
||||
content += `${i + 1}. ${u.name || '未知'} - ${u.city || ''} - 估值: ${u.user_evaluation_score || 'N/A'}\n`
|
||||
})
|
||||
content += `\n⏱️ 查询耗时: ${Date.now() - startTime}ms`
|
||||
return {
|
||||
role: "assistant",
|
||||
content,
|
||||
data: result
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: `🤔 我不太理解 "${message}"\n\n` +
|
||||
`💡 你可以尝试:\n` +
|
||||
`- 查询手机号: "查 13800138000"\n` +
|
||||
`- 查询QQ: "28533368 qq"\n` +
|
||||
`- 输入 "帮助" 查看更多功能`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("AI Chat 错误:", error)
|
||||
return {
|
||||
role: "assistant",
|
||||
content: `⚠️ 查询出错: ${error.message}\n\n请稍后重试或检查数据库连接`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ai-chat
|
||||
* AI 对话接口
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { message, history = [] } = body
|
||||
|
||||
if (!message || !message.trim()) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: "消息不能为空"
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await processChat(message)
|
||||
response.timestamp = new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
response
|
||||
})
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("AI Chat API 错误:", error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
response: {
|
||||
role: "assistant",
|
||||
content: `⚠️ 服务暂时不可用: ${error.message}`,
|
||||
timestamp: new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ai-chat
|
||||
* 获取 AI 助手状态
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const stats = await getDatabaseStats()
|
||||
return NextResponse.json({
|
||||
status: "online",
|
||||
model: "神射手 AI v1.0",
|
||||
capabilities: ["用户查询", "QQ查询", "RFM分析", "智能搜索"],
|
||||
database: {
|
||||
connected: stats.connected,
|
||||
totalUsers: stats.totalDocuments,
|
||||
latency: stats.latency
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({
|
||||
status: "degraded",
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
303
app/api/ai-tagging/route.ts
Normal file
303
app/api/ai-tagging/route.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* AI 打标 API
|
||||
* 提供智能标签生成、批量打标、标签推荐等功能
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getMongoClient, queryFullProfile, maskPhone } from '@/lib/mongodb'
|
||||
|
||||
// AI 标签规则
|
||||
interface TagRule {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
conditions: {
|
||||
field: string
|
||||
operator: 'eq' | 'ne' | 'gt' | 'lt' | 'gte' | 'lte' | 'contains' | 'in'
|
||||
value: any
|
||||
}[]
|
||||
resultTag: string
|
||||
category: string
|
||||
priority: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
// 预定义的 AI 标签规则
|
||||
const AI_TAG_RULES: TagRule[] = [
|
||||
{
|
||||
id: 'rule_1',
|
||||
name: '钻石用户识别',
|
||||
description: 'RFM评分≥80的用户标记为钻石用户',
|
||||
conditions: [
|
||||
{ field: 'rfm_composite_score', operator: 'gte', value: 80 }
|
||||
],
|
||||
resultTag: '钻石用户',
|
||||
category: 'value',
|
||||
priority: 1,
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: 'rule_2',
|
||||
name: '高频活跃用户',
|
||||
description: '近30天活跃次数>10的用户',
|
||||
conditions: [
|
||||
{ field: 'activity_count_30d', operator: 'gt', value: 10 }
|
||||
],
|
||||
resultTag: '高频活跃',
|
||||
category: 'behavior',
|
||||
priority: 2,
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: 'rule_3',
|
||||
name: '流失风险用户',
|
||||
description: '90天未活跃的用户',
|
||||
conditions: [
|
||||
{ field: 'last_active_days', operator: 'gt', value: 90 }
|
||||
],
|
||||
resultTag: '流失风险',
|
||||
category: 'lifecycle',
|
||||
priority: 3,
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: 'rule_4',
|
||||
name: '一线城市用户',
|
||||
description: '来自北上广深的用户',
|
||||
conditions: [
|
||||
{ field: 'city', operator: 'in', value: ['北京', '上海', '广州', '深圳'] }
|
||||
],
|
||||
resultTag: '一线城市',
|
||||
category: 'region',
|
||||
priority: 4,
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: 'rule_5',
|
||||
name: '厦门本地用户',
|
||||
description: '来自厦门的用户',
|
||||
conditions: [
|
||||
{ field: 'city', operator: 'eq', value: '厦门' }
|
||||
],
|
||||
resultTag: '厦门本地',
|
||||
category: 'region',
|
||||
priority: 5,
|
||||
enabled: true
|
||||
}
|
||||
]
|
||||
|
||||
// 根据规则计算用户标签
|
||||
function computeTagsForUser(userData: any, rules: TagRule[]): string[] {
|
||||
const tags: string[] = []
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!rule.enabled) continue
|
||||
|
||||
let match = true
|
||||
for (const condition of rule.conditions) {
|
||||
const fieldValue = userData[condition.field]
|
||||
|
||||
switch (condition.operator) {
|
||||
case 'eq':
|
||||
match = match && fieldValue === condition.value
|
||||
break
|
||||
case 'ne':
|
||||
match = match && fieldValue !== condition.value
|
||||
break
|
||||
case 'gt':
|
||||
match = match && fieldValue > condition.value
|
||||
break
|
||||
case 'lt':
|
||||
match = match && fieldValue < condition.value
|
||||
break
|
||||
case 'gte':
|
||||
match = match && fieldValue >= condition.value
|
||||
break
|
||||
case 'lte':
|
||||
match = match && fieldValue <= condition.value
|
||||
break
|
||||
case 'contains':
|
||||
match = match && String(fieldValue).includes(condition.value)
|
||||
break
|
||||
case 'in':
|
||||
match = match && Array.isArray(condition.value) && condition.value.includes(fieldValue)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (match) {
|
||||
tags.push(rule.resultTag)
|
||||
}
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
// 为单个用户生成AI标签
|
||||
async function generateTagsForUser(phone: string): Promise<{
|
||||
phone: string
|
||||
phoneMasked: string
|
||||
originalTags: string[]
|
||||
newTags: string[]
|
||||
allTags: string[]
|
||||
}> {
|
||||
const profile = await queryFullProfile(phone)
|
||||
|
||||
const userData = {
|
||||
...profile.valuation,
|
||||
city: profile.valuation?.city || profile.qqPhone?.地区,
|
||||
province: profile.valuation?.province || profile.qqPhone?.省份
|
||||
}
|
||||
|
||||
const originalTags = profile.valuation?.tags || []
|
||||
const newTags = computeTagsForUser(userData, AI_TAG_RULES)
|
||||
const allTags = [...new Set([...originalTags, ...newTags])]
|
||||
|
||||
return {
|
||||
phone,
|
||||
phoneMasked: maskPhone(phone),
|
||||
originalTags,
|
||||
newTags,
|
||||
allTags
|
||||
}
|
||||
}
|
||||
|
||||
// GET: 获取 AI 标签规则或统计
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action')
|
||||
const phone = searchParams.get('phone')
|
||||
|
||||
try {
|
||||
// 获取标签规则
|
||||
if (action === 'rules') {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
rules: AI_TAG_RULES,
|
||||
total: AI_TAG_RULES.length
|
||||
})
|
||||
}
|
||||
|
||||
// 为单个用户生成标签
|
||||
if (phone) {
|
||||
const result = await generateTagsForUser(phone)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
// AI 打标统计
|
||||
const client = await getMongoClient()
|
||||
const collection = client.db('KR').collection('用户估值')
|
||||
|
||||
const stats = await collection.aggregate([
|
||||
{ $match: { tags: { $exists: true, $ne: [] } } },
|
||||
{ $unwind: '$tags' },
|
||||
{ $group: { _id: '$tags', count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 20 }
|
||||
]).toArray()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
rules: AI_TAG_RULES,
|
||||
tagDistribution: stats.map(s => ({
|
||||
tag: s._id,
|
||||
count: s.count
|
||||
}))
|
||||
})
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('AI 打标 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, phones, rule, filters } = body
|
||||
|
||||
// 批量打标
|
||||
if (action === 'batch') {
|
||||
const results = []
|
||||
const phonesToProcess = phones || []
|
||||
|
||||
for (const phone of phonesToProcess.slice(0, 100)) {
|
||||
try {
|
||||
const result = await generateTagsForUser(phone)
|
||||
results.push(result)
|
||||
} catch (e) {
|
||||
results.push({
|
||||
phone,
|
||||
phoneMasked: maskPhone(phone),
|
||||
error: '处理失败'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
processed: results.length,
|
||||
results
|
||||
})
|
||||
}
|
||||
|
||||
// 按条件批量打标
|
||||
if (action === 'batch_by_filter') {
|
||||
const client = await getMongoClient()
|
||||
const collection = client.db('KR').collection('用户估值')
|
||||
|
||||
// 构建查询条件
|
||||
const query: any = {}
|
||||
if (filters?.province) query.province = filters.province
|
||||
if (filters?.city) query.city = filters.city
|
||||
if (filters?.userLevel) query.user_level = filters.userLevel
|
||||
|
||||
const count = await collection.countDocuments(query)
|
||||
|
||||
// 模拟批量任务创建
|
||||
const taskId = `task_${Date.now()}`
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
taskId,
|
||||
estimatedUsers: count,
|
||||
message: `已创建批量打标任务,预计处理 ${count} 个用户`
|
||||
})
|
||||
}
|
||||
|
||||
// 创建标签规则
|
||||
if (action === 'create_rule') {
|
||||
const newRule: TagRule = {
|
||||
id: `rule_${Date.now()}`,
|
||||
...rule,
|
||||
enabled: true
|
||||
}
|
||||
|
||||
// TODO: 保存到数据库
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
rule: newRule,
|
||||
message: '标签规则创建成功'
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '未知操作'
|
||||
}, { status: 400 })
|
||||
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
77
app/api/auth/login/route.ts
Normal file
77
app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
|
||||
/**
|
||||
* 本地登录 API - 支持邮箱/手机号 + 密码
|
||||
* 当未配置 NEXT_PUBLIC_API_BASE_URL 时使用
|
||||
* 开发账号: zhiqun@qq.com / Zhiqun1984
|
||||
*/
|
||||
const MOCK_USERS: Record<string, { password: string }> = {
|
||||
"zhiqun@qq.com": { password: "Zhiqun1984" },
|
||||
}
|
||||
|
||||
function isEmail(value: string): boolean {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const account = (formData.get("email") || formData.get("phone") || "").toString().trim()
|
||||
const password = (formData.get("password") || "").toString()
|
||||
const verificationCode = formData.get("verificationCode")?.toString()
|
||||
|
||||
if (!account) {
|
||||
return NextResponse.json(
|
||||
{ code: 40001, message: "请输入邮箱或手机号" },
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
|
||||
// 验证码登录:开发环境下任意6位验证码通过
|
||||
if (verificationCode) {
|
||||
if (verificationCode.length >= 4) {
|
||||
const token = `mock_token_${Date.now()}_${account}`
|
||||
return NextResponse.json({
|
||||
code: 10000,
|
||||
message: "登录成功",
|
||||
data: { token },
|
||||
})
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ code: 40002, message: "验证码错误" },
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
|
||||
// 密码登录
|
||||
if (!password) {
|
||||
return NextResponse.json(
|
||||
{ code: 40003, message: "请输入密码" },
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
|
||||
const key = isEmail(account) ? account : account
|
||||
const user = MOCK_USERS[key]
|
||||
|
||||
if (user && user.password === password) {
|
||||
const token = `mock_token_${Date.now()}_${account}`
|
||||
return NextResponse.json({
|
||||
code: 10000,
|
||||
message: "登录成功",
|
||||
data: { token },
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ code: 40004, message: "邮箱/手机号或密码错误" },
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("[auth/login]", error)
|
||||
return NextResponse.json(
|
||||
{ code: 50000, message: "服务器错误" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
31
app/api/auth/send-code/route.ts
Normal file
31
app/api/auth/send-code/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
|
||||
/**
|
||||
* 本地验证码发送 API (mock)
|
||||
* 开发环境下直接返回成功,验证码可为任意4位以上
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const phone = (formData.get("phone") || "").toString().trim()
|
||||
|
||||
if (!phone) {
|
||||
return NextResponse.json(
|
||||
{ code: 40001, message: "请输入手机号" },
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
|
||||
// Mock: 模拟发送成功,开发时可用 123456 等作为验证码
|
||||
return NextResponse.json({
|
||||
code: 10000,
|
||||
message: "验证码已发送(开发模式:可使用任意4位以上数字)",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[auth/send-code]", error)
|
||||
return NextResponse.json(
|
||||
{ code: 50000, message: "服务器错误" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
185
app/api/channels/route.ts
Normal file
185
app/api/channels/route.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
// 渠道配置接口
|
||||
interface ChannelConfig {
|
||||
id: string
|
||||
type: 'feishu' | 'wecom' | 'wechat' | 'api'
|
||||
name: string
|
||||
status: 'active' | 'pending' | 'error'
|
||||
config: Record<string, string>
|
||||
lastChecked?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
// 网关地址(卡若AI部署的网关)
|
||||
const GATEWAY_URL = process.env.GATEWAY_URL || 'http://localhost:8000'
|
||||
|
||||
// GET: 获取渠道状态
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action')
|
||||
const type = searchParams.get('type')
|
||||
|
||||
try {
|
||||
// 测试飞书连接
|
||||
if (action === 'test' && type === 'feishu') {
|
||||
try {
|
||||
const res = await fetch(`${GATEWAY_URL}/feishu/test`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
const data = await res.json()
|
||||
return NextResponse.json({
|
||||
success: data.status === 'success',
|
||||
message: data.message,
|
||||
appId: data.app_id
|
||||
})
|
||||
} catch (e) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: '网关连接失败,请确认卡若AI网关已启动',
|
||||
error: String(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 获取飞书群列表
|
||||
if (action === 'chats' && type === 'feishu') {
|
||||
try {
|
||||
const res = await fetch(`${GATEWAY_URL}/feishu/chats?refresh=true`)
|
||||
const data = await res.json()
|
||||
return NextResponse.json({
|
||||
success: data.success,
|
||||
chats: data.chats || [],
|
||||
count: data.count || 0
|
||||
})
|
||||
} catch (e) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: '获取群列表失败',
|
||||
chats: []
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有渠道状态
|
||||
const channels: ChannelConfig[] = [
|
||||
{
|
||||
id: 'feishu-1',
|
||||
type: 'feishu',
|
||||
name: '飞书机器人',
|
||||
status: 'pending',
|
||||
config: {
|
||||
webhookUrl: `${GATEWAY_URL}/feishu/webhook`,
|
||||
testUrl: `${GATEWAY_URL}/feishu/test`
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'wecom-1',
|
||||
type: 'wecom',
|
||||
name: '企业微信应用',
|
||||
status: 'pending',
|
||||
config: {
|
||||
webhookUrl: `${GATEWAY_URL}/wecom/callback`
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'api-1',
|
||||
type: 'api',
|
||||
name: 'REST API',
|
||||
status: 'active',
|
||||
config: {
|
||||
endpoint: '/api/ai-chat',
|
||||
method: 'POST'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
channels,
|
||||
gateway: GATEWAY_URL
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: String(error)
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST: 发送消息到渠道
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { action, type, ...params } = body
|
||||
|
||||
// 发送消息到飞书群
|
||||
if (action === 'send' && type === 'feishu') {
|
||||
const { chatName, content } = params
|
||||
|
||||
if (!chatName || !content) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '缺少参数: chatName, content'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${GATEWAY_URL}/feishu/send_message`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_name: chatName,
|
||||
content: content
|
||||
})
|
||||
})
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (e) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: '发送失败',
|
||||
error: String(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 发送会议纪要
|
||||
if (action === 'send_minutes' && type === 'feishu') {
|
||||
const { minutesUrl, chatName, useLlm = true } = params
|
||||
|
||||
try {
|
||||
const res = await fetch(`${GATEWAY_URL}/feishu/send_minutes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
minutes_url: minutesUrl,
|
||||
chat_name: chatName,
|
||||
use_llm: useLlm
|
||||
})
|
||||
})
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (e) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: '发送会议纪要失败',
|
||||
error: String(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '未知操作'
|
||||
}, { status: 400 })
|
||||
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: String(error)
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
317
app/api/cleaning-rules/route.ts
Normal file
317
app/api/cleaning-rules/route.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* 清洗规则 API
|
||||
* 提供数据清洗规则管理、执行清洗任务等功能
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getMongoClient, maskPhone } from '@/lib/mongodb'
|
||||
|
||||
// 清洗规则接口
|
||||
interface CleaningRule {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
type: 'format' | 'dedup' | 'validate' | 'enrich' | 'mask'
|
||||
targetField: string
|
||||
config: {
|
||||
pattern?: string
|
||||
replacement?: string
|
||||
validation?: string
|
||||
enrichSource?: string
|
||||
maskType?: 'phone' | 'email' | 'idcard' | 'name'
|
||||
}
|
||||
priority: number
|
||||
enabled: boolean
|
||||
createdAt: string
|
||||
lastRunAt?: string
|
||||
processedCount?: number
|
||||
}
|
||||
|
||||
// 预定义清洗规则
|
||||
const CLEANING_RULES: CleaningRule[] = [
|
||||
{
|
||||
id: 'rule_phone_format',
|
||||
name: '手机号格式标准化',
|
||||
description: '将各种格式的手机号统一为11位标准格式',
|
||||
type: 'format',
|
||||
targetField: 'phone',
|
||||
config: {
|
||||
pattern: '^(\\+?86)?([1][3-9]\\d{9})$',
|
||||
replacement: '$2'
|
||||
},
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
createdAt: '2025-01-01',
|
||||
lastRunAt: '2026-01-31',
|
||||
processedCount: 1500000
|
||||
},
|
||||
{
|
||||
id: 'rule_phone_dedup',
|
||||
name: '手机号去重',
|
||||
description: '按手机号去除重复记录,保留最新数据',
|
||||
type: 'dedup',
|
||||
targetField: 'phone',
|
||||
config: {},
|
||||
priority: 2,
|
||||
enabled: true,
|
||||
createdAt: '2025-01-01',
|
||||
lastRunAt: '2026-01-30',
|
||||
processedCount: 50000
|
||||
},
|
||||
{
|
||||
id: 'rule_phone_validate',
|
||||
name: '手机号有效性验证',
|
||||
description: '验证手机号是否符合中国大陆手机号规则',
|
||||
type: 'validate',
|
||||
targetField: 'phone',
|
||||
config: {
|
||||
validation: '^1[3-9]\\d{9}$'
|
||||
},
|
||||
priority: 3,
|
||||
enabled: true,
|
||||
createdAt: '2025-01-01',
|
||||
lastRunAt: '2026-01-31',
|
||||
processedCount: 2000000000
|
||||
},
|
||||
{
|
||||
id: 'rule_phone_mask',
|
||||
name: '手机号脱敏',
|
||||
description: '对外展示时隐藏手机号中间4位',
|
||||
type: 'mask',
|
||||
targetField: 'phone',
|
||||
config: {
|
||||
maskType: 'phone'
|
||||
},
|
||||
priority: 4,
|
||||
enabled: true,
|
||||
createdAt: '2025-01-01',
|
||||
processedCount: 0
|
||||
},
|
||||
{
|
||||
id: 'rule_province_enrich',
|
||||
name: '省份信息补全',
|
||||
description: '根据手机号归属地补全省份信息',
|
||||
type: 'enrich',
|
||||
targetField: 'province',
|
||||
config: {
|
||||
enrichSource: 'phone_location'
|
||||
},
|
||||
priority: 5,
|
||||
enabled: true,
|
||||
createdAt: '2025-01-01',
|
||||
lastRunAt: '2026-01-29',
|
||||
processedCount: 800000
|
||||
},
|
||||
{
|
||||
id: 'rule_email_format',
|
||||
name: '邮箱格式标准化',
|
||||
description: '邮箱地址转小写并去除空格',
|
||||
type: 'format',
|
||||
targetField: 'email',
|
||||
config: {
|
||||
pattern: '\\s+',
|
||||
replacement: ''
|
||||
},
|
||||
priority: 6,
|
||||
enabled: true,
|
||||
createdAt: '2025-01-01',
|
||||
processedCount: 100000
|
||||
}
|
||||
]
|
||||
|
||||
// 执行清洗规则
|
||||
async function executeCleaningRule(ruleId: string, limit: number = 1000): Promise<{
|
||||
processed: number
|
||||
cleaned: number
|
||||
errors: number
|
||||
samples: any[]
|
||||
}> {
|
||||
const rule = CLEANING_RULES.find(r => r.id === ruleId)
|
||||
if (!rule) throw new Error('规则不存在')
|
||||
|
||||
const client = await getMongoClient()
|
||||
const collection = client.db('KR').collection('用户估值')
|
||||
|
||||
// 模拟清洗执行
|
||||
const samples = await collection.find({})
|
||||
.limit(10)
|
||||
.project({ phone: 1, province: 1, city: 1 })
|
||||
.toArray()
|
||||
|
||||
return {
|
||||
processed: limit,
|
||||
cleaned: Math.floor(limit * 0.95),
|
||||
errors: Math.floor(limit * 0.01),
|
||||
samples: samples.map(s => ({
|
||||
...s,
|
||||
phone: maskPhone(s.phone),
|
||||
cleaningApplied: rule.name
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// GET: 获取清洗规则列表
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action')
|
||||
const type = searchParams.get('type')
|
||||
const id = searchParams.get('id')
|
||||
|
||||
try {
|
||||
// 获取规则详情
|
||||
if (id) {
|
||||
const rule = CLEANING_RULES.find(r => r.id === id)
|
||||
if (!rule) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '规则不存在'
|
||||
}, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
rule
|
||||
})
|
||||
}
|
||||
|
||||
// 获取规则统计
|
||||
if (action === 'stats') {
|
||||
const stats = {
|
||||
totalRules: CLEANING_RULES.length,
|
||||
enabledRules: CLEANING_RULES.filter(r => r.enabled).length,
|
||||
totalProcessed: CLEANING_RULES.reduce((sum, r) => sum + (r.processedCount || 0), 0),
|
||||
rulesByType: {
|
||||
format: CLEANING_RULES.filter(r => r.type === 'format').length,
|
||||
dedup: CLEANING_RULES.filter(r => r.type === 'dedup').length,
|
||||
validate: CLEANING_RULES.filter(r => r.type === 'validate').length,
|
||||
enrich: CLEANING_RULES.filter(r => r.type === 'enrich').length,
|
||||
mask: CLEANING_RULES.filter(r => r.type === 'mask').length
|
||||
}
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
stats
|
||||
})
|
||||
}
|
||||
|
||||
// 获取规则列表
|
||||
let rules = [...CLEANING_RULES]
|
||||
|
||||
if (type) {
|
||||
rules = rules.filter(r => r.type === type)
|
||||
}
|
||||
|
||||
rules.sort((a, b) => a.priority - b.priority)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
rules,
|
||||
total: rules.length
|
||||
})
|
||||
|
||||
} 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, ruleId, rule, limit } = body
|
||||
|
||||
// 执行清洗
|
||||
if (action === 'execute') {
|
||||
if (!ruleId) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '请指定要执行的规则'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await executeCleaningRule(ruleId, limit || 1000)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
// 批量执行所有启用的规则
|
||||
if (action === 'execute_all') {
|
||||
const enabledRules = CLEANING_RULES.filter(r => r.enabled)
|
||||
const results = []
|
||||
|
||||
for (const r of enabledRules) {
|
||||
try {
|
||||
const result = await executeCleaningRule(r.id, limit || 100)
|
||||
results.push({
|
||||
ruleId: r.id,
|
||||
ruleName: r.name,
|
||||
...result
|
||||
})
|
||||
} catch (e: any) {
|
||||
results.push({
|
||||
ruleId: r.id,
|
||||
ruleName: r.name,
|
||||
error: e.message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
executed: results.length,
|
||||
results
|
||||
})
|
||||
}
|
||||
|
||||
// 创建规则
|
||||
if (action === 'create') {
|
||||
if (!rule?.name || !rule?.type || !rule?.targetField) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '规则名称、类型和目标字段为必填项'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
const newRule: CleaningRule = {
|
||||
id: `rule_${Date.now()}`,
|
||||
...rule,
|
||||
priority: CLEANING_RULES.length + 1,
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString().split('T')[0],
|
||||
processedCount: 0
|
||||
}
|
||||
|
||||
// TODO: 保存到数据库
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
rule: newRule,
|
||||
message: '清洗规则创建成功'
|
||||
})
|
||||
}
|
||||
|
||||
// 更新规则状态
|
||||
if (action === 'toggle') {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `规则 ${ruleId} 状态已更新`
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '未知操作'
|
||||
}, { status: 400 })
|
||||
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
286
app/api/crowd-pools/route.ts
Normal file
286
app/api/crowd-pools/route.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getMongoClient } from '@/lib/mongodb'
|
||||
|
||||
// 项目定义(基于真实数据库)
|
||||
const PROJECTS = {
|
||||
ckb: {
|
||||
id: 'ckb',
|
||||
name: '存客宝',
|
||||
database: 'KR_存客宝',
|
||||
collection: '用户资产统一视图',
|
||||
color: 'blue',
|
||||
icon: 'Users'
|
||||
},
|
||||
dlm: {
|
||||
id: 'dlm',
|
||||
name: '点了码',
|
||||
database: 'KR_点了码',
|
||||
collection: '用户资产统一视图',
|
||||
color: 'green',
|
||||
icon: 'QrCode'
|
||||
},
|
||||
weibo: {
|
||||
id: 'weibo',
|
||||
name: '微博',
|
||||
database: 'KR_微博',
|
||||
collection: '微博uid+手机',
|
||||
color: 'orange',
|
||||
icon: 'Globe'
|
||||
},
|
||||
qq: {
|
||||
id: 'qq',
|
||||
name: 'QQ社交',
|
||||
database: 'KR_腾讯',
|
||||
collection: 'QQ+手机',
|
||||
color: 'purple',
|
||||
icon: 'MessageCircle'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取项目列表及统计
|
||||
async function getProjectStats() {
|
||||
const client = await getMongoClient()
|
||||
const stats = []
|
||||
|
||||
for (const [key, project] of Object.entries(PROJECTS)) {
|
||||
try {
|
||||
const db = client.db(project.database)
|
||||
const count = await db.collection(project.collection).estimatedDocumentCount()
|
||||
stats.push({
|
||||
...project,
|
||||
userCount: count,
|
||||
status: 'active'
|
||||
})
|
||||
} catch {
|
||||
stats.push({
|
||||
...project,
|
||||
userCount: 0,
|
||||
status: 'error'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// 获取项目下的流量池/标签分类
|
||||
async function getProjectPools(projectId: string) {
|
||||
const client = await getMongoClient()
|
||||
const project = PROJECTS[projectId as keyof typeof PROJECTS]
|
||||
|
||||
if (!project) {
|
||||
return { error: '项目不存在' }
|
||||
}
|
||||
|
||||
const db = client.db(project.database)
|
||||
const collection = db.collection(project.collection)
|
||||
|
||||
// 根据项目类型获取不同的分组字段
|
||||
let pools: any[] = []
|
||||
|
||||
if (projectId === 'ckb') {
|
||||
// 存客宝按流量池分组
|
||||
const poolStats = await collection.aggregate([
|
||||
{ $sample: { size: 50000 } },
|
||||
{ $group: {
|
||||
_id: '$traffic_pool.pool_name',
|
||||
count: { $sum: 1 },
|
||||
avgScore: { $avg: '$user_evaluation_score' }
|
||||
}},
|
||||
{ $sort: { count: -1 } }
|
||||
], { maxTimeMS: 10000 }).toArray()
|
||||
|
||||
// 按标签分组
|
||||
const tagStats = await collection.aggregate([
|
||||
{ $sample: { size: 50000 } },
|
||||
{ $unwind: '$unified_tags' },
|
||||
{ $group: { _id: '$unified_tags', count: { $sum: 1 } }},
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 20 }
|
||||
], { maxTimeMS: 10000 }).toArray()
|
||||
|
||||
const total = await collection.estimatedDocumentCount()
|
||||
const ratio = total / 50000
|
||||
|
||||
pools = [
|
||||
...poolStats.filter(p => p._id).map(p => ({
|
||||
id: `pool_${p._id}`,
|
||||
name: p._id,
|
||||
type: 'pool',
|
||||
count: Math.round(p.count * ratio),
|
||||
avgScore: Math.round(p.avgScore || 0)
|
||||
})),
|
||||
...tagStats.filter(t => t._id).map(t => ({
|
||||
id: `tag_${t._id}`,
|
||||
name: t._id,
|
||||
type: 'tag',
|
||||
count: Math.round(t.count * ratio)
|
||||
}))
|
||||
]
|
||||
} else if (projectId === 'dlm') {
|
||||
// 点了码按角色标签分组
|
||||
const roleStats = await collection.aggregate([
|
||||
{ $unwind: { path: '$角色标签', preserveNullAndEmptyArrays: true } },
|
||||
{ $group: { _id: '$角色标签', count: { $sum: 1 } }},
|
||||
{ $sort: { count: -1 } }
|
||||
], { maxTimeMS: 10000 }).toArray()
|
||||
|
||||
// 按用户等级分组
|
||||
const levelStats = await collection.aggregate([
|
||||
{ $group: { _id: '$用户等级', count: { $sum: 1 } }},
|
||||
{ $sort: { count: -1 } }
|
||||
], { maxTimeMS: 10000 }).toArray()
|
||||
|
||||
pools = [
|
||||
...roleStats.filter(r => r._id).map(r => ({
|
||||
id: `role_${r._id}`,
|
||||
name: r._id,
|
||||
type: 'role',
|
||||
count: r.count
|
||||
})),
|
||||
...levelStats.filter(l => l._id).map(l => ({
|
||||
id: `level_${l._id}`,
|
||||
name: `${l._id}级用户`,
|
||||
type: 'level',
|
||||
count: l.count
|
||||
}))
|
||||
]
|
||||
} else if (projectId === 'qq') {
|
||||
// QQ按省份分组
|
||||
const provinceStats = await collection.aggregate([
|
||||
{ $sample: { size: 100000 } },
|
||||
{ $match: { '省份': { $exists: true, $ne: null } } },
|
||||
{ $group: { _id: '$省份', count: { $sum: 1 } }},
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 20 }
|
||||
], { maxTimeMS: 10000 }).toArray()
|
||||
|
||||
const total = await collection.estimatedDocumentCount()
|
||||
const ratio = total / 100000
|
||||
|
||||
pools = provinceStats.map(p => ({
|
||||
id: `province_${p._id}`,
|
||||
name: p._id,
|
||||
type: 'province',
|
||||
count: Math.round(p.count * ratio)
|
||||
}))
|
||||
} else if (projectId === 'weibo') {
|
||||
// 微博简单统计
|
||||
const total = await collection.estimatedDocumentCount()
|
||||
pools = [{
|
||||
id: 'weibo_all',
|
||||
name: '微博用户',
|
||||
type: 'all',
|
||||
count: total
|
||||
}]
|
||||
}
|
||||
|
||||
return {
|
||||
project,
|
||||
pools,
|
||||
totalPools: pools.length
|
||||
}
|
||||
}
|
||||
|
||||
// 获取流量池内的用户列表
|
||||
async function getPoolUsers(projectId: string, poolId: string, page: number = 1, limit: number = 20) {
|
||||
const client = await getMongoClient()
|
||||
const project = PROJECTS[projectId as keyof typeof PROJECTS]
|
||||
|
||||
if (!project) {
|
||||
return { error: '项目不存在' }
|
||||
}
|
||||
|
||||
const db = client.db(project.database)
|
||||
const collection = db.collection(project.collection)
|
||||
|
||||
// 解析poolId构建查询条件
|
||||
let query: any = {}
|
||||
const [type, ...nameParts] = poolId.split('_')
|
||||
const name = nameParts.join('_')
|
||||
|
||||
console.log('Pool query:', { type, name, projectId, poolId })
|
||||
|
||||
if (type === 'pool') {
|
||||
query['traffic_pool.pool_name'] = name
|
||||
} else if (type === 'tag') {
|
||||
query['unified_tags'] = name
|
||||
} else if (type === 'role') {
|
||||
query['角色标签'] = name
|
||||
} else if (type === 'level') {
|
||||
query['用户等级'] = name.replace('级用户', '')
|
||||
} else if (type === 'province') {
|
||||
query['省份'] = name
|
||||
}
|
||||
|
||||
// 如果没有匹配条件,尝试直接用name搜索
|
||||
if (Object.keys(query).length === 0 || type === 'all') {
|
||||
query = {}
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
collection.find(query)
|
||||
.project({
|
||||
phone_masked: 1,
|
||||
name: 1,
|
||||
'core_profile.name': 1,
|
||||
user_evaluation_score: 1,
|
||||
unified_tags: 1,
|
||||
'角色标签': 1,
|
||||
'用户等级': 1,
|
||||
'rfm_scores.user_level': 1,
|
||||
created_at: 1
|
||||
})
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.toArray(),
|
||||
collection.countDocuments(query)
|
||||
])
|
||||
|
||||
return {
|
||||
users: users.map(u => ({
|
||||
id: u._id.toString(),
|
||||
phone: u.phone_masked || '未知',
|
||||
name: u.name || u.core_profile?.name || '未知用户',
|
||||
score: u.user_evaluation_score || 0,
|
||||
level: u.rfm_scores?.user_level || u['用户等级'] || '-',
|
||||
tags: u.unified_tags || u['角色标签'] || [],
|
||||
createdAt: u.created_at
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
totalPages: Math.ceil(total / limit)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action') || 'projects'
|
||||
const projectId = searchParams.get('projectId')
|
||||
const poolId = searchParams.get('poolId')
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
|
||||
try {
|
||||
if (action === 'projects') {
|
||||
const stats = await getProjectStats()
|
||||
return NextResponse.json({ success: true, projects: stats })
|
||||
}
|
||||
|
||||
if (action === 'pools' && projectId) {
|
||||
const pools = await getProjectPools(projectId)
|
||||
return NextResponse.json({ success: true, ...pools })
|
||||
}
|
||||
|
||||
if (action === 'users' && projectId && poolId) {
|
||||
const users = await getPoolUsers(projectId, poolId, page)
|
||||
return NextResponse.json({ success: true, ...users })
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: '无效的操作' }, { status: 400 })
|
||||
} catch (error) {
|
||||
console.error('流量池API错误:', error)
|
||||
return NextResponse.json({ error: '查询失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
297
app/api/data-sources/route.ts
Normal file
297
app/api/data-sources/route.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* 数据源管理 API
|
||||
* 提供数据源列表、连接状态检测、同步管理等功能
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getMongoClient, getDatabaseStats } from '@/lib/mongodb'
|
||||
|
||||
// 数据源接口定义
|
||||
interface DataSource {
|
||||
id: string
|
||||
name: string
|
||||
nameCn: string // 中文名称
|
||||
description: string // 功能描述
|
||||
type: 'mongodb' | 'mysql' | 'api' | 'webhook'
|
||||
status: 'connected' | 'disconnected' | 'warning'
|
||||
host?: string
|
||||
database?: string
|
||||
endpoint?: string
|
||||
lastSync: string
|
||||
recordCount: number
|
||||
syncFrequency: string
|
||||
collections?: number
|
||||
tables?: number
|
||||
latency?: number
|
||||
dataCategory?: string // 数据分类
|
||||
targetCollection?: string // 目标集合(数据中台分配)
|
||||
}
|
||||
|
||||
// 数据库中文名称和描述映射
|
||||
const DB_DESCRIPTIONS: Record<string, { nameCn: string; description: string; category: string }> = {
|
||||
'KR': { nameCn: '核心用户库', description: '用户估值、RFM评分、统一画像', category: '用户画像' },
|
||||
'KR_KR': { nameCn: '扩展用户库', description: '多源用户数据汇聚', category: '用户数据' },
|
||||
'KR_Linkedln': { nameCn: '领英数据库', description: '职业社交平台用户数据', category: '社交数据' },
|
||||
'KR_京东': { nameCn: '京东用户库', description: '京东电商用户消费数据', category: '电商数据' },
|
||||
'KR_人才库': { nameCn: '人才数据库', description: '招聘平台人才信息', category: '人力资源' },
|
||||
'KR_企业': { nameCn: '企业信息库', description: '企业工商注册信息', category: '企业数据' },
|
||||
'KR_企业名录': { nameCn: '企业名录', description: '企业联系方式和经营信息', category: '企业数据' },
|
||||
'KR_卡若私域': { nameCn: '卡若私域库', description: '私域运营用户数据', category: '私域数据' },
|
||||
'KR_商城': { nameCn: '商城用户库', description: '电商平台用户交易数据', category: '电商数据' },
|
||||
'KR_国外': { nameCn: '海外用户库', description: '海外平台用户数据', category: '国际数据' },
|
||||
'KR_存客宝': { nameCn: '存客宝CRM', description: '私域CRM用户资产数据', category: '私域数据' },
|
||||
'KR_存客宝_四表重构KR_KR版': { nameCn: '存客宝重构版', description: '存客宝数据统一重构', category: '私域数据' },
|
||||
'KR_微博': { nameCn: '微博用户库', description: '微博UID与手机号关联', category: '社交数据' },
|
||||
'KR_快递': { nameCn: '快递信息库', description: '快递收发地址信息', category: '物流数据' },
|
||||
'KR_户口': { nameCn: '户籍信息库', description: '户籍地址信息', category: '身份数据' },
|
||||
'KR_手机': { nameCn: '手机号库', description: '手机号归属地和运营商', category: '基础数据' },
|
||||
'KR_投资': { nameCn: '投资信息库', description: '投资理财用户数据', category: '金融数据' },
|
||||
'KR_淘宝': { nameCn: '淘宝用户库', description: '淘宝电商用户数据', category: '电商数据' },
|
||||
'KR_游戏': { nameCn: '游戏用户库', description: '游戏平台用户数据', category: '娱乐数据' },
|
||||
'KR_点了码': { nameCn: '点了码商户库', description: '点了码商户和用户统一视图', category: '商业数据' },
|
||||
'KR_腾讯': { nameCn: '腾讯社交库', description: 'QQ号与手机号关联数据', category: '社交数据' },
|
||||
'KR_酒店': { nameCn: '酒店住宿库', description: '酒店入住记录信息', category: '消费数据' },
|
||||
'KR_顺丰': { nameCn: '顺丰快递库', description: '顺丰快递收发信息', category: '物流数据' },
|
||||
'KR_魔兽世界': { nameCn: '魔兽世界库', description: '魔兽世界玩家数据', category: '游戏数据' },
|
||||
}
|
||||
|
||||
// 获取 MongoDB 数据源列表
|
||||
async function getMongoDBSources(): Promise<DataSource[]> {
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
const dbList = await client.db().admin().listDatabases()
|
||||
const sources: DataSource[] = []
|
||||
|
||||
for (const dbInfo of dbList.databases) {
|
||||
if (dbInfo.name.startsWith('KR')) {
|
||||
const db = client.db(dbInfo.name)
|
||||
const collections = await db.listCollections().toArray()
|
||||
|
||||
let totalDocs = 0
|
||||
for (const coll of collections) {
|
||||
const count = await db.collection(coll.name).estimatedDocumentCount()
|
||||
totalDocs += count
|
||||
}
|
||||
|
||||
// 获取中文名称和描述
|
||||
const dbMeta = DB_DESCRIPTIONS[dbInfo.name] || {
|
||||
nameCn: dbInfo.name,
|
||||
description: `${dbInfo.name} 数据库`,
|
||||
category: '其他'
|
||||
}
|
||||
|
||||
sources.push({
|
||||
id: `mongo_${dbInfo.name}`,
|
||||
name: dbInfo.name,
|
||||
nameCn: dbMeta.nameCn,
|
||||
description: dbMeta.description,
|
||||
type: 'mongodb',
|
||||
status: 'connected',
|
||||
host: process.env.MONGODB_URI?.split('@')[1]?.split('/')[0] || 'localhost:27017',
|
||||
database: dbInfo.name,
|
||||
lastSync: '实时',
|
||||
recordCount: totalDocs,
|
||||
syncFrequency: '实时',
|
||||
collections: collections.length,
|
||||
latency: 0,
|
||||
dataCategory: dbMeta.category,
|
||||
targetCollection: 'KR.用户估值' // 默认目标集合
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return sources
|
||||
} catch (error) {
|
||||
console.error('获取 MongoDB 数据源失败:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 检测数据源连接状态
|
||||
async function checkConnectionStatus(): Promise<{
|
||||
mongodb: { connected: boolean; latency: number }
|
||||
mysql: { connected: boolean; latency: number }
|
||||
}> {
|
||||
const startTime = Date.now()
|
||||
let mongoConnected = false
|
||||
let mongoLatency = 0
|
||||
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
await client.db().admin().ping()
|
||||
mongoConnected = true
|
||||
mongoLatency = Date.now() - startTime
|
||||
} catch {
|
||||
mongoLatency = Date.now() - startTime
|
||||
}
|
||||
|
||||
return {
|
||||
mongodb: { connected: mongoConnected, latency: mongoLatency },
|
||||
mysql: { connected: false, latency: 0 } // MySQL 暂未实现
|
||||
}
|
||||
}
|
||||
|
||||
// GET: 获取数据源列表
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action')
|
||||
|
||||
try {
|
||||
// 检查连接状态
|
||||
if (action === 'status') {
|
||||
const status = await checkConnectionStatus()
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
status
|
||||
})
|
||||
}
|
||||
|
||||
// 获取数据库统计
|
||||
if (action === 'stats') {
|
||||
const stats = await getDatabaseStats()
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
stats
|
||||
})
|
||||
}
|
||||
|
||||
// 获取完整数据源列表
|
||||
const mongoSources = await getMongoDBSources()
|
||||
|
||||
// 计算统计信息
|
||||
const stats = await getDatabaseStats()
|
||||
|
||||
// 预定义的外部数据源(MySQL、API 等)
|
||||
const externalSources: DataSource[] = [
|
||||
{
|
||||
id: 'mysql_ckb',
|
||||
name: 'cunkebao_v3',
|
||||
nameCn: '存客宝MySQL主库',
|
||||
description: '存客宝CRM系统MySQL数据库,包含客户、订单、营销数据',
|
||||
type: 'mysql',
|
||||
status: 'warning',
|
||||
host: 'cdb-xxx.gz.tencentcdb.com:10050',
|
||||
database: 'cunkebao_v3',
|
||||
lastSync: '需要配置连接',
|
||||
recordCount: 0,
|
||||
syncFrequency: '增量同步',
|
||||
tables: 45,
|
||||
dataCategory: '私域数据',
|
||||
targetCollection: 'KR_存客宝.用户资产统一视图'
|
||||
},
|
||||
{
|
||||
id: 'mysql_dlm',
|
||||
name: 'dianlema',
|
||||
nameCn: '点了码MySQL库',
|
||||
description: '点了码商户系统,包含商户、用户、交易数据',
|
||||
type: 'mysql',
|
||||
status: 'warning',
|
||||
host: 'cdb-xxx.gz.tencentcdb.com:14413',
|
||||
database: 'dianlema',
|
||||
lastSync: '需要配置连接',
|
||||
recordCount: 0,
|
||||
syncFrequency: '增量同步',
|
||||
tables: 28,
|
||||
dataCategory: '商业数据',
|
||||
targetCollection: 'KR_点了码.用户资产统一视图'
|
||||
},
|
||||
{
|
||||
id: 'api_weibo',
|
||||
name: 'weibo_api',
|
||||
nameCn: '微博开放API',
|
||||
description: '微博开放平台API,获取用户公开信息和热点数据',
|
||||
type: 'api',
|
||||
status: 'disconnected',
|
||||
endpoint: 'https://api.weibo.com/2',
|
||||
lastSync: '未配置',
|
||||
recordCount: 0,
|
||||
syncFrequency: '按需调用',
|
||||
dataCategory: '社交数据',
|
||||
targetCollection: 'KR_微博.微博uid+手机'
|
||||
},
|
||||
{
|
||||
id: 'webhook_feishu',
|
||||
name: 'feishu_webhook',
|
||||
nameCn: '飞书机器人',
|
||||
description: '飞书机器人Webhook,接收对话消息并触发查询',
|
||||
type: 'webhook',
|
||||
status: 'connected',
|
||||
endpoint: '/api/feishu/webhook',
|
||||
lastSync: '实时',
|
||||
recordCount: 0,
|
||||
syncFrequency: '实时',
|
||||
dataCategory: '消息通道'
|
||||
}
|
||||
]
|
||||
|
||||
const allSources = [...mongoSources, ...externalSources]
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
sources: allSources,
|
||||
summary: {
|
||||
total: allSources.length,
|
||||
connected: allSources.filter(s => s.status === 'connected').length,
|
||||
warning: allSources.filter(s => s.status === 'warning').length,
|
||||
disconnected: allSources.filter(s => s.status === 'disconnected').length,
|
||||
totalRecords: stats.totalDocuments,
|
||||
totalSize: stats.totalSize,
|
||||
latency: stats.latency
|
||||
}
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('数据源 API 错误:', error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
sources: [],
|
||||
summary: {
|
||||
total: 0,
|
||||
connected: 0,
|
||||
warning: 0,
|
||||
disconnected: 0,
|
||||
totalRecords: 0,
|
||||
totalSize: 0,
|
||||
latency: 0
|
||||
}
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST: 测试数据源连接
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { type, config } = body
|
||||
|
||||
if (type === 'mongodb') {
|
||||
const { MongoClient } = await import('mongodb')
|
||||
const uri = config.uri || `mongodb://${config.username}:${config.password}@${config.host}/?authSource=admin`
|
||||
|
||||
const client = new MongoClient(uri, {
|
||||
serverSelectionTimeoutMS: 5000
|
||||
})
|
||||
|
||||
await client.connect()
|
||||
await client.db().admin().ping()
|
||||
await client.close()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'MongoDB 连接成功'
|
||||
})
|
||||
}
|
||||
|
||||
// 其他数据源类型暂未实现
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: `${type} 类型暂未支持`
|
||||
}, { status: 400 })
|
||||
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,195 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getDatabases, getDatabaseStructure } from "@/lib/mongodb-mock-connector" // 更新导入路径
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getMongoClient } from '@/lib/mongodb'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const database = searchParams.get("database")
|
||||
|
||||
if (database) {
|
||||
// 获取指定数据库的结构
|
||||
const structure = await getDatabaseStructure(database)
|
||||
return NextResponse.json({ success: true, data: structure })
|
||||
} else {
|
||||
// 获取所有数据库列表
|
||||
const databases = await getDatabases()
|
||||
return NextResponse.json({ success: true, data: databases })
|
||||
// 获取所有数据库结构
|
||||
async function getDatabaseStructure() {
|
||||
const client = await getMongoClient()
|
||||
const admin = client.db().admin()
|
||||
|
||||
// 获取数据库列表
|
||||
const dbList = await admin.listDatabases()
|
||||
const krDatabases = dbList.databases.filter(db => db.name.startsWith('KR'))
|
||||
|
||||
const structure = []
|
||||
|
||||
for (const dbInfo of krDatabases.slice(0, 10)) { // 限制前10个
|
||||
try {
|
||||
const db = client.db(dbInfo.name)
|
||||
const collections = await db.listCollections().toArray()
|
||||
|
||||
// 获取每个集合的字段示例
|
||||
const collectionDetails = []
|
||||
for (const col of collections.slice(0, 5)) { // 每个库最多5个集合
|
||||
try {
|
||||
const sample = await db.collection(col.name).findOne()
|
||||
const fields = sample ? Object.keys(sample).filter(k => k !== '_id').slice(0, 10) : []
|
||||
const count = await db.collection(col.name).estimatedDocumentCount()
|
||||
|
||||
collectionDetails.push({
|
||||
name: col.name,
|
||||
fields,
|
||||
count
|
||||
})
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
structure.push({
|
||||
database: dbInfo.name,
|
||||
sizeGB: (dbInfo.sizeOnDisk / 1024 / 1024 / 1024).toFixed(2),
|
||||
collections: collectionDetails
|
||||
})
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
return structure
|
||||
}
|
||||
|
||||
// 生成血缘节点
|
||||
function generateLineageNodes(structure: any[]) {
|
||||
const nodes: any[] = []
|
||||
const connections: any[] = []
|
||||
let yOffset = 50
|
||||
|
||||
// 数据源节点 (左侧)
|
||||
const colors = [
|
||||
'from-blue-400 to-blue-600',
|
||||
'from-orange-400 to-orange-600',
|
||||
'from-green-400 to-green-600',
|
||||
'from-red-400 to-red-600',
|
||||
'from-purple-400 to-purple-600',
|
||||
]
|
||||
|
||||
structure.forEach((db, i) => {
|
||||
const mainCol = db.collections[0]
|
||||
if (!mainCol) return
|
||||
|
||||
nodes.push({
|
||||
id: `source_${db.database}`,
|
||||
type: 'source',
|
||||
name: db.database.replace('KR_', ''),
|
||||
database: db.database,
|
||||
collection: mainCol.name,
|
||||
fields: mainCol.fields.slice(0, 5),
|
||||
x: 50,
|
||||
y: yOffset,
|
||||
color: colors[i % colors.length],
|
||||
count: mainCol.count,
|
||||
sizeGB: db.sizeGB
|
||||
})
|
||||
yOffset += 140
|
||||
})
|
||||
|
||||
// AI引擎节点 (中间)
|
||||
nodes.push({
|
||||
id: 'transform_ai',
|
||||
type: 'transform',
|
||||
name: 'AI标签引擎',
|
||||
fields: ['phone_norm', 'qq_norm', 'rfm_score', 'user_level', 'tags'],
|
||||
x: 400,
|
||||
y: 150,
|
||||
color: 'from-violet-400 to-violet-600'
|
||||
})
|
||||
|
||||
nodes.push({
|
||||
id: 'transform_clean',
|
||||
type: 'transform',
|
||||
name: '数据清洗',
|
||||
fields: ['unique_id', 'merged_data', 'quality'],
|
||||
x: 400,
|
||||
y: 350,
|
||||
color: 'from-yellow-400 to-yellow-600'
|
||||
})
|
||||
|
||||
// 目标节点 (右侧)
|
||||
nodes.push({
|
||||
id: 'target_valuation',
|
||||
type: 'target',
|
||||
name: '用户估值',
|
||||
database: 'KR',
|
||||
collection: '用户估值',
|
||||
fields: ['phone', 'qq', 'rfm_score', 'user_level', 'tags'],
|
||||
x: 750,
|
||||
y: 200,
|
||||
color: 'from-emerald-400 to-emerald-600'
|
||||
})
|
||||
|
||||
nodes.push({
|
||||
id: 'target_portrait',
|
||||
type: 'target',
|
||||
name: '用户画像',
|
||||
database: 'KR',
|
||||
collection: '用户画像',
|
||||
fields: ['user_id', 'portrait', 'behavior'],
|
||||
x: 750,
|
||||
y: 400,
|
||||
color: 'from-cyan-400 to-cyan-600'
|
||||
})
|
||||
|
||||
// 自动生成连接
|
||||
structure.forEach(db => {
|
||||
const sourceId = `source_${db.database}`
|
||||
const mainCol = db.collections[0]
|
||||
if (!mainCol) return
|
||||
|
||||
// 连接到AI引擎
|
||||
if (mainCol.fields.includes('phone') || mainCol.fields.includes('手机')) {
|
||||
connections.push({
|
||||
id: `conn_${sourceId}_phone`,
|
||||
sourceNode: sourceId,
|
||||
sourceField: mainCol.fields.includes('phone') ? 'phone' : '手机',
|
||||
targetNode: 'transform_ai',
|
||||
targetField: 'phone_norm'
|
||||
})
|
||||
}
|
||||
if (mainCol.fields.includes('qq') || mainCol.fields.includes('QQ')) {
|
||||
connections.push({
|
||||
id: `conn_${sourceId}_qq`,
|
||||
sourceNode: sourceId,
|
||||
sourceField: mainCol.fields.includes('qq') ? 'qq' : 'QQ',
|
||||
targetNode: 'transform_ai',
|
||||
targetField: 'qq_norm'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// AI引擎到目标
|
||||
connections.push({
|
||||
id: 'conn_ai_valuation',
|
||||
sourceNode: 'transform_ai',
|
||||
sourceField: 'rfm_score',
|
||||
targetNode: 'target_valuation',
|
||||
targetField: 'rfm_score'
|
||||
})
|
||||
connections.push({
|
||||
id: 'conn_clean_portrait',
|
||||
sourceNode: 'transform_clean',
|
||||
sourceField: 'merged_data',
|
||||
targetNode: 'target_portrait',
|
||||
targetField: 'portrait'
|
||||
})
|
||||
|
||||
return { nodes, connections }
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action') || 'structure'
|
||||
|
||||
try {
|
||||
const structure = await getDatabaseStructure()
|
||||
|
||||
if (action === 'lineage') {
|
||||
const lineage = generateLineageNodes(structure)
|
||||
return NextResponse.json({ success: true, ...lineage })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, databases: structure })
|
||||
} catch (error) {
|
||||
console.error("数据库结构查询失败:", error)
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "数据库结构查询失败", error: (error as Error).message },
|
||||
{ status: 500 },
|
||||
)
|
||||
console.error('数据库结构查询失败:', error)
|
||||
return NextResponse.json({ error: '查询失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
325
app/api/monitoring/route.ts
Normal file
325
app/api/monitoring/route.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getMongoClient } from '@/lib/mongodb'
|
||||
|
||||
// 获取数据库详细状态
|
||||
async function getDatabaseStats() {
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
const adminDb = client.db('admin')
|
||||
|
||||
// 获取服务器状态
|
||||
const serverStatus = await adminDb.command({ serverStatus: 1 })
|
||||
|
||||
// 获取所有KR_开头的数据库
|
||||
const dbList = await adminDb.admin().listDatabases()
|
||||
const krDatabases = dbList.databases.filter((db: any) =>
|
||||
db.name.startsWith('KR')
|
||||
)
|
||||
|
||||
// 统计各数据库信息
|
||||
const databaseDetails = await Promise.all(
|
||||
krDatabases.slice(0, 10).map(async (db: any) => {
|
||||
try {
|
||||
const database = client.db(db.name)
|
||||
const stats = await database.command({ dbStats: 1 })
|
||||
const collections = await database.listCollections().toArray()
|
||||
|
||||
return {
|
||||
name: db.name,
|
||||
sizeGB: (db.sizeOnDisk / (1024 * 1024 * 1024)).toFixed(2),
|
||||
collections: collections.length,
|
||||
objects: stats.objects || 0,
|
||||
indexes: stats.indexes || 0,
|
||||
avgObjSize: stats.avgObjSize || 0
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
name: db.name,
|
||||
sizeGB: (db.sizeOnDisk / (1024 * 1024 * 1024)).toFixed(2),
|
||||
collections: 0,
|
||||
objects: 0,
|
||||
indexes: 0,
|
||||
error: String(e)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
server: {
|
||||
version: serverStatus.version,
|
||||
uptime: serverStatus.uptime,
|
||||
uptimeHours: Math.floor(serverStatus.uptime / 3600),
|
||||
host: serverStatus.host,
|
||||
connections: {
|
||||
current: serverStatus.connections?.current || 0,
|
||||
available: serverStatus.connections?.available || 0,
|
||||
totalCreated: serverStatus.connections?.totalCreated || 0
|
||||
},
|
||||
memory: {
|
||||
resident: serverStatus.mem?.resident || 0,
|
||||
virtual: serverStatus.mem?.virtual || 0,
|
||||
mapped: serverStatus.mem?.mapped || 0
|
||||
},
|
||||
network: {
|
||||
bytesIn: serverStatus.network?.bytesIn || 0,
|
||||
bytesOut: serverStatus.network?.bytesOut || 0,
|
||||
numRequests: serverStatus.network?.numRequests || 0
|
||||
},
|
||||
opcounters: {
|
||||
insert: serverStatus.opcounters?.insert || 0,
|
||||
query: serverStatus.opcounters?.query || 0,
|
||||
update: serverStatus.opcounters?.update || 0,
|
||||
delete: serverStatus.opcounters?.delete || 0
|
||||
}
|
||||
},
|
||||
databases: {
|
||||
total: krDatabases.length,
|
||||
totalSizeGB: krDatabases.reduce((sum: number, db: any) =>
|
||||
sum + db.sizeOnDisk / (1024 * 1024 * 1024), 0
|
||||
).toFixed(2),
|
||||
details: databaseDetails
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: String(error),
|
||||
server: null,
|
||||
databases: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取健康检查
|
||||
async function getHealthStatus() {
|
||||
const services = []
|
||||
|
||||
// 检查MongoDB
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
const start = Date.now()
|
||||
await client.db('admin').command({ ping: 1 })
|
||||
const latency = Date.now() - start
|
||||
|
||||
services.push({
|
||||
name: 'MongoDB',
|
||||
status: latency < 100 ? 'healthy' : latency < 500 ? 'degraded' : 'unhealthy',
|
||||
latency: `${latency}ms`,
|
||||
message: latency < 100 ? '运行正常' : '响应较慢'
|
||||
})
|
||||
} catch (e) {
|
||||
services.push({
|
||||
name: 'MongoDB',
|
||||
status: 'unhealthy',
|
||||
latency: '-',
|
||||
message: String(e)
|
||||
})
|
||||
}
|
||||
|
||||
// 检查卡若AI网关
|
||||
try {
|
||||
const gatewayUrl = process.env.GATEWAY_URL || 'http://localhost:8000'
|
||||
const start = Date.now()
|
||||
const res = await fetch(`${gatewayUrl}/health`, {
|
||||
signal: AbortSignal.timeout(5000)
|
||||
})
|
||||
const latency = Date.now() - start
|
||||
|
||||
services.push({
|
||||
name: '卡若AI网关',
|
||||
status: res.ok ? 'healthy' : 'degraded',
|
||||
latency: `${latency}ms`,
|
||||
message: res.ok ? '运行正常' : '响应异常'
|
||||
})
|
||||
} catch (e) {
|
||||
services.push({
|
||||
name: '卡若AI网关',
|
||||
status: 'unhealthy',
|
||||
latency: '-',
|
||||
message: '无法连接'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查飞书服务
|
||||
try {
|
||||
const gatewayUrl = process.env.GATEWAY_URL || 'http://localhost:8000'
|
||||
const res = await fetch(`${gatewayUrl}/feishu/test`, {
|
||||
signal: AbortSignal.timeout(5000)
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
services.push({
|
||||
name: '飞书机器人',
|
||||
status: data.status === 'success' ? 'healthy' : 'degraded',
|
||||
latency: '-',
|
||||
message: data.message || '未知状态'
|
||||
})
|
||||
} catch (e) {
|
||||
services.push({
|
||||
name: '飞书机器人',
|
||||
status: 'unhealthy',
|
||||
latency: '-',
|
||||
message: '未配置或无法连接'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
services,
|
||||
overall: services.every(s => s.status === 'healthy') ? 'healthy' :
|
||||
services.some(s => s.status === 'unhealthy') ? 'unhealthy' : 'degraded'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取告警信息
|
||||
async function getAlerts() {
|
||||
// 从数据库状态生成告警
|
||||
const alerts = []
|
||||
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
const adminDb = client.db('admin')
|
||||
const serverStatus = await adminDb.command({ serverStatus: 1 })
|
||||
|
||||
// 检查连接数
|
||||
const connCurrent = serverStatus.connections?.current || 0
|
||||
const connAvailable = serverStatus.connections?.available || 0
|
||||
if (connCurrent > connAvailable * 0.8) {
|
||||
alerts.push({
|
||||
id: 'conn-high',
|
||||
type: 'warning',
|
||||
message: `MongoDB连接数较高 (${connCurrent}/${connAvailable})`,
|
||||
time: new Date().toISOString(),
|
||||
status: 'active'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查内存
|
||||
const memResident = serverStatus.mem?.resident || 0
|
||||
if (memResident > 8000) { // 8GB
|
||||
alerts.push({
|
||||
id: 'mem-high',
|
||||
type: 'warning',
|
||||
message: `MongoDB内存使用较高 (${(memResident/1024).toFixed(1)}GB)`,
|
||||
time: new Date().toISOString(),
|
||||
status: 'active'
|
||||
})
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
alerts.push({
|
||||
id: 'mongo-error',
|
||||
type: 'error',
|
||||
message: `MongoDB连接失败: ${String(e)}`,
|
||||
time: new Date().toISOString(),
|
||||
status: 'active'
|
||||
})
|
||||
}
|
||||
|
||||
// 默认告警(如果没有问题)
|
||||
if (alerts.length === 0) {
|
||||
alerts.push({
|
||||
id: 'all-ok',
|
||||
type: 'info',
|
||||
message: '系统运行正常,无告警',
|
||||
time: new Date().toISOString(),
|
||||
status: 'resolved'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
alerts,
|
||||
activeCount: alerts.filter(a => a.status === 'active').length
|
||||
}
|
||||
}
|
||||
|
||||
// 获取业务指标
|
||||
async function getBusinessMetrics() {
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
|
||||
// 从KR.用户估值获取统计
|
||||
const krDb = client.db('KR')
|
||||
const userCollection = krDb.collection('用户估值')
|
||||
|
||||
const totalUsers = await userCollection.estimatedDocumentCount()
|
||||
|
||||
// 获取用户等级分布(采样)
|
||||
const levelStats = await userCollection.aggregate([
|
||||
{ $sample: { size: 10000 } },
|
||||
{ $match: { user_level: { $exists: true } } },
|
||||
{ $group: { _id: '$user_level', count: { $sum: 1 } } }
|
||||
], { maxTimeMS: 5000 }).toArray()
|
||||
|
||||
// 计算各等级占比
|
||||
const levelDistribution = levelStats.map(l => ({
|
||||
level: l._id || '未知',
|
||||
count: l.count,
|
||||
percentage: ((l.count / 10000) * 100).toFixed(1)
|
||||
}))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
metrics: {
|
||||
totalUsers,
|
||||
totalUsersFormatted: totalUsers >= 1000000000
|
||||
? `${(totalUsers/1000000000).toFixed(2)}B`
|
||||
: `${(totalUsers/1000000).toFixed(1)}M`,
|
||||
levelDistribution,
|
||||
lastUpdated: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: String(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action')
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'databases':
|
||||
return NextResponse.json(await getDatabaseStats())
|
||||
|
||||
case 'health':
|
||||
return NextResponse.json(await getHealthStatus())
|
||||
|
||||
case 'alerts':
|
||||
return NextResponse.json(await getAlerts())
|
||||
|
||||
case 'metrics':
|
||||
return NextResponse.json(await getBusinessMetrics())
|
||||
|
||||
default:
|
||||
// 返回综合状态
|
||||
const [dbStats, health, alerts, metrics] = await Promise.all([
|
||||
getDatabaseStats(),
|
||||
getHealthStatus(),
|
||||
getAlerts(),
|
||||
getBusinessMetrics()
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
database: dbStats,
|
||||
health,
|
||||
alerts,
|
||||
metrics
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: String(error)
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
768
app/api/open-api/route.ts
Normal file
768
app/api/open-api/route.ts
Normal file
@@ -0,0 +1,768 @@
|
||||
/**
|
||||
* 神射手开放API接口
|
||||
*
|
||||
* 提供给第三方系统(存客宝、点了码等)调用的开放接口
|
||||
* 支持数据流入、数据查询、标签完善、批量处理等功能
|
||||
*
|
||||
* @author 神射手团队
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import crypto from 'crypto'
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
interface Partner {
|
||||
id: string
|
||||
name: string
|
||||
apiKey: string
|
||||
apiSecret: string
|
||||
status: 'active' | 'inactive' | 'pending'
|
||||
permissions: {
|
||||
dataIngest: boolean
|
||||
dataQuery: boolean
|
||||
tagEnrich: boolean
|
||||
batchProcess: boolean
|
||||
}
|
||||
quotaConfig: {
|
||||
dailyLimit: number
|
||||
monthlyLimit: number
|
||||
rateLimit: number
|
||||
}
|
||||
}
|
||||
|
||||
interface APIResponse<T = any> {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: string
|
||||
code?: number
|
||||
}
|
||||
|
||||
// ==================== 模拟数据存储 ====================
|
||||
|
||||
// 模拟接入方数据(实际应存储在数据库中)
|
||||
const PARTNERS: Map<string, Partner> = new Map([
|
||||
['sk-ckb-xxxxxxxxxx', {
|
||||
id: 'ckb_001',
|
||||
name: '存客宝',
|
||||
apiKey: 'sk-ckb-xxxxxxxxxx',
|
||||
apiSecret: 'sec-ckb-xxxxxxxxxx',
|
||||
status: 'active',
|
||||
permissions: {
|
||||
dataIngest: true,
|
||||
dataQuery: true,
|
||||
tagEnrich: true,
|
||||
batchProcess: true,
|
||||
},
|
||||
quotaConfig: {
|
||||
dailyLimit: 10000,
|
||||
monthlyLimit: 300000,
|
||||
rateLimit: 100,
|
||||
},
|
||||
}],
|
||||
['sk-dlm-xxxxxxxxxx', {
|
||||
id: 'dlm_001',
|
||||
name: '点了码',
|
||||
apiKey: 'sk-dlm-xxxxxxxxxx',
|
||||
apiSecret: 'sec-dlm-xxxxxxxxxx',
|
||||
status: 'active',
|
||||
permissions: {
|
||||
dataIngest: true,
|
||||
dataQuery: true,
|
||||
tagEnrich: false,
|
||||
batchProcess: false,
|
||||
},
|
||||
quotaConfig: {
|
||||
dailyLimit: 5000,
|
||||
monthlyLimit: 150000,
|
||||
rateLimit: 50,
|
||||
},
|
||||
}],
|
||||
])
|
||||
|
||||
// 模拟调用计数(实际应使用Redis)
|
||||
const CALL_COUNTS: Map<string, { daily: number; monthly: number }> = new Map()
|
||||
|
||||
// 模拟批量任务存储
|
||||
const BATCH_TASKS: Map<string, {
|
||||
id: string
|
||||
partnerId: string
|
||||
type: string
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||
progress: number
|
||||
total: number
|
||||
processed: number
|
||||
failed: number
|
||||
createdAt: Date
|
||||
completedAt?: Date
|
||||
}> = new Map()
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
/**
|
||||
* 验证API请求
|
||||
*/
|
||||
function validateRequest(request: NextRequest): { valid: boolean; partner?: Partner; error?: string } {
|
||||
const authHeader = request.headers.get('Authorization')
|
||||
const apiSecret = request.headers.get('X-API-Secret')
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return { valid: false, error: '缺少Authorization头' }
|
||||
}
|
||||
|
||||
const apiKey = authHeader.replace('Bearer ', '')
|
||||
const partner = PARTNERS.get(apiKey)
|
||||
|
||||
if (!partner) {
|
||||
return { valid: false, error: '无效的API Key' }
|
||||
}
|
||||
|
||||
if (partner.status !== 'active') {
|
||||
return { valid: false, error: '接入方已被禁用' }
|
||||
}
|
||||
|
||||
// 验证API Secret(可选,增强安全性)
|
||||
if (apiSecret && apiSecret !== partner.apiSecret) {
|
||||
return { valid: false, error: 'API Secret验证失败' }
|
||||
}
|
||||
|
||||
// 检查配额
|
||||
const counts = CALL_COUNTS.get(partner.id) || { daily: 0, monthly: 0 }
|
||||
if (counts.daily >= partner.quotaConfig.dailyLimit) {
|
||||
return { valid: false, error: '已达到每日调用限额' }
|
||||
}
|
||||
if (counts.monthly >= partner.quotaConfig.monthlyLimit) {
|
||||
return { valid: false, error: '已达到每月调用限额' }
|
||||
}
|
||||
|
||||
// 更新调用计数
|
||||
CALL_COUNTS.set(partner.id, {
|
||||
daily: counts.daily + 1,
|
||||
monthly: counts.monthly + 1,
|
||||
})
|
||||
|
||||
return { valid: true, partner }
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证手机号格式
|
||||
*/
|
||||
function isValidPhone(phone: string): boolean {
|
||||
return /^1[3-9]\d{9}$/.test(phone)
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏处理
|
||||
*/
|
||||
function maskPhone(phone: string): string {
|
||||
if (!phone || phone.length !== 11) return phone
|
||||
return phone.slice(0, 3) + '****' + phone.slice(7)
|
||||
}
|
||||
|
||||
function maskName(name: string): string {
|
||||
if (!name) return name
|
||||
if (name.length <= 1) return name
|
||||
return name[0] + '*'.repeat(name.length - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟用户画像数据
|
||||
*/
|
||||
function getMockUserProfile(phone: string) {
|
||||
// 模拟从数据库获取用户画像
|
||||
const hash = crypto.createHash('md5').update(phone).digest('hex')
|
||||
const seed = parseInt(hash.slice(0, 8), 16)
|
||||
|
||||
const levels = ['S', 'A', 'B', 'C', 'D']
|
||||
const pools = ['钻石池', '黄金池', '白银池', '青铜池', '潜力池']
|
||||
const tagOptions = [
|
||||
'高价值用户', '优质用户', '电商活跃', '社交达人', '一线城市',
|
||||
'二线城市', '高频用户', '沉默用户', '新用户', '流失风险',
|
||||
'高消费', '低消费', '金融偏好', '科技爱好者', '时尚达人'
|
||||
]
|
||||
|
||||
const levelIndex = seed % 5
|
||||
const r = 1 + (seed % 5)
|
||||
const f = 1 + ((seed >> 4) % 5)
|
||||
const m = 1 + ((seed >> 8) % 5)
|
||||
const rfmScore = Math.round(r * 0.3 + f * 0.3 + m * 0.4) * 20
|
||||
|
||||
// 随机选择3-5个标签
|
||||
const numTags = 3 + (seed % 3)
|
||||
const tags: string[] = []
|
||||
for (let i = 0; i < numTags; i++) {
|
||||
const tagIndex = (seed + i * 7) % tagOptions.length
|
||||
if (!tags.includes(tagOptions[tagIndex])) {
|
||||
tags.push(tagOptions[tagIndex])
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
phone: maskPhone(phone),
|
||||
name: maskName('张三'),
|
||||
tags,
|
||||
rfm: { r, f, m, score: rfmScore },
|
||||
level: levels[levelIndex],
|
||||
traffic_pool: pools[levelIndex],
|
||||
data_sources: ['KR_存客宝', 'KR_腾讯'],
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟AI标签完善
|
||||
*/
|
||||
function enrichUserTags(phone: string, existingTags: string[] = []) {
|
||||
const profile = getMockUserProfile(phone)
|
||||
const allTags = [...new Set([...existingTags, ...profile.tags])]
|
||||
const newTags = allTags.filter(t => !existingTags.includes(t))
|
||||
|
||||
return {
|
||||
newTags,
|
||||
allTags,
|
||||
rfm_score: profile.rfm.score,
|
||||
level: profile.level,
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== API处理函数 ====================
|
||||
|
||||
/**
|
||||
* 处理数据流入
|
||||
*/
|
||||
async function handleIngest(request: NextRequest, partner: Partner): Promise<APIResponse> {
|
||||
if (!partner.permissions.dataIngest) {
|
||||
return { success: false, error: '无数据流入权限', code: 403 }
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { phone, name, source, tags = [], extra = {} } = body
|
||||
|
||||
if (!phone) {
|
||||
return { success: false, error: '缺少phone参数', code: 400 }
|
||||
}
|
||||
|
||||
if (!isValidPhone(phone)) {
|
||||
return { success: false, error: '手机号格式错误', code: 400 }
|
||||
}
|
||||
|
||||
if (!source) {
|
||||
return { success: false, error: '缺少source参数', code: 400 }
|
||||
}
|
||||
|
||||
// 模拟数据处理和标签完善
|
||||
const enrichResult = enrichUserTags(phone, tags)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
userId: `usr_${crypto.randomBytes(8).toString('hex')}`,
|
||||
enriched: true,
|
||||
originalTags: tags,
|
||||
newTags: enrichResult.newTags,
|
||||
allTags: enrichResult.allTags,
|
||||
rfm_score: enrichResult.rfm_score,
|
||||
user_level: enrichResult.level,
|
||||
source,
|
||||
processedAt: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: '请求体解析失败', code: 400 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理批量数据流入
|
||||
*/
|
||||
async function handleBatchIngest(request: NextRequest, partner: Partner): Promise<APIResponse> {
|
||||
if (!partner.permissions.dataIngest) {
|
||||
return { success: false, error: '无数据流入权限', code: 403 }
|
||||
}
|
||||
|
||||
if (!partner.permissions.batchProcess) {
|
||||
return { success: false, error: '无批量处理权限', code: 403 }
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { users = [], source, async: isAsync = false } = body
|
||||
|
||||
if (!Array.isArray(users) || users.length === 0) {
|
||||
return { success: false, error: '缺少users数组', code: 400 }
|
||||
}
|
||||
|
||||
if (users.length > 1000) {
|
||||
return { success: false, error: '单次最多支持1000条数据', code: 400 }
|
||||
}
|
||||
|
||||
if (!source) {
|
||||
return { success: false, error: '缺少source参数', code: 400 }
|
||||
}
|
||||
|
||||
if (isAsync) {
|
||||
// 创建异步任务
|
||||
const taskId = `task_${crypto.randomBytes(8).toString('hex')}`
|
||||
BATCH_TASKS.set(taskId, {
|
||||
id: taskId,
|
||||
partnerId: partner.id,
|
||||
type: 'ingest',
|
||||
status: 'processing',
|
||||
progress: 0,
|
||||
total: users.length,
|
||||
processed: 0,
|
||||
failed: 0,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
// 模拟异步处理(实际应使用队列)
|
||||
setTimeout(() => {
|
||||
const task = BATCH_TASKS.get(taskId)
|
||||
if (task) {
|
||||
task.status = 'completed'
|
||||
task.progress = 100
|
||||
task.processed = users.length
|
||||
task.completedAt = new Date()
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
taskId,
|
||||
total: users.length,
|
||||
processed: 0,
|
||||
status: 'processing',
|
||||
estimatedTime: `${Math.ceil(users.length / 100)}秒`,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
// 同步处理
|
||||
const results = users.map((user: { phone: string; name?: string; tags?: string[] }) => {
|
||||
if (!user.phone || !isValidPhone(user.phone)) {
|
||||
return { phone: user.phone, success: false, error: '手机号格式错误' }
|
||||
}
|
||||
const enrichResult = enrichUserTags(user.phone, user.tags || [])
|
||||
return {
|
||||
phone: maskPhone(user.phone),
|
||||
success: true,
|
||||
tags: enrichResult.allTags,
|
||||
level: enrichResult.level,
|
||||
}
|
||||
})
|
||||
|
||||
const successful = results.filter((r: { success: boolean }) => r.success).length
|
||||
const failed = results.length - successful
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total: users.length,
|
||||
processed: successful,
|
||||
failed,
|
||||
results,
|
||||
},
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: '请求体解析失败', code: 400 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理用户查询
|
||||
*/
|
||||
async function handleQuery(request: NextRequest, partner: Partner): Promise<APIResponse> {
|
||||
if (!partner.permissions.dataQuery) {
|
||||
return { success: false, error: '无数据查询权限', code: 403 }
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const phone = searchParams.get('phone')
|
||||
const qq = searchParams.get('qq')
|
||||
const fields = searchParams.get('fields')?.split(',')
|
||||
|
||||
if (!phone && !qq) {
|
||||
return { success: false, error: '缺少phone或qq参数', code: 400 }
|
||||
}
|
||||
|
||||
// 优先使用手机号查询
|
||||
const queryPhone = phone || `138${Math.floor(Math.random() * 100000000).toString().padStart(8, '0')}`
|
||||
|
||||
if (phone && !isValidPhone(phone)) {
|
||||
return { success: false, error: '手机号格式错误', code: 400 }
|
||||
}
|
||||
|
||||
const profile = getMockUserProfile(queryPhone)
|
||||
|
||||
// 根据fields参数过滤返回字段
|
||||
if (fields && fields.length > 0) {
|
||||
const filteredProfile: Record<string, any> = {}
|
||||
fields.forEach(field => {
|
||||
if (field in profile) {
|
||||
filteredProfile[field] = (profile as Record<string, any>)[field]
|
||||
}
|
||||
})
|
||||
filteredProfile.phone = profile.phone // 始终返回phone
|
||||
return { success: true, data: filteredProfile }
|
||||
}
|
||||
|
||||
return { success: true, data: profile }
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理批量用户查询
|
||||
*/
|
||||
async function handleBatchQuery(request: NextRequest, partner: Partner): Promise<APIResponse> {
|
||||
if (!partner.permissions.dataQuery) {
|
||||
return { success: false, error: '无数据查询权限', code: 403 }
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { phones = [], qqs = [], fields = [] } = body
|
||||
|
||||
const identifiers = [...phones, ...qqs]
|
||||
if (identifiers.length === 0) {
|
||||
return { success: false, error: '缺少phones或qqs参数', code: 400 }
|
||||
}
|
||||
|
||||
if (identifiers.length > 500) {
|
||||
return { success: false, error: '单次最多支持500条查询', code: 400 }
|
||||
}
|
||||
|
||||
const users: any[] = []
|
||||
const notFound: string[] = []
|
||||
|
||||
for (const phone of phones) {
|
||||
if (!isValidPhone(phone)) {
|
||||
notFound.push(phone)
|
||||
continue
|
||||
}
|
||||
|
||||
// 模拟30%的用户不存在
|
||||
if (Math.random() > 0.7) {
|
||||
notFound.push(phone)
|
||||
continue
|
||||
}
|
||||
|
||||
const profile = getMockUserProfile(phone)
|
||||
|
||||
if (fields.length > 0) {
|
||||
const filteredProfile: Record<string, any> = { phone: profile.phone }
|
||||
fields.forEach((field: string) => {
|
||||
if (field in profile) {
|
||||
filteredProfile[field] = (profile as Record<string, any>)[field]
|
||||
}
|
||||
})
|
||||
users.push(filteredProfile)
|
||||
} else {
|
||||
users.push(profile)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
users,
|
||||
notFound,
|
||||
total: phones.length,
|
||||
found: users.length,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: '请求体解析失败', code: 400 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理标签完善
|
||||
*/
|
||||
async function handleTagEnrich(request: NextRequest, partner: Partner): Promise<APIResponse> {
|
||||
if (!partner.permissions.tagEnrich) {
|
||||
return { success: false, error: '无标签完善权限', code: 403 }
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { phone, strategy = 'rfm', force = false } = body
|
||||
|
||||
if (!phone) {
|
||||
return { success: false, error: '缺少phone参数', code: 400 }
|
||||
}
|
||||
|
||||
if (!isValidPhone(phone)) {
|
||||
return { success: false, error: '手机号格式错误', code: 400 }
|
||||
}
|
||||
|
||||
const enrichResult = enrichUserTags(phone, [])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
phone: maskPhone(phone),
|
||||
strategy,
|
||||
force,
|
||||
newTags: enrichResult.newTags,
|
||||
allTags: enrichResult.allTags,
|
||||
rfm_score: enrichResult.rfm_score,
|
||||
level: enrichResult.level,
|
||||
enrichedAt: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: '请求体解析失败', code: 400 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理标签列表查询
|
||||
*/
|
||||
async function handleTagList(request: NextRequest, partner: Partner): Promise<APIResponse> {
|
||||
if (!partner.permissions.dataQuery) {
|
||||
return { success: false, error: '无数据查询权限', code: 403 }
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const category = searchParams.get('category')
|
||||
const source = searchParams.get('source')
|
||||
|
||||
// 模拟标签列表
|
||||
const allTags = [
|
||||
{ id: 'tag_001', name: '高价值用户', category: 'value', count: 1250000 },
|
||||
{ id: 'tag_002', name: '优质用户', category: 'value', count: 8500000 },
|
||||
{ id: 'tag_003', name: '普通用户', category: 'value', count: 32000000 },
|
||||
{ id: 'tag_004', name: '待激活用户', category: 'value', count: 68000000 },
|
||||
{ id: 'tag_005', name: '高频活跃', category: 'behavior', count: 5600000 },
|
||||
{ id: 'tag_006', name: '沉默用户', category: 'behavior', count: 12000000 },
|
||||
{ id: 'tag_007', name: '新用户', category: 'behavior', count: 3200000 },
|
||||
{ id: 'tag_008', name: '流失风险', category: 'behavior', count: 4500000 },
|
||||
{ id: 'tag_009', name: '一线城市', category: 'region', count: 15000000 },
|
||||
{ id: 'tag_010', name: '二线城市', category: 'region', count: 28000000 },
|
||||
{ id: 'tag_011', name: '电商活跃', category: 'preference', count: 8900000 },
|
||||
{ id: 'tag_012', name: '社交达人', category: 'preference', count: 4200000 },
|
||||
{ id: 'tag_013', name: '金融偏好', category: 'preference', count: 2100000 },
|
||||
{ id: 'tag_014', name: '科技爱好者', category: 'preference', count: 3600000 },
|
||||
]
|
||||
|
||||
let filteredTags = allTags
|
||||
if (category) {
|
||||
filteredTags = filteredTags.filter(t => t.category === category)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
tags: filteredTags,
|
||||
total: filteredTags.length,
|
||||
categories: ['value', 'behavior', 'region', 'preference'],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理批量任务创建
|
||||
*/
|
||||
async function handleBatchTask(request: NextRequest, partner: Partner): Promise<APIResponse> {
|
||||
if (!partner.permissions.batchProcess) {
|
||||
return { success: false, error: '无批量处理权限', code: 403 }
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { type, config, callback } = body
|
||||
|
||||
if (!type) {
|
||||
return { success: false, error: '缺少type参数', code: 400 }
|
||||
}
|
||||
|
||||
if (!['enrich', 'export', 'sync'].includes(type)) {
|
||||
return { success: false, error: '不支持的任务类型', code: 400 }
|
||||
}
|
||||
|
||||
const taskId = `task_${crypto.randomBytes(8).toString('hex')}`
|
||||
|
||||
BATCH_TASKS.set(taskId, {
|
||||
id: taskId,
|
||||
partnerId: partner.id,
|
||||
type,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
total: 0,
|
||||
processed: 0,
|
||||
failed: 0,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
// 模拟异步处理
|
||||
setTimeout(() => {
|
||||
const task = BATCH_TASKS.get(taskId)
|
||||
if (task) {
|
||||
task.status = 'processing'
|
||||
task.total = 10000
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
setTimeout(() => {
|
||||
const task = BATCH_TASKS.get(taskId)
|
||||
if (task) {
|
||||
task.status = 'completed'
|
||||
task.progress = 100
|
||||
task.processed = 9995
|
||||
task.failed = 5
|
||||
task.completedAt = new Date()
|
||||
}
|
||||
}, 10000)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
taskId,
|
||||
type,
|
||||
status: 'pending',
|
||||
estimatedTime: '10分钟',
|
||||
callback: callback || null,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: '请求体解析失败', code: 400 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理批量任务状态查询
|
||||
*/
|
||||
async function handleBatchStatus(request: NextRequest, partner: Partner): Promise<APIResponse> {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const taskId = searchParams.get('taskId')
|
||||
|
||||
if (!taskId) {
|
||||
return { success: false, error: '缺少taskId参数', code: 400 }
|
||||
}
|
||||
|
||||
const task = BATCH_TASKS.get(taskId)
|
||||
|
||||
if (!task) {
|
||||
return { success: false, error: '任务不存在', code: 404 }
|
||||
}
|
||||
|
||||
if (task.partnerId !== partner.id) {
|
||||
return { success: false, error: '无权查看此任务', code: 403 }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
taskId: task.id,
|
||||
type: task.type,
|
||||
status: task.status,
|
||||
progress: task.progress,
|
||||
total: task.total,
|
||||
processed: task.processed,
|
||||
failed: task.failed,
|
||||
createdAt: task.createdAt.toISOString(),
|
||||
completedAt: task.completedAt?.toISOString() || null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取接入方列表
|
||||
*/
|
||||
async function handleGetPartners(): Promise<APIResponse> {
|
||||
const partners = Array.from(PARTNERS.values()).map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
status: p.status,
|
||||
permissions: p.permissions,
|
||||
quotaConfig: p.quotaConfig,
|
||||
}))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { partners, total: partners.length },
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 路由处理 ====================
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action')
|
||||
|
||||
// 获取接入方列表(管理接口,不需要认证)
|
||||
if (action === 'partners') {
|
||||
const result = await handleGetPartners()
|
||||
return NextResponse.json(result)
|
||||
}
|
||||
|
||||
// 其他GET请求需要认证
|
||||
const validation = validateRequest(request)
|
||||
if (!validation.valid) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: validation.error, code: 401 },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const partner = validation.partner!
|
||||
const endpoint = searchParams.get('endpoint')
|
||||
|
||||
let result: APIResponse
|
||||
|
||||
switch (endpoint) {
|
||||
case 'query/user':
|
||||
result = await handleQuery(request, partner)
|
||||
break
|
||||
case 'tag/list':
|
||||
result = await handleTagList(request, partner)
|
||||
break
|
||||
case 'batch/status':
|
||||
result = await handleBatchStatus(request, partner)
|
||||
break
|
||||
default:
|
||||
result = { success: false, error: '未知的API端点', code: 404 }
|
||||
}
|
||||
|
||||
return NextResponse.json(result, { status: result.code || 200 })
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const validation = validateRequest(request)
|
||||
if (!validation.valid) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: validation.error, code: 401 },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const partner = validation.partner!
|
||||
const { searchParams } = new URL(request.url)
|
||||
const endpoint = searchParams.get('endpoint')
|
||||
|
||||
let result: APIResponse
|
||||
|
||||
switch (endpoint) {
|
||||
case 'ingest/user':
|
||||
result = await handleIngest(request, partner)
|
||||
break
|
||||
case 'ingest/batch':
|
||||
result = await handleBatchIngest(request, partner)
|
||||
break
|
||||
case 'query/batch':
|
||||
result = await handleBatchQuery(request, partner)
|
||||
break
|
||||
case 'tag/enrich':
|
||||
result = await handleTagEnrich(request, partner)
|
||||
break
|
||||
case 'batch/task':
|
||||
result = await handleBatchTask(request, partner)
|
||||
break
|
||||
default:
|
||||
result = { success: false, error: '未知的API端点', code: 404 }
|
||||
}
|
||||
|
||||
return NextResponse.json(result, { status: result.code || 200 })
|
||||
}
|
||||
656
app/api/portrait/route.ts
Normal file
656
app/api/portrait/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
411
app/api/tags/route.ts
Normal file
411
app/api/tags/route.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* 标签体系 API
|
||||
* 提供标签列表、标签统计、标签管理等功能
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getMongoClient, getDatabase } from '@/lib/mongodb'
|
||||
|
||||
// 标签接口定义
|
||||
interface Tag {
|
||||
id: string
|
||||
name: string
|
||||
category: string
|
||||
type: 'system' | 'custom' | 'ai'
|
||||
userCount: number
|
||||
description?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
// 标签分类
|
||||
const TAG_CATEGORIES = {
|
||||
value: { name: '价值标签', icon: 'Star', color: 'yellow' },
|
||||
behavior: { name: '行为标签', icon: 'Activity', color: 'blue' },
|
||||
channel: { name: '渠道标签', icon: 'Share2', color: 'green' },
|
||||
region: { name: '地域标签', icon: 'MapPin', color: 'purple' },
|
||||
lifecycle: { name: '生命周期', icon: 'Clock', color: 'orange' },
|
||||
preference: { name: '偏好标签', icon: 'Heart', color: 'red' }
|
||||
}
|
||||
|
||||
// 从数据库获取标签统计(优化版:使用采样和限制)
|
||||
async function getTagsFromDB(): Promise<Tag[]> {
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
const db = client.db('KR')
|
||||
const collection = db.collection('用户估值')
|
||||
|
||||
// 使用 $sample 采样代替全表扫描,大幅提升性能
|
||||
const sampleSize = 100000 // 采样10万条估算分布
|
||||
|
||||
// 聚合统计用户等级分布(采样)
|
||||
const levelStats = await collection.aggregate([
|
||||
{ $sample: { size: sampleSize } },
|
||||
{ $match: { user_level: { $exists: true, $ne: null } } },
|
||||
{ $group: { _id: '$user_level', count: { $sum: 1 } } }
|
||||
], { maxTimeMS: 10000 }).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: 20 }
|
||||
], { maxTimeMS: 10000 }).toArray()
|
||||
|
||||
// 获取总文档数用于估算
|
||||
const totalDocs = await collection.estimatedDocumentCount()
|
||||
|
||||
// 构建标签列表
|
||||
const tags: Tag[] = []
|
||||
let tagId = 1
|
||||
|
||||
// 价值标签(用户等级)- 按采样比例估算实际数量
|
||||
const sampleRatio = totalDocs / sampleSize
|
||||
for (const level of levelStats) {
|
||||
if (level._id) {
|
||||
tags.push({
|
||||
id: `tag_${tagId++}`,
|
||||
name: level._id,
|
||||
category: 'value',
|
||||
type: 'system',
|
||||
userCount: Math.round(level.count * sampleRatio), // 估算实际数量
|
||||
description: `${level._id}用户群体`,
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: new Date().toISOString().split('T')[0],
|
||||
source: 'RFM计算'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 地域标签
|
||||
for (const province of provinceStats) {
|
||||
if (province._id) {
|
||||
tags.push({
|
||||
id: `tag_${tagId++}`,
|
||||
name: province._id,
|
||||
category: 'region',
|
||||
type: 'system',
|
||||
userCount: Math.round(province.count * sampleRatio), // 估算实际数量
|
||||
description: `来自${province._id}的用户`,
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: new Date().toISOString().split('T')[0],
|
||||
source: '地域分析'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 添加预定义的行为标签和生命周期标签
|
||||
const predefinedTags: Partial<Tag>[] = [
|
||||
{ name: '高频活跃', category: 'behavior', description: '近30天活跃次数>10', userCount: 0 },
|
||||
{ name: '流失风险', category: 'lifecycle', description: '90天未活跃', userCount: 0 },
|
||||
{ name: '新用户', category: 'lifecycle', description: '注册30天内', userCount: 0 },
|
||||
{ name: '沉睡用户', category: 'lifecycle', description: '60天未活跃', userCount: 0 },
|
||||
{ name: '微信渠道', category: 'channel', description: '通过微信获客', userCount: 0 },
|
||||
{ name: '抖音渠道', category: 'channel', description: '通过抖音获客', userCount: 0 },
|
||||
{ name: '内容偏好', category: 'preference', description: '偏好内容消费', userCount: 0 },
|
||||
{ name: '社交偏好', category: 'preference', description: '偏好社交互动', userCount: 0 }
|
||||
]
|
||||
|
||||
for (const tag of predefinedTags) {
|
||||
tags.push({
|
||||
id: `tag_${tagId++}`,
|
||||
name: tag.name!,
|
||||
category: tag.category!,
|
||||
type: 'ai',
|
||||
userCount: tag.userCount || Math.floor(Math.random() * 100000),
|
||||
description: tag.description,
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: new Date().toISOString().split('T')[0],
|
||||
source: 'AI标签引擎'
|
||||
})
|
||||
}
|
||||
|
||||
return tags
|
||||
} catch (error) {
|
||||
console.error('获取标签失败:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// GET: 获取标签列表
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const category = searchParams.get('category')
|
||||
const type = searchParams.get('type')
|
||||
const search = searchParams.get('search')
|
||||
const action = searchParams.get('action')
|
||||
|
||||
try {
|
||||
// 获取分类统计
|
||||
if (action === 'categories') {
|
||||
const tags = await getTagsFromDB()
|
||||
const categoryStats = Object.entries(TAG_CATEGORIES).map(([key, value]) => {
|
||||
const categoryTags = tags.filter(t => t.category === key)
|
||||
return {
|
||||
id: key,
|
||||
...value,
|
||||
tagCount: categoryTags.length,
|
||||
userCount: categoryTags.reduce((sum, t) => sum + t.userCount, 0)
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
categories: categoryStats
|
||||
})
|
||||
}
|
||||
|
||||
// 获取标签列表
|
||||
let tags = await getTagsFromDB()
|
||||
|
||||
// 筛选
|
||||
if (category) {
|
||||
tags = tags.filter(t => t.category === category)
|
||||
}
|
||||
if (type) {
|
||||
tags = tags.filter(t => t.type === type)
|
||||
}
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase()
|
||||
tags = tags.filter(t =>
|
||||
t.name.toLowerCase().includes(searchLower) ||
|
||||
t.description?.toLowerCase().includes(searchLower)
|
||||
)
|
||||
}
|
||||
|
||||
// 排序(按用户数降序)
|
||||
tags.sort((a, b) => b.userCount - a.userCount)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
tags,
|
||||
total: tags.length,
|
||||
categories: TAG_CATEGORIES
|
||||
})
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('标签 API 错误:', error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
tags: [],
|
||||
total: 0
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST: 创建自定义标签
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { name, category, description, rules } = body
|
||||
|
||||
// 验证必填字段
|
||||
if (!name || !category) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '标签名称和分类为必填项'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// 根据规则计算用户数
|
||||
let userCount = 0
|
||||
if (rules) {
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
const collection = client.db('KR').collection('用户估值')
|
||||
|
||||
// 解析简单规则
|
||||
const query = parseRuleToQuery(rules)
|
||||
userCount = await collection.countDocuments(query)
|
||||
} catch (e) {
|
||||
console.log('规则解析失败,使用默认用户数')
|
||||
}
|
||||
}
|
||||
|
||||
// 创建标签
|
||||
const newTag: Tag = {
|
||||
id: `tag_custom_${Date.now()}`,
|
||||
name,
|
||||
category,
|
||||
type: 'custom',
|
||||
userCount,
|
||||
description,
|
||||
createdAt: new Date().toISOString().split('T')[0],
|
||||
updatedAt: new Date().toISOString().split('T')[0],
|
||||
source: '手动创建'
|
||||
}
|
||||
|
||||
// 保存到 MongoDB shensheshou.tags 集合
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
await client.db('shensheshou').collection('tags').insertOne({
|
||||
...newTag,
|
||||
rules,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
} catch (e) {
|
||||
console.log('保存标签到数据库失败,使用内存存储')
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
tag: newTag,
|
||||
message: `标签创建成功,匹配 ${userCount.toLocaleString()} 个用户`
|
||||
})
|
||||
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// PUT: 更新标签
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { id, name, category, description, rules, enabled } = body
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '标签ID为必填项'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// 根据规则重新计算用户数
|
||||
let userCount = 0
|
||||
if (rules) {
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
const collection = client.db('KR').collection('用户估值')
|
||||
const query = parseRuleToQuery(rules)
|
||||
userCount = await collection.countDocuments(query)
|
||||
} catch (e) {
|
||||
console.log('规则解析失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 更新数据库
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
await client.db('shensheshou').collection('tags').updateOne(
|
||||
{ id },
|
||||
{
|
||||
$set: {
|
||||
...(name && { name }),
|
||||
...(category && { category }),
|
||||
...(description !== undefined && { description }),
|
||||
...(rules !== undefined && { rules }),
|
||||
...(enabled !== undefined && { enabled }),
|
||||
...(userCount > 0 && { userCount }),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
},
|
||||
{ upsert: true }
|
||||
)
|
||||
} catch (e) {
|
||||
console.log('更新标签到数据库失败')
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '标签更新成功',
|
||||
userCount
|
||||
})
|
||||
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: 删除标签
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const id = searchParams.get('id')
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '标签ID为必填项'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// 从数据库删除
|
||||
try {
|
||||
const client = await getMongoClient()
|
||||
const result = await client.db('shensheshou').collection('tags').deleteOne({ id })
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: result.deletedCount > 0 ? '标签删除成功' : '标签不存在或已删除',
|
||||
deleted: result.deletedCount > 0
|
||||
})
|
||||
} catch (e) {
|
||||
// 如果是系统标签,不允许删除
|
||||
if (id.startsWith('tag_') && !id.startsWith('tag_custom_')) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '系统标签不允许删除'
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '标签已标记删除(数据库未配置)'
|
||||
})
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:解析规则字符串为MongoDB查询
|
||||
function parseRuleToQuery(rules: string): any {
|
||||
const query: any = {}
|
||||
|
||||
// 简单规则解析
|
||||
// 支持格式: field >= value, field = value, field IN (...)
|
||||
const patterns = [
|
||||
{ regex: /(\w+)\s*>=\s*(\d+)/g, handler: (field: string, value: string) => ({ [field]: { $gte: parseInt(value) } }) },
|
||||
{ regex: /(\w+)\s*<=\s*(\d+)/g, handler: (field: string, value: string) => ({ [field]: { $lte: parseInt(value) } }) },
|
||||
{ regex: /(\w+)\s*>\s*(\d+)/g, handler: (field: string, value: string) => ({ [field]: { $gt: parseInt(value) } }) },
|
||||
{ regex: /(\w+)\s*<\s*(\d+)/g, handler: (field: string, value: string) => ({ [field]: { $lt: parseInt(value) } }) },
|
||||
{ regex: /(\w+)\s*=\s*'([^']+)'/g, handler: (field: string, value: string) => ({ [field]: value }) },
|
||||
{ regex: /(\w+)\s*=\s*"([^"]+)"/g, handler: (field: string, value: string) => ({ [field]: value }) },
|
||||
]
|
||||
|
||||
// 字段映射
|
||||
const fieldMap: { [key: string]: string } = {
|
||||
'rfm_score': 'user_evaluation_score',
|
||||
'province': 'province',
|
||||
'city': 'city',
|
||||
'user_level': 'user_level',
|
||||
}
|
||||
|
||||
for (const pattern of patterns) {
|
||||
let match
|
||||
while ((match = pattern.regex.exec(rules)) !== null) {
|
||||
const field = fieldMap[match[1]] || match[1]
|
||||
const value = match[2]
|
||||
const condition = pattern.handler(field, value)
|
||||
Object.assign(query, condition)
|
||||
}
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
283
app/api/traffic-packages/route.ts
Normal file
283
app/api/traffic-packages/route.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* 流量包管理 API
|
||||
* 提供流量包列表、创建、导出等功能
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getMongoClient, maskPhone } from '@/lib/mongodb'
|
||||
|
||||
// 流量包接口
|
||||
interface TrafficPackage {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
userCount: number
|
||||
conditions: {
|
||||
userLevel?: string[]
|
||||
province?: string[]
|
||||
city?: string[]
|
||||
rfmScoreRange?: { min: number; max: number }
|
||||
tags?: string[]
|
||||
}
|
||||
status: 'active' | 'expired' | 'pending'
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
createdBy: string
|
||||
exportCount: number
|
||||
lastExportAt?: string
|
||||
}
|
||||
|
||||
// 预定义的流量池配置(基于 user_evaluation_score 字段,分数范围0-5000+)
|
||||
const TRAFFIC_POOLS = {
|
||||
diamond: { name: '钻石池', minScore: 3000, color: 'purple', icon: 'Diamond' },
|
||||
gold: { name: '黄金池', minScore: 2000, maxScore: 3000, color: 'yellow', icon: 'Award' },
|
||||
silver: { name: '白银池', minScore: 1000, maxScore: 2000, color: 'gray', icon: 'Medal' },
|
||||
bronze: { name: '青铜池', minScore: 500, maxScore: 1000, color: 'orange', icon: 'Shield' },
|
||||
potential: { name: '潜力池', maxScore: 500, color: 'blue', icon: 'TrendingUp' }
|
||||
}
|
||||
|
||||
// 获取流量池统计(使用采样优化性能)
|
||||
async function getTrafficPoolStats() {
|
||||
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 poolStats = [
|
||||
{ pool: 'potential', ...stats.find(s => s._id === 0) || { count: 0 } },
|
||||
{ pool: 'bronze', ...stats.find(s => s._id === 500) || { count: 0 } },
|
||||
{ pool: 'silver', ...stats.find(s => s._id === 1000) || { count: 0 } },
|
||||
{ pool: 'gold', ...stats.find(s => s._id === 2000) || { count: 0 } },
|
||||
{ pool: 'diamond', ...stats.find(s => s._id === 3000) || { count: 0 } }
|
||||
].map(s => ({
|
||||
...TRAFFIC_POOLS[s.pool as keyof typeof TRAFFIC_POOLS],
|
||||
id: s.pool,
|
||||
count: Math.round((s.count || 0) * sampleRatio), // 估算实际数量
|
||||
avgScore: Math.round((s.avgScore || 0) * 100) / 100
|
||||
}))
|
||||
|
||||
return poolStats
|
||||
} catch (error) {
|
||||
console.error('获取流量池统计失败:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 根据条件查询用户
|
||||
async function queryUsersByConditions(conditions: TrafficPackage['conditions']) {
|
||||
const client = await getMongoClient()
|
||||
const collection = client.db('KR').collection('用户估值')
|
||||
|
||||
const query: any = {}
|
||||
|
||||
if (conditions.userLevel?.length) {
|
||||
query.user_level = { $in: conditions.userLevel }
|
||||
}
|
||||
if (conditions.province?.length) {
|
||||
query.province = { $in: conditions.province }
|
||||
}
|
||||
if (conditions.city?.length) {
|
||||
query.city = { $in: conditions.city }
|
||||
}
|
||||
if (conditions.rfmScoreRange) {
|
||||
query.rfm_composite_score = {
|
||||
$gte: conditions.rfmScoreRange.min,
|
||||
$lte: conditions.rfmScoreRange.max
|
||||
}
|
||||
}
|
||||
if (conditions.tags?.length) {
|
||||
query.tags = { $in: conditions.tags }
|
||||
}
|
||||
|
||||
const count = await collection.countDocuments(query)
|
||||
const samples = await collection.find(query)
|
||||
.limit(100)
|
||||
.project({ phone: 1, name: 1, user_level: 1, province: 1, city: 1, rfm_composite_score: 1 })
|
||||
.toArray()
|
||||
|
||||
return { count, samples }
|
||||
}
|
||||
|
||||
// GET: 获取流量包列表或流量池统计
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action')
|
||||
const id = searchParams.get('id')
|
||||
|
||||
try {
|
||||
// 获取流量池统计
|
||||
if (action === 'pools') {
|
||||
const poolStats = await getTrafficPoolStats()
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
pools: poolStats
|
||||
})
|
||||
}
|
||||
|
||||
// 导出流量包用户数据
|
||||
if (action === 'export') {
|
||||
const packageId = searchParams.get('packageId')
|
||||
const pool = searchParams.get('pool') || 'gold'
|
||||
|
||||
const client = await getMongoClient()
|
||||
const collection = client.db('KR').collection('用户估值')
|
||||
|
||||
// 根据流量池类型获取用户
|
||||
const scoreRange = {
|
||||
diamond: { $gte: 3000 },
|
||||
gold: { $gte: 2000, $lt: 3000 },
|
||||
silver: { $gte: 1000, $lt: 2000 },
|
||||
bronze: { $gte: 500, $lt: 1000 },
|
||||
potential: { $lt: 500 }
|
||||
}
|
||||
|
||||
const query = { user_evaluation_score: scoreRange[pool as keyof typeof scoreRange] || { $gte: 2000 } }
|
||||
|
||||
const users = await collection.find(query)
|
||||
.limit(1000) // 限制导出数量
|
||||
.project({
|
||||
phone: '$phone_masked',
|
||||
name: 1,
|
||||
level: '$user_level',
|
||||
score: '$user_evaluation_score',
|
||||
tags: '$source_channels'
|
||||
})
|
||||
.toArray()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
users: users.map(u => ({
|
||||
phone: u.phone || u.phone_masked || '未知',
|
||||
name: u.name || '未知',
|
||||
level: u.level || '-',
|
||||
score: u.score || 0,
|
||||
tags: u.tags || []
|
||||
})),
|
||||
total: users.length
|
||||
})
|
||||
}
|
||||
|
||||
// 获取流量包详情
|
||||
if (id) {
|
||||
// TODO: 从数据库获取流量包详情
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
package: {
|
||||
id,
|
||||
name: '示例流量包',
|
||||
description: '测试描述',
|
||||
userCount: 1000,
|
||||
conditions: {},
|
||||
status: 'active',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
createdBy: 'admin',
|
||||
exportCount: 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取流量包列表(预定义 + 流量池)
|
||||
const poolStats = await getTrafficPoolStats()
|
||||
|
||||
const packages: TrafficPackage[] = poolStats.map(pool => ({
|
||||
id: `pool_${pool.id}`,
|
||||
name: `${pool.name}用户包`,
|
||||
description: `RFM评分 ${pool.id === 'diamond' ? '≥80' : pool.id === 'potential' ? '<20' : `${TRAFFIC_POOLS[pool.id as keyof typeof TRAFFIC_POOLS].minScore || 0}-${TRAFFIC_POOLS[pool.id as keyof typeof TRAFFIC_POOLS].maxScore || 100}`} 的用户群体`,
|
||||
userCount: pool.count,
|
||||
conditions: {
|
||||
rfmScoreRange: {
|
||||
min: TRAFFIC_POOLS[pool.id as keyof typeof TRAFFIC_POOLS].minScore || 0,
|
||||
max: TRAFFIC_POOLS[pool.id as keyof typeof TRAFFIC_POOLS].maxScore || 100
|
||||
}
|
||||
},
|
||||
status: 'active',
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: new Date().toISOString().split('T')[0],
|
||||
createdBy: 'system',
|
||||
exportCount: 0
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
packages,
|
||||
total: packages.length,
|
||||
pools: poolStats
|
||||
})
|
||||
|
||||
} 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 { name, description, conditions } = body
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '流量包名称为必填项'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// 查询符合条件的用户数
|
||||
const { count, samples } = await queryUsersByConditions(conditions || {})
|
||||
|
||||
const newPackage: TrafficPackage = {
|
||||
id: `pkg_${Date.now()}`,
|
||||
name,
|
||||
description: description || '',
|
||||
userCount: count,
|
||||
conditions: conditions || {},
|
||||
status: 'active',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
createdBy: 'admin',
|
||||
exportCount: 0
|
||||
}
|
||||
|
||||
// TODO: 保存到 MongoDB
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
package: newPackage,
|
||||
samples: samples.slice(0, 10).map(s => ({
|
||||
...s,
|
||||
phone: maskPhone(s.phone)
|
||||
}))
|
||||
})
|
||||
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error.message
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user