Files
mp-pilates/packages/server/src/body-portrait/body-portrait-offline.service.ts
richarjiang ab6602e41d feat: 新增个人身体画像评估与馆主经营助手
把 3 分钟身体状态评估做成独立获客链路,匿名测评后登录认领完整报告,并接入体验预约与今日待办。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 22:09:19 +08:00

281 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}