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,280 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
import {
BODY_PORTRAIT_OFFLINE_PROTOCOL,
CreateProfessionalAssessmentDto,
CreateTrainingPlanDto,
GrowthShareCardRecord,
ProfessionalAssessmentKind,
ProfessionalAssessmentSessionRecord,
ProfessionalObservationScores,
ReassessmentTodoRecord,
TrainingPlanRecord,
TrainingPlanStatus,
} from '@mp-pilates/shared'
import { Prisma } from '@prisma/client'
import { PrismaService } from '../prisma/prisma.service'
import { createShareCode } from './body-portrait.token'
const DEFAULT_PHASES = [
{ name: 'Phase 1 · 重新建立控制', lessonStart: 1, lessonEnd: 4, focus: ['呼吸', '骨盆控制', '核心激活'], summary: '先找回呼吸、骨盆和核心的基本控制。', sortOrder: 0 },
{ name: 'Phase 2 · 改善活动能力', lessonStart: 5, lessonEnd: 8, focus: ['胸椎', '肩带', '髋'], summary: '在控制基础上改善上背、肩和髋的活动。', sortOrder: 1 },
{ name: 'Phase 3 · 整合身体动作', lessonStart: 9, lessonEnd: 12, focus: ['全身控制', '稳定', '日常动作迁移'], summary: '把训练能力带回站、走和日常动作。', sortOrder: 2 },
]
@Injectable()
export class BodyPortraitOfflineService {
constructor(private readonly prisma: PrismaService) {}
async createSession(userId: string, operatorId: string, dto: CreateProfessionalAssessmentDto): Promise<ProfessionalAssessmentSessionRecord> {
await this.ensureUser(userId)
if (dto.bookingId) {
const booking = await this.prisma.booking.findFirst({ where: { id: dto.bookingId, userId } })
if (!booking) throw new BadRequestException('只能关联该学员的预约')
}
if (dto.originAssessmentId) {
const assessment = await this.prisma.bodyPortraitAssessment.findFirst({ where: { id: dto.originAssessmentId, userId } })
if (!assessment) throw new BadRequestException('线上画像不属于该学员')
}
const recordedAt = this.date(dto.recordedAt)
const created = await this.prisma.professionalAssessmentSession.create({
data: {
userId,
kind: dto.kind,
protocolVersion: BODY_PORTRAIT_OFFLINE_PROTOCOL,
recordedAt,
bookingId: dto.bookingId,
originAssessmentId: dto.originAssessmentId,
observations: dto.observations as unknown as Prisma.InputJsonValue,
subjectiveTension: dto.subjectiveTension ?? null,
coachSummary: dto.coachSummary.trim().slice(0, 1000),
trainingFocus: dto.trainingFocus.trim().slice(0, 500),
phaseGoal: dto.phaseGoal.trim().slice(0, 500),
photoAngle: dto.photoAngle || null,
operatorId,
},
})
if (dto.kind === ProfessionalAssessmentKind.FOLLOW_UP) {
await this.prisma.reassessmentTodo.updateMany({
where: { userId, completedAt: null },
data: { completedAt: new Date() },
})
}
return this.mapSession(created)
}
async listSessions(userId: string): Promise<ProfessionalAssessmentSessionRecord[]> {
const rows = await this.prisma.professionalAssessmentSession.findMany({
where: { userId },
orderBy: { recordedAt: 'asc' },
})
return rows.map((row) => this.mapSession(row))
}
async createPlan(userId: string, dto: CreateTrainingPlanDto): Promise<TrainingPlanRecord> {
await this.ensureUser(userId)
if (dto.sessionId) {
const session = await this.prisma.professionalAssessmentSession.findFirst({ where: { id: dto.sessionId, userId } })
if (!session) throw new BadRequestException('评估记录不属于该学员')
}
const plan = await this.prisma.trainingPlan.create({
data: {
userId,
sessionId: dto.sessionId,
title: dto.title?.trim() || '你的 12 周身体改善计划',
status: TrainingPlanStatus.ACTIVE,
weeks: dto.weeks || 12,
phases: {
create: DEFAULT_PHASES.map((phase) => ({
...phase,
focus: phase.focus as unknown as Prisma.InputJsonValue,
})),
},
},
include: { phases: { orderBy: { sortOrder: 'asc' } } },
})
const now = new Date()
await this.prisma.reassessmentTodo.createMany({
data: [4, 8, 12].map((checkpoint) => ({
userId,
planId: plan.id,
lessonCheckpoint: checkpoint,
dueAt: new Date(now.getTime() + checkpoint * 7 * 24 * 60 * 60 * 1000),
})),
})
return this.mapPlan(plan)
}
async listPlans(userId: string): Promise<TrainingPlanRecord[]> {
const plans = await this.prisma.trainingPlan.findMany({
where: { userId },
include: { phases: { orderBy: { sortOrder: 'asc' } } },
orderBy: { createdAt: 'desc' },
})
return plans.map((plan) => this.mapPlan(plan))
}
async todos(userId: string): Promise<ReassessmentTodoRecord[]> {
const rows = await this.prisma.reassessmentTodo.findMany({
where: { userId },
orderBy: { lessonCheckpoint: 'asc' },
})
return rows.map((row) => ({
id: row.id,
userId: row.userId,
planId: row.planId,
lessonCheckpoint: row.lessonCheckpoint,
dueAt: row.dueAt.toISOString(),
completedAt: row.completedAt?.toISOString() || null,
}))
}
async createShareCard(userId: string, includePhotos: boolean): Promise<GrowthShareCardRecord> {
const sessions = await this.listSessions(userId)
const first = sessions[0]
const latest = sessions[sessions.length - 1]
const completedCount = await this.prisma.booking.count({ where: { userId, status: 'COMPLETED' } })
const plan = await this.prisma.trainingPlan.findFirst({ where: { userId }, orderBy: { createdAt: 'desc' } })
const rows = first && latest ? this.compare(first, latest) : []
const created = await this.prisma.growthShareCard.create({
data: {
userId,
planId: plan?.id,
shareCode: createShareCode(),
title: `我的第 ${completedCount || 1} 节普拉提`,
caption: first && latest && first.subjectiveTension != null && latest.subjectiveTension != null
? `${plan?.weeks || 12} 周前肩颈紧张 ${first.subjectiveTension}/10现在 ${latest.subjectiveTension}/10。坚持有时候真的看得见。`
: '坚持有时候真的看得见。',
completedCount,
weeks: plan?.weeks || 12,
rows: rows as unknown as Prisma.InputJsonValue,
includePhotos: includePhotos === true,
},
})
return this.mapShare(created)
}
async publicShare(shareCode: string) {
const card = await this.prisma.growthShareCard.findUnique({ where: { shareCode } })
if (!card) throw new NotFoundException('分享卡片不存在')
return {
...this.mapShare(card),
includePhotos: false,
}
}
private compare(first: ProfessionalAssessmentSessionRecord, latest: ProfessionalAssessmentSessionRecord) {
const labels: { key: keyof ProfessionalObservationScores; label: string }[] = [
{ key: 'shoulderFlexion', label: '肩屈活动' },
{ key: 'singleLeg', label: '单腿稳定' },
{ key: 'coreControl', label: '核心控制' },
{ key: 'thoracicExtension', label: '胸椎活动' },
]
const rows = labels.map((item) => ({
label: item.label,
first: `${first.observations[item.key]}/5`,
latest: `${latest.observations[item.key]}/5`,
}))
if (first.subjectiveTension != null || latest.subjectiveTension != null) {
rows.push({
label: '肩颈主观紧张',
first: first.subjectiveTension != null ? `${first.subjectiveTension}/10` : '—',
latest: latest.subjectiveTension != null ? `${latest.subjectiveTension}/10` : '—',
})
}
return rows
}
private mapSession(row: {
id: string
userId: string
kind: string
protocolVersion: string
recordedAt: Date
bookingId: string | null
originAssessmentId: string | null
observations: Prisma.JsonValue
subjectiveTension: number | null
coachSummary: string
trainingFocus: string
phaseGoal: string
photoAngle: string | null
}): ProfessionalAssessmentSessionRecord {
return {
id: row.id,
userId: row.userId,
kind: row.kind as ProfessionalAssessmentKind,
protocolVersion: row.protocolVersion,
recordedAt: row.recordedAt.toISOString().slice(0, 10),
bookingId: row.bookingId,
originAssessmentId: row.originAssessmentId,
observations: row.observations as unknown as ProfessionalObservationScores,
subjectiveTension: row.subjectiveTension,
coachSummary: row.coachSummary,
trainingFocus: row.trainingFocus,
phaseGoal: row.phaseGoal,
photoAngle: row.photoAngle as ProfessionalAssessmentSessionRecord['photoAngle'],
}
}
private mapPlan(plan: {
id: string
userId: string
title: string
status: string
weeks: number
sessionId: string | null
phases: Array<{ id: string; name: string; lessonStart: number; lessonEnd: number; focus: Prisma.JsonValue; summary: string }>
}): TrainingPlanRecord {
return {
id: plan.id,
userId: plan.userId,
title: plan.title,
status: plan.status as TrainingPlanStatus,
weeks: plan.weeks,
sessionId: plan.sessionId,
phases: plan.phases.map((phase) => ({
id: phase.id,
name: phase.name,
lessonStart: phase.lessonStart,
lessonEnd: phase.lessonEnd,
focus: phase.focus as string[],
summary: phase.summary,
})),
}
}
private mapShare(card: {
id: string
shareCode: string
title: string
caption: string
completedCount: number
weeks: number
rows: Prisma.JsonValue
includePhotos: boolean
}): GrowthShareCardRecord {
return {
id: card.id,
shareCode: card.shareCode,
title: card.title,
caption: card.caption,
completedCount: card.completedCount,
weeks: card.weeks,
rows: card.rows as unknown as GrowthShareCardRecord['rows'],
includePhotos: card.includePhotos,
}
}
private async ensureUser(userId: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { id: true } })
if (!user) throw new NotFoundException('学员不存在')
}
private date(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new BadRequestException('日期无效')
const date = new Date(`${value}T00:00:00.000Z`)
if (Number.isNaN(date.getTime())) throw new BadRequestException('日期无效')
if (date.getTime() > Date.now()) throw new BadRequestException('日期不能晚于今天')
return date
}
}