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,209 @@
import { Injectable, Logger } from '@nestjs/common'
import {
BODY_DIMENSION_LABELS,
BodyDimension,
BodyPortraitReport,
BodyPortraitSource,
CardTypeCategory,
GrowthEventName,
GrowthLeadStage,
PORTRAIT_TYPE_LABELS,
SITTING_HOURS_LABELS,
SittingHours,
WORK_POSTURE_LABELS,
WorkPosture,
} from '@mp-pilates/shared'
import { Prisma } from '@prisma/client'
import { PrismaService } from '../prisma/prisma.service'
const STAGE_RANK: Record<GrowthLeadStage, number> = {
[GrowthLeadStage.VISIT]: 0,
[GrowthLeadStage.STARTED]: 1,
[GrowthLeadStage.COMPLETED]: 2,
[GrowthLeadStage.CLAIMED]: 3,
[GrowthLeadStage.PHONE_BOUND]: 4,
[GrowthLeadStage.TRIAL_PURCHASED]: 5,
[GrowthLeadStage.TRIAL_BOOKED]: 6,
[GrowthLeadStage.TRIAL_ATTENDED]: 7,
[GrowthLeadStage.PLAN_PURCHASED]: 8,
[GrowthLeadStage.TRAINING]: 9,
[GrowthLeadStage.RENEWAL_DUE]: 10,
[GrowthLeadStage.RENEWED]: 11,
}
const SAFE_EVENT_PROPS = new Set(['source', 'campaignId', 'cardType', 'bookingId', 'orderId', 'checkpoint'])
@Injectable()
export class BodyPortraitLifecycleService {
private readonly logger = new Logger(BodyPortraitLifecycleService.name)
constructor(private readonly prisma: PrismaService) {}
async recordEvent(input: {
name: GrowthEventName
idempotencyKey: string
visitId?: string | null
assessmentId?: string | null
userId?: string | null
properties?: Record<string, unknown>
stage?: GrowthLeadStage
}) {
const properties = this.sanitize(input.properties)
try {
await this.prisma.growthEvent.create({
data: {
name: input.name,
idempotencyKey: input.idempotencyKey.slice(0, 120),
visitId: input.visitId || undefined,
assessmentId: input.assessmentId || undefined,
userId: input.userId || undefined,
properties: properties as Prisma.InputJsonValue,
},
})
} catch (error) {
if (this.isUnique(error)) return
this.logger.warn(`growth event failed: ${input.name}`)
return
}
if (input.userId && input.stage) {
await this.advanceLead(input.userId, input.stage, input.assessmentId)
}
}
async advanceLead(userId: string, stage: GrowthLeadStage, assessmentId?: string | null) {
const existing = await this.prisma.growthLead.findUnique({ where: { userId } })
if (!existing) {
if (STAGE_RANK[stage] < STAGE_RANK[GrowthLeadStage.CLAIMED]) return
const assessment = assessmentId
? await this.prisma.bodyPortraitAssessment.findUnique({ where: { id: assessmentId }, include: { visit: true } })
: await this.prisma.bodyPortraitAssessment.findFirst({ where: { userId }, orderBy: { createdAt: 'desc' }, include: { visit: true } })
await this.prisma.growthLead.create({
data: {
userId,
latestAssessmentId: assessment?.id,
stage,
source: assessment?.visit.source,
campaignId: assessment?.visit.campaignId,
lastActiveAt: new Date(),
},
})
return
}
const nextStage = STAGE_RANK[stage] > STAGE_RANK[existing.stage as GrowthLeadStage] ? stage : existing.stage
await this.prisma.growthLead.update({
where: { userId },
data: {
stage: nextStage,
lastActiveAt: new Date(),
latestAssessmentId: assessmentId || existing.latestAssessmentId,
},
})
}
async onPhoneBound(userId: string) {
await this.recordEvent({
name: GrowthEventName.PHONE_BOUND,
idempotencyKey: `phone_bound:${userId}`,
userId,
stage: GrowthLeadStage.PHONE_BOUND,
})
}
async onOrderPaid(userId: string, cardType: string, orderId: string) {
const assessment = await this.latestAssessment(userId)
const trial = cardType === CardTypeCategory.TRIAL
await this.recordEvent({
name: trial ? GrowthEventName.TRIAL_PURCHASED : GrowthEventName.PLAN_PURCHASED,
idempotencyKey: `${trial ? 'trial_purchased' : 'plan_purchased'}:${orderId}`,
userId,
assessmentId: assessment?.id,
visitId: assessment?.visitId,
properties: { cardType, orderId },
stage: trial ? GrowthLeadStage.TRIAL_PURCHASED : GrowthLeadStage.PLAN_PURCHASED,
})
if (!trial) {
await this.advanceLead(userId, GrowthLeadStage.TRAINING, assessment?.id)
}
}
async onBookingCreated(userId: string, bookingId: string, cardType: string, originAssessmentId?: string | null) {
if (cardType !== CardTypeCategory.TRIAL) return
const assessmentId = originAssessmentId || (await this.latestAssessment(userId))?.id
const assessment = assessmentId
? await this.prisma.bodyPortraitAssessment.findUnique({ where: { id: assessmentId } })
: null
await this.recordEvent({
name: GrowthEventName.TRIAL_BOOKED,
idempotencyKey: `trial_booked:${bookingId}`,
userId,
assessmentId: assessment?.id,
visitId: assessment?.visitId,
properties: { bookingId },
stage: GrowthLeadStage.TRIAL_BOOKED,
})
}
async onBookingCompleted(userId: string, bookingId: string, cardType: string) {
if (cardType !== CardTypeCategory.TRIAL) return
const assessment = await this.latestAssessment(userId)
await this.recordEvent({
name: GrowthEventName.TRIAL_ATTENDED,
idempotencyKey: `trial_attended:${bookingId}`,
userId,
assessmentId: assessment?.id,
visitId: assessment?.visitId,
properties: { bookingId },
stage: GrowthLeadStage.TRIAL_ATTENDED,
})
}
followUpDraft(input: {
nickname: string
report: BodyPortraitReport | null
sittingHours?: SittingHours | null
workPosture?: WorkPosture | null
}): string {
const name = input.nickname || '你'
if (!input.report) {
return `看到你刚刚做了身体状态评估。线上只能做基础判断,如果方便的话,可以来工作室做一次实际的活动度和动作评估,我可以再帮你具体看看。`
}
const top = (Object.entries(input.report.scores) as [BodyDimension, number][])
.sort((a, b) => b[1] - a[1])[0]
const lifestyle = [
input.workPosture ? WORK_POSTURE_LABELS[input.workPosture] : '',
input.sittingHours ? SITTING_HOURS_LABELS[input.sittingHours] : '',
].filter(Boolean).join('、')
const type = PORTRAIT_TYPE_LABELS[input.report.primaryType]
const dimension = BODY_DIMENSION_LABELS[top[0]]
return `看到你刚刚做了身体状态评估,你目前比较明显的是${dimension}相关的关注点,画像更接近「${type}」。${lifestyle ? `如果平时${lifestyle}比较多,这种情况其实很常见。` : ''}线上只能做基础判断,如果方便的话,可以来工作室做一次实际的活动度和动作评估,我可以再帮你具体看看。`.replace(`${name}`, `${name}`)
}
parseSource(raw?: string | null): BodyPortraitSource {
const value = String(raw || '').toLowerCase()
return (Object.values(BodyPortraitSource) as string[]).includes(value)
? value as BodyPortraitSource
: BodyPortraitSource.ORGANIC
}
private async latestAssessment(userId: string) {
return this.prisma.bodyPortraitAssessment.findFirst({
where: { userId },
orderBy: { createdAt: 'desc' },
})
}
private sanitize(properties?: Record<string, unknown>) {
const result: Record<string, string> = {}
if (!properties) return result
for (const [key, value] of Object.entries(properties)) {
if (!SAFE_EVENT_PROPS.has(key)) continue
if (value == null) continue
result[key] = String(value).slice(0, 80)
}
return result
}
private isUnique(error: unknown) {
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002'
}
}