Files
users/services/rfm-engine.ts
v0 f0a6a364f2 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>
2025-08-08 07:00:12 +00:00

180 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

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")
}