feat: 新增个人身体画像评估与馆主经营助手
把 3 分钟身体状态评估做成独立获客链路,匿名测评后登录认领完整报告,并接入体验预约与今日待办。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user