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

@@ -3,15 +3,16 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, watch } from 'vue' import { getCurrentInstance, onMounted, watch } from 'vue'
import { BODY_DIMENSION_LABELS, type BodyPortraitScores } from '@mp-pilates/shared' import { BODY_DIMENSION_LABELS, type BodyPortraitScores } from '@mp-pilates/shared'
const props = defineProps<{ scores: BodyPortraitScores }>() const props = defineProps<{ scores: BodyPortraitScores }>()
const instance = getCurrentInstance()
const labels = ['肩颈', '脊柱', '核心', '髋骨盆', '下肢'] const labels = ['肩颈', '脊柱', '核心', '髋骨盆', '下肢']
const keys = ['cervicalShoulder', 'spinalMobility', 'coreControl', 'hipPelvis', 'lowerLimb'] as const const keys = ['cervicalShoulder', 'spinalMobility', 'coreControl', 'hipPelvis', 'lowerLimb'] as const
function draw() { function draw() {
const ctx = uni.createCanvasContext('portraitRadar') const ctx = uni.createCanvasContext('portraitRadar', instance?.proxy)
const w = 280 const w = 280
const h = 280 const h = 280
const cx = w / 2 const cx = w / 2

View File

@@ -4,7 +4,7 @@
<view class="hero"> <view class="hero">
<text class="title">今天有 {{ dashboard.tasks.length }} 件值得关注的事</text> <text class="title">今天有 {{ dashboard.tasks.length }} 件值得关注的事</text>
</view> </view>
<view v-for="task in dashboard.tasks" :key="task.leadId + task.kind" class="card" @tap="openLead(task.leadId)"> <view v-for="task in dashboard.tasks" :key="task.leadId + task.kind" class="card" @tap="handleTask(task)">
<text class="name">{{ task.title }}</text> <text class="name">{{ task.title }}</text>
<text class="detail">{{ task.detail }}</text> <text class="detail">{{ task.detail }}</text>
</view> </view>
@@ -24,7 +24,7 @@ import { onShow } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from './stores/admin' import { useAdminStore } from './stores/admin'
import type { GrowthTodayDashboard } from '@mp-pilates/shared' import type { GrowthTodayDashboard, GrowthTodayTask } from '@mp-pilates/shared'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`) const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const admin = useAdminStore() const admin = useAdminStore()
@@ -34,9 +34,12 @@ onShow(async () => {
dashboard.value = await admin.fetchGrowthToday() dashboard.value = await admin.fetchGrowthToday()
}) })
function openLead(id: string) { function handleTask(task: GrowthTodayTask) {
if (id.length < 20) return if (task.kind === 'reassessment_due') {
uni.navigateTo({ url: `/pages/admin/portrait-lead-detail?id=${id}` }) uni.navigateTo({ url: `/pages/admin/portrait-assessment?userId=${task.userId}` })
} else if (task.leadId && task.leadId.length >= 20) {
uni.navigateTo({ url: `/pages/admin/portrait-lead-detail?id=${task.leadId}` })
}
} }
function navigate(path: string) { uni.navigateTo({ url: path }) } function navigate(path: string) { uni.navigateTo({ url: path }) }
</script> </script>

View File

@@ -17,6 +17,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { onShow } from '@dcloudio/uni-app' import { onShow } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
import SafetyNotice from '../../components/SafetyNotice.vue' import SafetyNotice from '../../components/SafetyNotice.vue'
@@ -25,7 +26,7 @@ import { useBodyPortraitStore } from '../../stores/body-portrait'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`) const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const portrait = useBodyPortraitStore() const portrait = useBodyPortraitStore()
const report = portrait.report const { report } = storeToRefs(portrait)
onShow(() => { portrait.track('advice_viewed') }) onShow(() => { portrait.track('advice_viewed') })

View File

@@ -18,6 +18,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
@@ -25,7 +26,7 @@ import { useBodyPortraitStore } from '../../stores/body-portrait'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`) const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const portrait = useBodyPortraitStore() const portrait = useBodyPortraitStore()
const stats = portrait.stats const { stats } = storeToRefs(portrait)
onLoad((query) => { onLoad((query) => {
portrait.captureAttribution((query || {}) as Record<string, string>) portrait.captureAttribution((query || {}) as Record<string, string>)

View File

@@ -16,7 +16,7 @@
<view v-if="share" class="card"> <view v-if="share" class="card">
<text class="title">{{ share.title }}</text> <text class="title">{{ share.title }}</text>
<text class="body">{{ share.caption }}</text> <text class="body">{{ share.caption }}</text>
<text v-for="row in share.rows" :key="row.label" class="meta">{{ row.label }} {{ row.first }} {{ row.latest }}</text> <text v-for="row in share.rows" :key="row.label" class="meta">{{ row.label }} {{ row.first === row.latest ? row.first : `${row.first} ${row.latest}` }}</text>
</view> </view>
<view v-if="plan" class="cta" @tap="makeShare">生成我的成长卡片</view> <view v-if="plan" class="cta" @tap="makeShare">生成我的成长卡片</view>
<view v-else class="body">完成到店评估后教练会为你生成 12 周改善计划</view> <view v-else class="body">完成到店评估后教练会为你生成 12 周改善计划</view>

View File

@@ -28,6 +28,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { onShow } from '@dcloudio/uni-app' import { onShow } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
import PortraitRadar from '../../components/PortraitRadar.vue' import PortraitRadar from '../../components/PortraitRadar.vue'
@@ -40,20 +41,18 @@ import { useUserStore } from '../../stores/user'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`) const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const portrait = useBodyPortraitStore() const portrait = useBodyPortraitStore()
const userStore = useUserStore() const userStore = useUserStore()
const teaser = portrait.teaser const { teaser, report, claimed } = storeToRefs(portrait)
const report = portrait.report
const claimed = portrait.claimed
onShow(async () => { onShow(async () => {
if (!portrait.teaser && !portrait.session) { if (!teaser.value && !portrait.session) {
await portrait.loadMine().catch(() => {}) await portrait.loadMine().catch(() => {})
} }
if (portrait.teaser) await portrait.track('report_viewed') if (teaser.value) await portrait.track('report_viewed')
}) })
async function continueFlow() { async function continueFlow() {
try { try {
if (!portrait.claimed) { if (!claimed.value) {
if (!userStore.loggedIn) await userStore.login() if (!userStore.loggedIn) await userStore.login()
await portrait.claim() await portrait.claim()
} }

View File

@@ -20,6 +20,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
import { getErrorMessage, wxBindPhone } from '../../utils/auth' import { getErrorMessage, wxBindPhone } from '../../utils/auth'
@@ -29,7 +30,7 @@ import { useUserStore } from '../../stores/user'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`) const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const portrait = useBodyPortraitStore() const portrait = useBodyPortraitStore()
const userStore = useUserStore() const userStore = useUserStore()
const report = portrait.report const { report } = storeToRefs(portrait)
async function goTrial() { async function goTrial() {
try { try {

View File

@@ -104,10 +104,11 @@ export const useBodyPortraitStore = defineStore('body-portrait', () => {
async function track(name: string) { async function track(name: string) {
try { try {
const scopeKey = assessmentId.value || visitToken.value || `anon_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
await post('/body-portrait/events', { await post('/body-portrait/events', {
name, name,
accessToken: accessToken.value || undefined, accessToken: accessToken.value || undefined,
idempotencyKey: `${name}:${assessmentId.value || 'anon'}`, idempotencyKey: `${name}:${scopeKey}`.slice(0, 80),
}) })
} catch { } catch {
// tracking must never block the user flow // tracking must never block the user flow

View File

@@ -41,7 +41,8 @@ export const useBookingStore = defineStore('booking', () => {
} }
async function createBooking(dto: CreateBookingDto) { async function createBooking(dto: CreateBookingDto) {
const originAssessmentId = dto.originAssessmentId || useBodyPortraitStore().assessmentId || undefined const portraitStore = useBodyPortraitStore()
const originAssessmentId = dto.originAssessmentId || (portraitStore.claimed ? portraitStore.assessmentId : undefined)
const result = await post<BookingWithDetails>('/booking', { const result = await post<BookingWithDetails>('/booking', {
...dto, ...dto,
...(originAssessmentId ? { originAssessmentId } : {}), ...(originAssessmentId ? { originAssessmentId } : {}),

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, userId: null,
visitId: 'v1', visitId: 'v1',
status: BodyPortraitAssessmentStatus.DRAFT, status: BodyPortraitAssessmentStatus.DRAFT,
expiresAt: new Date(Date.now() + 10000),
completedAt: null, completedAt: null,
answers: { answers: {
concerns: ['neck'], concerns: ['neck'],
@@ -85,6 +86,18 @@ describe('BodyPortraitService', () => {
expect(session.report).toBeNull() expect(session.report).toBeNull()
expect(prisma.bodyPortraitAssessment.update.mock.calls[0][0].data.report.scores.cervicalShoulder).toBeGreaterThan(0) 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', () => { 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.idempotencyKey).toBe('trial_purchased:order-1')
expect(prisma.growthEvent.create.mock.calls[0][0].data.name).toBe(GrowthEventName.TRIAL_PURCHASED) 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 || '学员'}完成体验课后未购买`, title: `${lead.user.nickname || '学员'}完成体验课后未购买`,
detail: '建议回访,确认是否需要 12 周改善计划', detail: '建议回访,确认是否需要 12 周改善计划',
happenedAt: lead.lastActiveAt.toISOString(), 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 sittingHours?: SittingHours | null
workPosture?: WorkPosture | null workPosture?: WorkPosture | null
}): string { }): string {
const name = input.nickname || '' const greeting = input.nickname ? `${input.nickname}你好,` : ''
if (!input.report) { if (!input.report) {
return `看到你刚刚做了身体状态评估。线上只能做基础判断,如果方便的话,可以来工作室做一次实际的活动度和动作评估,我可以再帮你具体看看。` return `${greeting}看到你刚刚做了身体状态评估。线上只能做基础判断,如果方便的话,可以来工作室做一次实际的活动度和动作评估,我可以再帮你具体看看。`
} }
const top = (Object.entries(input.report.scores) as [BodyDimension, number][]) const top = (Object.entries(input.report.scores) as [BodyDimension, number][])
.sort((a, b) => b[1] - a[1])[0] .sort((a, b) => b[1] - a[1])[0]
@@ -175,7 +175,16 @@ export class BodyPortraitLifecycleService {
].filter(Boolean).join('、') ].filter(Boolean).join('、')
const type = PORTRAIT_TYPE_LABELS[input.report.primaryType] const type = PORTRAIT_TYPE_LABELS[input.report.primaryType]
const dimension = BODY_DIMENSION_LABELS[top[0]] 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 { parseSource(raw?: string | null): BodyPortraitSource {

View File

@@ -54,11 +54,17 @@ export class BodyPortraitOfflineService {
}, },
}) })
if (dto.kind === ProfessionalAssessmentKind.FOLLOW_UP) { if (dto.kind === ProfessionalAssessmentKind.FOLLOW_UP) {
await this.prisma.reassessmentTodo.updateMany({ const nextTodo = await this.prisma.reassessmentTodo.findFirst({
where: { userId, completedAt: null }, where: { userId, completedAt: null },
orderBy: { lessonCheckpoint: 'asc' },
})
if (nextTodo) {
await this.prisma.reassessmentTodo.update({
where: { id: nextTodo.id },
data: { completedAt: new Date() }, data: { completedAt: new Date() },
}) })
} }
}
return this.mapSession(created) return this.mapSession(created)
} }
@@ -132,18 +138,23 @@ export class BodyPortraitOfflineService {
const sessions = await this.listSessions(userId) const sessions = await this.listSessions(userId)
const first = sessions[0] const first = sessions[0]
const latest = sessions[sessions.length - 1] const latest = sessions[sessions.length - 1]
const hasComparison = sessions.length >= 2
const completedCount = await this.prisma.booking.count({ where: { userId, status: 'COMPLETED' } }) const completedCount = await this.prisma.booking.count({ where: { userId, status: 'COMPLETED' } })
const plan = await this.prisma.trainingPlan.findFirst({ where: { userId }, orderBy: { createdAt: 'desc' } }) 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({ const created = await this.prisma.growthShareCard.create({
data: { data: {
userId, userId,
planId: plan?.id, planId: plan?.id,
shareCode: createShareCode(), shareCode: createShareCode(),
title: `我的第 ${completedCount || 1} 节普拉提`, title: `我的第 ${completedCount || 1} 节普拉提`,
caption: first && latest && first.subjectiveTension != null && latest.subjectiveTension != null caption,
? `${plan?.weeks || 12} 周前肩颈紧张 ${first.subjectiveTension}/10现在 ${latest.subjectiveTension}/10。坚持有时候真的看得见。`
: '坚持有时候真的看得见。',
completedCount, completedCount,
weeks: plan?.weeks || 12, weeks: plan?.weeks || 12,
rows: rows as unknown as Prisma.InputJsonValue, 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) { private compare(first: ProfessionalAssessmentSessionRecord, latest: ProfessionalAssessmentSessionRecord) {
const labels: { key: keyof ProfessionalObservationScores; label: string }[] = [ const labels: { key: keyof ProfessionalObservationScores; label: string }[] = [
{ key: 'shoulderFlexion', label: '肩屈活动' }, { key: 'shoulderFlexion', label: '肩屈活动' },
@@ -274,7 +308,8 @@ export class BodyPortraitOfflineService {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new BadRequestException('日期无效') if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new BadRequestException('日期无效')
const date = new Date(`${value}T00:00:00.000Z`) const date = new Date(`${value}T00:00:00.000Z`)
if (Number.isNaN(date.getTime())) throw new BadRequestException('日期无效') 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 return date
} }
} }

View File

@@ -6,9 +6,21 @@ interface Bucket {
} }
const buckets = new Map<string, 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) { export function assertRateLimit(key: string, limit: number, windowMs = 60 * 60 * 1000) {
const now = Date.now() const now = Date.now()
pruneBuckets(now)
const current = buckets.get(key) const current = buckets.get(key)
if (!current || current.resetAt <= now) { if (!current || current.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + windowMs }) buckets.set(key, { count: 1, resetAt: now + windowMs })

View File

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

View File

@@ -160,6 +160,11 @@ function buildTxMock(overrides: Record<string, unknown> = {}) {
bookingStatusHistory: { bookingStatusHistory: {
create: jest.fn(), create: jest.fn(),
}, },
bodyPortraitAssessment: {
findUnique: jest.fn(),
findFirst: jest.fn(),
update: jest.fn(),
},
...overrides, ...overrides,
} }
} }
@@ -476,6 +481,58 @@ describe('BookingService', () => {
expect(result).toBeDefined() 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 () => { it('records booking status history when user creates a booking', async () => {
const nearFullSlot = { ...mockOpenSlot, bookedCount: 4, capacity: 5 } const nearFullSlot = { ...mockOpenSlot, bookedCount: 4, capacity: 5 }

View File

@@ -130,14 +130,24 @@ export class BookingService {
let originAssessmentId: string | undefined let originAssessmentId: string | undefined
if (dto.originAssessmentId) { if (dto.originAssessmentId) {
const owned = await tx.bodyPortraitAssessment.findFirst({ const assessment = await tx.bodyPortraitAssessment.findUnique({
where: { id: dto.originAssessmentId, userId }, where: { id: dto.originAssessmentId },
select: { id: true }, select: { id: true, userId: true },
}) })
if (!owned) { 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('画像报告不属于当前用户') throw new ForbiddenException('画像报告不属于当前用户')
} }
originAssessmentId = owned.id }
} }
// 5. Create booking or revive a previously cancelled booking. // 5. Create booking or revive a previously cancelled booking.