feat: 新增个人身体画像评估与馆主经营助手

把 3 分钟身体状态评估做成独立获客链路,匿名测评后登录认领完整报告,并接入体验预约与今日待办。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
richarjiang
2026-09-11 22:09:19 +08:00
parent b8c0dd6781
commit ab6602e41d
56 changed files with 4640 additions and 25 deletions

View File

@@ -0,0 +1,268 @@
import { Injectable, NotFoundException } from '@nestjs/common'
import {
BODY_DIMENSION_LABELS,
BodyDimension,
BodyPortraitAnswers,
BodyPortraitReport,
BodyPortraitSource,
GrowthEventName,
GrowthFunnelRow,
GrowthLeadDetail,
GrowthLeadStage,
GrowthLeadSummary,
GrowthTodayDashboard,
GrowthTodayTask,
} from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { BodyPortraitLifecycleService } from './body-portrait-lifecycle.service'
import { normalizeAnswers } from './scoring/score-v1'
const TERMINAL = new Set<string>([
GrowthLeadStage.TRIAL_BOOKED,
GrowthLeadStage.TRIAL_ATTENDED,
GrowthLeadStage.PLAN_PURCHASED,
GrowthLeadStage.TRAINING,
GrowthLeadStage.RENEWAL_DUE,
GrowthLeadStage.RENEWED,
])
@Injectable()
export class BodyPortraitAdminService {
constructor(
private readonly prisma: PrismaService,
private readonly lifecycle: BodyPortraitLifecycleService,
) {}
async dashboard(): Promise<GrowthTodayDashboard> {
const [leads, todos, funnel] = await Promise.all([
this.prisma.growthLead.findMany({
include: {
user: { select: { nickname: true, phone: true } },
latestAssessment: { include: { visit: true } },
},
orderBy: { lastActiveAt: 'desc' },
take: 80,
}),
this.prisma.reassessmentTodo.findMany({
where: { completedAt: null, dueAt: { lte: new Date() } },
include: { user: { select: { nickname: true } } },
take: 20,
}),
this.funnel(),
])
const tasks: GrowthTodayTask[] = []
const twoDaysAgo = Date.now() - 2 * 24 * 60 * 60 * 1000
for (const lead of leads) {
const report = (lead.latestAssessment?.report || null) as BodyPortraitReport | null
const answers = normalizeAnswers(lead.latestAssessment?.answers as Partial<BodyPortraitAnswers>)
const draft = this.lifecycle.followUpDraft({
nickname: lead.user.nickname,
report,
sittingHours: answers.sittingHours,
workPosture: answers.workPosture,
})
if (!TERMINAL.has(lead.stage) && lead.latestAssessment?.completedAt && ['CLAIMED', 'PHONE_BOUND', 'COMPLETED'].includes(lead.stage)) {
const top = report ? this.topDimension(report) : null
tasks.push({
kind: 'completed_unbooked',
leadId: lead.id,
userId: lead.userId,
nickname: lead.user.nickname || '未命名',
title: `${lead.user.nickname || '学员'}完成身体测试但未预约`,
detail: top ? `${BODY_DIMENSION_LABELS[top[0]]}关注度 ${top[1]}` : '已完成线上画像',
happenedAt: lead.lastActiveAt.toISOString(),
followUpDraft: draft,
})
}
if (lead.stage === GrowthLeadStage.TRIAL_ATTENDED && lead.lastActiveAt.getTime() <= twoDaysAgo) {
tasks.push({
kind: 'trial_unpurchased',
leadId: lead.id,
userId: lead.userId,
nickname: lead.user.nickname || '未命名',
title: `${lead.user.nickname || '学员'}完成体验课后未购买`,
detail: '建议回访,确认是否需要 12 周改善计划',
happenedAt: lead.lastActiveAt.toISOString(),
followUpDraft: draft,
})
}
}
for (const todo of todos) {
tasks.push({
kind: 'reassessment_due',
leadId: todo.planId,
userId: todo.userId,
nickname: todo.user.nickname || '未命名',
title: `${todo.user.nickname || '学员'}到达第 ${todo.lessonCheckpoint} 节复测节点`,
detail: '建议安排阶段复测',
happenedAt: todo.dueAt.toISOString(),
followUpDraft: `训练已经走到第 ${todo.lessonCheckpoint} 节附近了,可以安排一次阶段复测,看看身体状态有没有变化。`,
})
}
return { tasks: tasks.slice(0, 12), funnel }
}
async listLeads(query: { page?: number; limit?: number; search?: string; stage?: string; source?: string }) {
const page = Math.max(1, Number(query.page) || 1)
const limit = Math.min(50, Math.max(1, Number(query.limit) || 20))
const where: Record<string, unknown> = {}
if (query.stage) where.stage = query.stage
if (query.source) where.source = query.source
if (query.search) {
where.user = {
OR: [
{ nickname: { contains: query.search } },
{ phone: { contains: query.search } },
],
}
}
const [items, total] = await Promise.all([
this.prisma.growthLead.findMany({
where,
include: { user: { select: { nickname: true, phone: true } }, latestAssessment: true },
orderBy: { lastActiveAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.growthLead.count({ where }),
])
return {
items: items.map((lead) => this.toSummary(lead)),
total,
page,
limit,
}
}
async leadDetail(id: string): Promise<GrowthLeadDetail> {
const lead = await this.prisma.growthLead.findUnique({
where: { id },
include: {
user: { select: { nickname: true, phone: true } },
latestAssessment: { include: { visit: true } },
},
})
if (!lead) throw new NotFoundException('线索不存在')
const events = await this.prisma.growthEvent.findMany({
where: { userId: lead.userId },
orderBy: { occurredAt: 'asc' },
take: 50,
})
const report = (lead.latestAssessment?.report || null) as BodyPortraitReport | null
const answers = lead.latestAssessment
? normalizeAnswers(lead.latestAssessment.answers as Partial<BodyPortraitAnswers>)
: null
const summary = this.toSummary(lead)
return {
...summary,
goalLabel: report?.goalLabel || null,
scores: report?.scores || null,
levels: report?.levels || null,
evidence: report?.evidence || [],
answers,
report,
events: events.map((event) => ({
id: event.id,
name: event.name as GrowthEventName,
occurredAt: event.occurredAt.toISOString(),
})),
coachNote: lead.coachNote,
firstAssessmentHints: report?.firstAssessmentHints || [],
}
}
async updateNote(id: string, coachNote: string) {
const lead = await this.prisma.growthLead.findUnique({ where: { id } })
if (!lead) throw new NotFoundException('线索不存在')
await this.prisma.growthLead.update({ where: { id }, data: { coachNote: coachNote.slice(0, 500) } })
return this.leadDetail(id)
}
private async funnel(): Promise<GrowthFunnelRow[]> {
const visits = await this.prisma.bodyPortraitVisit.findMany({
select: { id: true, source: true },
})
const byVisit = new Map(visits.map((visit) => [visit.id, visit.source as BodyPortraitSource]))
const events = await this.prisma.growthEvent.findMany({
select: { name: true, visitId: true, userId: true },
})
const rows = new Map<BodyPortraitSource, { source: BodyPortraitSource; started: number; completed: number; claimed: number; booked: number; attended: number; purchased: number }>()
const ensure = (source: BodyPortraitSource) => {
const current = rows.get(source) || {
source, started: 0, completed: 0, claimed: 0, booked: 0, attended: 0, purchased: 0,
}
rows.set(source, current)
return current
}
for (const source of Object.values(BodyPortraitSource)) ensure(source)
for (const visit of visits) ensure(visit.source as BodyPortraitSource).started += 0
const counted = new Set<string>()
for (const event of events) {
const source = (event.visitId && byVisit.get(event.visitId)) || BodyPortraitSource.ORGANIC
const row = ensure(source)
const key = `${source}:${event.name}:${event.userId || event.visitId}`
if (counted.has(key)) continue
counted.add(key)
if (event.name === GrowthEventName.ASSESSMENT_STARTED) row.started += 1
if (event.name === GrowthEventName.ASSESSMENT_COMPLETED) row.completed += 1
if (event.name === GrowthEventName.ASSESSMENT_CLAIMED) row.claimed += 1
if (event.name === GrowthEventName.TRIAL_BOOKED) row.booked += 1
if (event.name === GrowthEventName.TRIAL_ATTENDED) row.attended += 1
if (event.name === GrowthEventName.PLAN_PURCHASED) row.purchased += 1
}
return [...rows.values()]
}
private toSummary(lead: {
id: string
userId: string
stage: string
source: string | null
campaignId: string | null
lastActiveAt: Date
user: { nickname: string; phone: string | null }
latestAssessment: { completedAt: Date | null; safetyFlagged: boolean; report: unknown } | null
}): GrowthLeadSummary {
const report = (lead.latestAssessment?.report || null) as BodyPortraitReport | null
const top = report ? this.topDimension(report) : null
const answers = lead.latestAssessment
? normalizeAnswers((lead.latestAssessment as { answers?: unknown }).answers as Partial<BodyPortraitAnswers>)
: EMPTY_SAFE
return {
id: lead.id,
userId: lead.userId,
nickname: lead.user.nickname || '未命名',
phone: lead.user.phone,
stage: lead.stage as GrowthLeadStage,
source: (lead.source as BodyPortraitSource | null) || null,
campaignId: lead.campaignId,
primaryType: report?.primaryType || null,
topDimension: top?.[0] || null,
safetyFlagged: lead.latestAssessment?.safetyFlagged || false,
lastActiveAt: lead.lastActiveAt.toISOString(),
completedAt: lead.latestAssessment?.completedAt?.toISOString() || null,
followUpDraft: this.lifecycle.followUpDraft({
nickname: lead.user.nickname,
report,
sittingHours: answers.sittingHours,
workPosture: answers.workPosture,
}),
}
}
private topDimension(report: BodyPortraitReport): [BodyDimension, number] {
return (Object.entries(report.scores) as [BodyDimension, number][]).sort((a, b) => b[1] - a[1])[0]
}
}
const EMPTY_SAFE: BodyPortraitAnswers = {
concerns: [],
goal: null,
sittingHours: null,
exerciseFreq: null,
workPosture: null,
endOfDayFatigue: [],
afterSitting: [],
standingNotice: [],
safety: [],
}