fix: 修复身体画像约课阻断、页面响应式丢失及多项边界问题

- fix(booking): 修复未认领画像阻断约课问题,支持约课事务内自动认领绑定
- fix(app): 修复 Pinia 状态解构导致响应式失效,补齐 PortraitRadar 组件实例传参
- fix(admin): 修复经营助手复测待办点击 404,优化体验课后线索回访文案
- fix(server): 线下评估日期间隔按中国时区校验,复测按期次单项核销,优化单次评估分享卡与限流清理
- test: 补齐 body-portrait-offline 与 booking 来源画像单元测试

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
richarjiang
2026-09-11 22:45:44 +08:00
parent ab6602e41d
commit d91282ecbd
18 changed files with 324 additions and 37 deletions

View File

@@ -0,0 +1,121 @@
import { BadRequestException } from '@nestjs/common'
import { ProfessionalAssessmentKind } from '@mp-pilates/shared'
import { BodyPortraitOfflineService } from '../body-portrait-offline.service'
import { PrismaService } from '../../prisma/prisma.service'
describe('BodyPortraitOfflineService', () => {
const prisma: any = {
user: { findUnique: jest.fn() },
booking: { findFirst: jest.fn(), count: jest.fn() },
bodyPortraitAssessment: { findFirst: jest.fn() },
professionalAssessmentSession: { create: jest.fn(), findMany: jest.fn(), findFirst: jest.fn() },
reassessmentTodo: { findFirst: jest.fn(), update: jest.fn(), createMany: jest.fn(), findMany: jest.fn() },
trainingPlan: { create: jest.fn(), findMany: jest.fn(), findFirst: jest.fn() },
growthShareCard: { create: jest.fn(), findUnique: jest.fn() },
}
let service: BodyPortraitOfflineService
beforeEach(() => {
jest.resetAllMocks()
prisma.user.findUnique.mockResolvedValue({ id: 'u1' })
service = new BodyPortraitOfflineService(prisma as unknown as PrismaService)
})
it('rejects assessment recorded in the future based on China timezone', async () => {
const futureDate = new Date(Date.now() + 86400000 * 2).toISOString().slice(0, 10)
await expect(
service.createSession('u1', 'op1', {
kind: ProfessionalAssessmentKind.INITIAL,
recordedAt: futureDate,
observations: {
headPosition: 3, shoulderPosition: 3, thoracicExtension: 3, shoulderFlexion: 3,
breathing: 3, pelvis: 3, coreControl: 3, hipMobility: 3, singleLeg: 3,
},
coachSummary: 'test',
trainingFocus: 'test',
phaseGoal: 'test',
}),
).rejects.toThrow(BadRequestException)
})
it('completes only the earliest pending reassessment todo on follow-up session', async () => {
const today = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10)
prisma.professionalAssessmentSession.create.mockResolvedValue({
id: 'sess-2',
userId: 'u1',
kind: ProfessionalAssessmentKind.FOLLOW_UP,
protocolVersion: 'offline-v1',
recordedAt: new Date(`${today}T00:00:00.000Z`),
bookingId: null,
originAssessmentId: null,
observations: {
headPosition: 3, shoulderPosition: 3, thoracicExtension: 3, shoulderFlexion: 3,
breathing: 3, pelvis: 3, coreControl: 3, hipMobility: 3, singleLeg: 3,
},
subjectiveTension: 4,
coachSummary: 'progress good',
trainingFocus: 'stability',
phaseGoal: 'next stage',
photoAngle: null,
})
prisma.reassessmentTodo.findFirst.mockResolvedValue({ id: 'todo-4', lessonCheckpoint: 4 })
prisma.reassessmentTodo.update.mockResolvedValue({ id: 'todo-4', completedAt: new Date() })
const result = await service.createSession('u1', 'op1', {
kind: ProfessionalAssessmentKind.FOLLOW_UP,
recordedAt: today,
observations: {
headPosition: 3, shoulderPosition: 3, thoracicExtension: 3, shoulderFlexion: 3,
breathing: 3, pelvis: 3, coreControl: 3, hipMobility: 3, singleLeg: 3,
},
coachSummary: 'progress good',
trainingFocus: 'stability',
phaseGoal: 'next stage',
})
expect(result.id).toBe('sess-2')
expect(prisma.reassessmentTodo.findFirst).toHaveBeenCalledWith({
where: { userId: 'u1', completedAt: null },
orderBy: { lessonCheckpoint: 'asc' },
})
expect(prisma.reassessmentTodo.update).toHaveBeenCalledWith({
where: { id: 'todo-4' },
data: { completedAt: expect.any(Date) },
})
})
it('generates baseline share card for single session without false comparison', async () => {
const sessDate = new Date('2026-09-01T00:00:00.000Z')
prisma.professionalAssessmentSession.findMany.mockResolvedValue([
{
id: 'sess-1',
userId: 'u1',
kind: ProfessionalAssessmentKind.INITIAL,
protocolVersion: 'offline-v1',
recordedAt: sessDate,
bookingId: null,
originAssessmentId: null,
observations: {
headPosition: 3, shoulderPosition: 3, thoracicExtension: 3, shoulderFlexion: 3,
breathing: 3, pelvis: 3, coreControl: 3, hipMobility: 3, singleLeg: 3,
},
subjectiveTension: 6,
coachSummary: 'initial summary',
trainingFocus: 'focus',
phaseGoal: 'goal',
photoAngle: null,
},
])
prisma.booking.count.mockResolvedValue(1)
prisma.trainingPlan.findFirst.mockResolvedValue({ id: 'p1', weeks: 12 })
prisma.growthShareCard.create.mockImplementation(async ({ data }: { data: any }) => ({
id: 'card-1',
...data,
}))
const card = await service.createShareCard('u1', false)
expect(card.title).toBe('我的第 1 节普拉提')
expect(card.caption).toContain('已建立初始身体基准状态')
expect(prisma.growthShareCard.create.mock.calls[0][0].data.caption).not.toContain('周前肩颈紧张')
})
})

View File

@@ -60,6 +60,7 @@ describe('BodyPortraitService', () => {
userId: null,
visitId: 'v1',
status: BodyPortraitAssessmentStatus.DRAFT,
expiresAt: new Date(Date.now() + 10000),
completedAt: null,
answers: {
concerns: ['neck'],
@@ -85,6 +86,18 @@ describe('BodyPortraitService', () => {
expect(session.report).toBeNull()
expect(prisma.bodyPortraitAssessment.update.mock.calls[0][0].data.report.scores.cervicalShoulder).toBeGreaterThan(0)
})
it('rejects saveAnswers on expired assessment', async () => {
prisma.bodyPortraitAssessment.findUnique.mockResolvedValue({
id: 'a1',
userId: null,
visitId: 'v1',
status: BodyPortraitAssessmentStatus.EXPIRED,
expiresAt: new Date(Date.now() - 1000),
answers: {},
})
await expect(service.saveAnswers('expired-token', { goal: PortraitGoal.CORE })).rejects.toThrow('测评已过期')
})
})
describe('BodyPortraitLifecycleService', () => {
@@ -114,4 +127,20 @@ describe('BodyPortraitLifecycleService', () => {
expect(prisma.growthEvent.create.mock.calls[0][0].data.idempotencyKey).toBe('trial_purchased:order-1')
expect(prisma.growthEvent.create.mock.calls[0][0].data.name).toBe(GrowthEventName.TRIAL_PURCHASED)
})
it('generates customized follow-up drafts with nickname', () => {
const service = new BodyPortraitLifecycleService(prisma as unknown as PrismaService)
const draft = service.followUpDraft({
nickname: '张同学',
report: null,
})
expect(draft).toContain('张同学你好,')
expect(draft).toContain('做一次实际的活动度和动作评估')
const trialDraft = service.followUpAfterTrialDraft({
nickname: '李同学',
report: null,
})
expect(trialDraft).toContain('李同学你好,前两天的普拉提体验课感觉怎么样?')
})
})

View File

@@ -83,7 +83,10 @@ export class BodyPortraitAdminService {
title: `${lead.user.nickname || '学员'}完成体验课后未购买`,
detail: '建议回访,确认是否需要 12 周改善计划',
happenedAt: lead.lastActiveAt.toISOString(),
followUpDraft: draft,
followUpDraft: this.lifecycle.followUpAfterTrialDraft({
nickname: lead.user.nickname,
report,
}),
})
}
}

View File

@@ -163,9 +163,9 @@ export class BodyPortraitLifecycleService {
sittingHours?: SittingHours | null
workPosture?: WorkPosture | null
}): string {
const name = input.nickname || ''
const greeting = input.nickname ? `${input.nickname}你好,` : ''
if (!input.report) {
return `看到你刚刚做了身体状态评估。线上只能做基础判断,如果方便的话,可以来工作室做一次实际的活动度和动作评估,我可以再帮你具体看看。`
return `${greeting}看到你刚刚做了身体状态评估。线上只能做基础判断,如果方便的话,可以来工作室做一次实际的活动度和动作评估,我可以再帮你具体看看。`
}
const top = (Object.entries(input.report.scores) as [BodyDimension, number][])
.sort((a, b) => b[1] - a[1])[0]
@@ -175,7 +175,16 @@ export class BodyPortraitLifecycleService {
].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}`)
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 {

View File

@@ -54,10 +54,16 @@ export class BodyPortraitOfflineService {
},
})
if (dto.kind === ProfessionalAssessmentKind.FOLLOW_UP) {
await this.prisma.reassessmentTodo.updateMany({
const nextTodo = await this.prisma.reassessmentTodo.findFirst({
where: { userId, completedAt: null },
data: { completedAt: new Date() },
orderBy: { lessonCheckpoint: 'asc' },
})
if (nextTodo) {
await this.prisma.reassessmentTodo.update({
where: { id: nextTodo.id },
data: { completedAt: new Date() },
})
}
}
return this.mapSession(created)
}
@@ -132,18 +138,23 @@ export class BodyPortraitOfflineService {
const sessions = await this.listSessions(userId)
const first = sessions[0]
const latest = sessions[sessions.length - 1]
const hasComparison = sessions.length >= 2
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 rows = hasComparison ? this.compare(first, latest) : this.singleSnapshot(first)
let caption = '坚持有时候真的看得见。'
if (hasComparison && first?.subjectiveTension != null && latest?.subjectiveTension != null) {
caption = `${plan?.weeks || 12} 周前肩颈紧张 ${first.subjectiveTension}/10现在 ${latest.subjectiveTension}/10。坚持有时候真的看得见。`
} else if (!hasComparison && first?.subjectiveTension != null) {
caption = `已建立初始身体基准状态,肩颈紧张度 ${first.subjectiveTension}/10。开启专属改善计划。`
}
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。坚持有时候真的看得见。`
: '坚持有时候真的看得见。',
caption,
completedCount,
weeks: plan?.weeks || 12,
rows: rows as unknown as Prisma.InputJsonValue,
@@ -162,6 +173,29 @@ export class BodyPortraitOfflineService {
}
}
private singleSnapshot(first?: ProfessionalAssessmentSessionRecord) {
if (!first) return []
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: `${first.observations[item.key]}/5`,
}))
if (first.subjectiveTension != null) {
rows.push({
label: '肩颈主观紧张',
first: `${first.subjectiveTension}/10`,
latest: `${first.subjectiveTension}/10`,
})
}
return rows
}
private compare(first: ProfessionalAssessmentSessionRecord, latest: ProfessionalAssessmentSessionRecord) {
const labels: { key: keyof ProfessionalObservationScores; label: string }[] = [
{ key: 'shoulderFlexion', label: '肩屈活动' },
@@ -274,7 +308,8 @@ export class BodyPortraitOfflineService {
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('日期不能晚于今天')
const todayChina = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10)
if (value > todayChina) throw new BadRequestException('日期不能晚于今天')
return date
}
}

View File

@@ -6,9 +6,21 @@ interface Bucket {
}
const buckets = new Map<string, Bucket>()
let lastCleanAt = 0
function pruneBuckets(now: number) {
if (now - lastCleanAt < 60_000 && buckets.size < 1000) return
lastCleanAt = now
for (const [key, bucket] of buckets.entries()) {
if (bucket.resetAt <= now) {
buckets.delete(key)
}
}
}
export function assertRateLimit(key: string, limit: number, windowMs = 60 * 60 * 1000) {
const now = Date.now()
pruneBuckets(now)
const current = buckets.get(key)
if (!current || current.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + windowMs })

View File

@@ -117,7 +117,7 @@ export class BodyPortraitService {
if (assessment.status === BodyPortraitAssessmentStatus.CLAIMED) {
throw new ForbiddenException('报告已认领,不能再修改')
}
if (assessment.expiresAt < new Date() && assessment.status === BodyPortraitAssessmentStatus.DRAFT) {
if (assessment.status === BodyPortraitAssessmentStatus.EXPIRED || assessment.expiresAt < new Date()) {
throw new BadRequestException('测评已过期,请重新开始')
}
const answers = normalizeAnswers(raw as Partial<BodyPortraitAnswers>)
@@ -130,6 +130,9 @@ export class BodyPortraitService {
async complete(accessToken: string): Promise<BodyPortraitSessionResponse> {
const assessment = await this.assessmentByToken(accessToken)
if (assessment.status === BodyPortraitAssessmentStatus.EXPIRED || assessment.expiresAt < new Date()) {
throw new BadRequestException('测评已过期,请重新开始')
}
const answers = normalizeAnswers(assessment.answers as Partial<BodyPortraitAnswers>)
if (!answers.goal || !answers.sittingHours || !answers.exerciseFreq || !answers.workPosture || !answers.safety.length) {
throw new BadRequestException('请完成必答题后再生成画像')

View File

@@ -160,6 +160,11 @@ function buildTxMock(overrides: Record<string, unknown> = {}) {
bookingStatusHistory: {
create: jest.fn(),
},
bodyPortraitAssessment: {
findUnique: jest.fn(),
findFirst: jest.fn(),
update: jest.fn(),
},
...overrides,
}
}
@@ -476,6 +481,58 @@ describe('BookingService', () => {
expect(result).toBeDefined()
})
it('binds anonymous originAssessmentId to current user on booking create', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.bodyPortraitAssessment.findUnique.mockResolvedValue({ id: 'anon-assess', userId: null })
tx.bodyPortraitAssessment.update.mockResolvedValue({ id: 'anon-assess', userId: MOCK_USER_ID })
tx.booking.create.mockResolvedValue({
...mockConfirmedBooking,
originAssessmentId: 'anon-assess',
status: BookingStatus.PENDING_CONFIRMATION,
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
originAssessmentId: 'anon-assess',
status: BookingStatus.PENDING_CONFIRMATION,
timeSlot: mockOpenSlot,
membership: mockActiveMembership,
})
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
await service.createBooking(MOCK_USER_ID, { ...dto, originAssessmentId: 'anon-assess' })
expect(tx.bodyPortraitAssessment.update).toHaveBeenCalledWith({
where: { id: 'anon-assess' },
data: { userId: MOCK_USER_ID, status: 'CLAIMED', claimedAt: expect.any(Date) },
})
expect(tx.booking.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
originAssessmentId: 'anon-assess',
}),
}),
)
})
it('rejects originAssessmentId belonging to another user', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.bodyPortraitAssessment.findUnique.mockResolvedValue({ id: 'other-assess', userId: 'someone-else' })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(
service.createBooking(MOCK_USER_ID, { ...dto, originAssessmentId: 'other-assess' }),
).rejects.toThrow('画像报告不属于当前用户')
})
it('records booking status history when user creates a booking', async () => {
const nearFullSlot = { ...mockOpenSlot, bookedCount: 4, capacity: 5 }

View File

@@ -130,14 +130,24 @@ export class BookingService {
let originAssessmentId: string | undefined
if (dto.originAssessmentId) {
const owned = await tx.bodyPortraitAssessment.findFirst({
where: { id: dto.originAssessmentId, userId },
select: { id: true },
const assessment = await tx.bodyPortraitAssessment.findUnique({
where: { id: dto.originAssessmentId },
select: { id: true, userId: true },
})
if (!owned) {
throw new ForbiddenException('画像报告不属于当前用户')
if (assessment) {
if (!assessment.userId) {
// 匿名画像在约课时绑定给当前用户
await tx.bodyPortraitAssessment.update({
where: { id: assessment.id },
data: { userId, status: 'CLAIMED', claimedAt: new Date() },
})
originAssessmentId = assessment.id
} else if (assessment.userId === userId) {
originAssessmentId = assessment.id
} else {
throw new ForbiddenException('画像报告不属于当前用户')
}
}
originAssessmentId = owned.id
}
// 5. Create booking or revive a previously cancelled booking.