- fix(booking): 修复未认领画像阻断约课问题,支持约课事务内自动认领绑定 - fix(app): 修复 Pinia 状态解构导致响应式失效,补齐 PortraitRadar 组件实例传参 - fix(admin): 修复经营助手复测待办点击 404,优化体验课后线索回访文案 - fix(server): 线下评估日期间隔按中国时区校验,复测按期次单项核销,优化单次评估分享卡与限流清理 - test: 补齐 body-portrait-offline 与 booking 来源画像单元测试 Co-authored-by: Cursor <cursoragent@cursor.com>
219 lines
8.2 KiB
TypeScript
219 lines
8.2 KiB
TypeScript
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 greeting = input.nickname ? `${input.nickname}你好,` : ''
|
|
if (!input.report) {
|
|
return `${greeting}看到你刚刚做了身体状态评估。线上只能做基础判断,如果方便的话,可以来工作室做一次实际的活动度和动作评估,我可以再帮你具体看看。`
|
|
}
|
|
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 `${greeting}看到你刚刚做了身体状态评估,你目前比较明显的是${dimension}相关的关注点,画像更接近「${type}」。${lifestyle ? `如果平时${lifestyle}比较多,这种情况其实很常见。` : ''}线上只能做基础判断,如果方便的话,可以来工作室做一次实际的活动度和动作评估,我可以再帮你具体看看。`
|
|
}
|
|
|
|
followUpAfterTrialDraft(input: {
|
|
nickname: string
|
|
report: BodyPortraitReport | null
|
|
}): string {
|
|
const greeting = input.nickname ? `${input.nickname}你好,` : ''
|
|
const type = input.report ? `结合你之前的「${PORTRAIT_TYPE_LABELS[input.report.primaryType]}」画像和` : ''
|
|
return `${greeting}前两天的普拉提体验课感觉怎么样?${type}现场教练观察的情况,我们为你准备了针对性的阶段改善建议。如果有时间可以聊聊你的感受,看看是否需要为你规划后续的训练。`
|
|
}
|
|
|
|
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'
|
|
}
|
|
}
|