feat: sync Sidebar and BottomNav, standardize user profile API
Align Sidebar & BottomNav menus, remove "Search", add user profile mock data, implement /api/users, add FilterDrawer, complete Section, ProfileHeader, MetricsRFM components Co-authored-by: null <4804959+fnvtk@users.noreply.github.com>
This commit is contained in:
179
services/rfm-engine.ts
Normal file
179
services/rfm-engine.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
export type RFMScore = { R: number; F: number; M: number; total: number; grade: "S" | "A" | "B" | "C" | "D" }
|
||||
export type AnalyzeInput = {
|
||||
user_id: string
|
||||
last_active: string
|
||||
interactions: number
|
||||
amount: number
|
||||
chat_logs?: string[]
|
||||
source?: string
|
||||
}
|
||||
export type AnalyzeResult = {
|
||||
user_id: string
|
||||
rfm_score: RFMScore
|
||||
tags: {
|
||||
emotion?: "积极" | "中性" | "消极"
|
||||
behavior?: string[]
|
||||
intent?: "弱意图" | "中等意图" | "强意图"
|
||||
lifecycle?: "新用户" | "活跃用户" | "沉睡用户" | "流失风险"
|
||||
value?: "高" | "中" | "低"
|
||||
}
|
||||
weights: { R: number; F: number; M: number }
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
type GroupSummary = {
|
||||
gradeCount: Record<RFMScore["grade"], number>
|
||||
valueCount: Record<"高" | "中" | "低", number>
|
||||
lifecycleCount: Record<string, number>
|
||||
}
|
||||
|
||||
// 内存存储(后续可替换为 Mongo/ES)
|
||||
const store = new Map<string, AnalyzeResult>()
|
||||
let weights = { R: 0.5, F: 0.3, M: 0.2 }
|
||||
|
||||
export function getWeights() {
|
||||
return weights
|
||||
}
|
||||
export function setWeights(w: Partial<typeof weights>) {
|
||||
weights = { ...weights, ...w }
|
||||
}
|
||||
|
||||
// utils
|
||||
function clamp(n: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, n))
|
||||
}
|
||||
function daysFromNow(iso: string) {
|
||||
const d = new Date(iso).getTime()
|
||||
const now = Date.now()
|
||||
return Math.max(0, Math.floor((now - d) / (1000 * 60 * 60 * 24)))
|
||||
}
|
||||
|
||||
// 评分
|
||||
function scoreR(lastActiveISO: string): number {
|
||||
const days = daysFromNow(lastActiveISO)
|
||||
if (days <= 1) return 5
|
||||
if (days <= 3) return 4
|
||||
if (days <= 7) return 3
|
||||
if (days <= 30) return 2
|
||||
return 1
|
||||
}
|
||||
function scoreF(interactions: number): number {
|
||||
if (interactions >= 30) return 5
|
||||
if (interactions >= 15) return 4
|
||||
if (interactions >= 7) return 3
|
||||
if (interactions >= 3) return 2
|
||||
return 1
|
||||
}
|
||||
function scoreM(amount: number): number {
|
||||
if (amount >= 5000) return 5
|
||||
if (amount >= 2000) return 4
|
||||
if (amount >= 800) return 3
|
||||
if (amount >= 200) return 2
|
||||
return 1
|
||||
}
|
||||
function gradeFromTotal(t: number): RFMScore["grade"] {
|
||||
if (t >= 4.5) return "S"
|
||||
if (t >= 3.8) return "A"
|
||||
if (t >= 3.0) return "B"
|
||||
if (t >= 2.2) return "C"
|
||||
return "D"
|
||||
}
|
||||
function analyzeEmotion(chat?: string[]): "积极" | "中性" | "消极" | undefined {
|
||||
if (!chat || chat.length === 0) return undefined
|
||||
const joined = chat.join(" ")
|
||||
if (/[好棒|满意|喜欢|👍|推荐]/.test(joined)) return "积极"
|
||||
if (/[差|不行|失望|退款|投诉]/.test(joined)) return "消极"
|
||||
return "中性"
|
||||
}
|
||||
function analyzeIntent(chat?: string[], interactions?: number): "弱意图" | "中等意图" | "强意图" {
|
||||
const hasBuyWords = chat?.some((t) => /(购买|下单|价格|优惠|库存)/.test(t)) ?? false
|
||||
if (hasBuyWords && (interactions ?? 0) >= 20) return "强意图"
|
||||
if (hasBuyWords || (interactions ?? 0) >= 10) return "中等意图"
|
||||
return "弱意图"
|
||||
}
|
||||
function lifecycleByR(R: number): AnalyzeResult["tags"]["lifecycle"] {
|
||||
if (R >= 5) return "活跃用户"
|
||||
if (R >= 3) return "新用户"
|
||||
if (R === 2) return "沉睡用户"
|
||||
return "流失风险"
|
||||
}
|
||||
function valueByScore(total: number): "高" | "中" | "低" {
|
||||
if (total >= 4.0) return "高"
|
||||
if (total >= 2.8) return "中"
|
||||
return "低"
|
||||
}
|
||||
|
||||
export function computeRFM(input: AnalyzeInput): RFMScore {
|
||||
const R = scoreR(input.last_active)
|
||||
const F = scoreF(input.interactions)
|
||||
const M = scoreM(input.amount)
|
||||
const total = clamp(R * weights.R + F * weights.F + M * weights.M, 1, 5)
|
||||
const grade = gradeFromTotal(total)
|
||||
return { R, F, M, total: Number(total.toFixed(2)), grade }
|
||||
}
|
||||
|
||||
export function analyzeUser(input: AnalyzeInput): AnalyzeResult {
|
||||
const rfm = computeRFM(input)
|
||||
const tags: AnalyzeResult["tags"] = {
|
||||
emotion: analyzeEmotion(input.chat_logs),
|
||||
behavior: [],
|
||||
intent: analyzeIntent(input.chat_logs, input.interactions),
|
||||
lifecycle: lifecycleByR(rfm.R),
|
||||
value: valueByScore(rfm.total),
|
||||
}
|
||||
if (input.interactions >= 20) tags.behavior?.push("高频互动")
|
||||
if (input.amount >= 1000) tags.behavior?.push("高消费偏好")
|
||||
if (input.source === "wechat") tags.behavior?.push("微信渠道")
|
||||
if (input.source === "douyin") tags.behavior?.push("短视频渠道")
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const result: AnalyzeResult = {
|
||||
user_id: input.user_id,
|
||||
rfm_score: rfm,
|
||||
tags,
|
||||
weights,
|
||||
created_at: store.has(input.user_id) ? store.get(input.user_id)!.created_at : now,
|
||||
updated_at: now,
|
||||
}
|
||||
store.set(input.user_id, result)
|
||||
return result
|
||||
}
|
||||
|
||||
export function getUserTags(user_id: string): AnalyzeResult | null {
|
||||
return store.get(user_id) ?? null
|
||||
}
|
||||
|
||||
export function getGroupSummary(): GroupSummary {
|
||||
const grades: GroupSummary["gradeCount"] = { S: 0, A: 0, B: 0, C: 0, D: 0 }
|
||||
const values: GroupSummary["valueCount"] = { 高: 0, 中: 0, 低: 0 }
|
||||
const lifecycle: Record<string, number> = {}
|
||||
store.forEach((r) => {
|
||||
grades[r.rfm_score.grade]++
|
||||
if (r.tags.value) values[r.tags.value]++
|
||||
const lc = r.tags.lifecycle ?? "未知"
|
||||
lifecycle[lc] = (lifecycle[lc] || 0) + 1
|
||||
})
|
||||
return { gradeCount: grades, valueCount: values, lifecycleCount: lifecycle }
|
||||
}
|
||||
|
||||
export function dumpCsv(): string {
|
||||
const headers = ["user_id", "R", "F", "M", "total", "grade", "emotion", "intent", "lifecycle", "value"].join(",")
|
||||
const rows: string[] = [headers]
|
||||
store.forEach((r) => {
|
||||
rows.push(
|
||||
[
|
||||
r.user_id,
|
||||
r.rfm_score.R,
|
||||
r.rfm_score.F,
|
||||
r.rfm_score.M,
|
||||
r.rfm_score.total,
|
||||
r.rfm_score.grade,
|
||||
r.tags.emotion ?? "",
|
||||
r.tags.intent ?? "",
|
||||
r.tags.lifecycle ?? "",
|
||||
r.tags.value ?? "",
|
||||
].join(","),
|
||||
)
|
||||
})
|
||||
return rows.join("\n")
|
||||
}
|
||||
Reference in New Issue
Block a user