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:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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') })
|
||||||
|
|
||||||
|
|||||||
@@ -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>)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
|
|||||||
@@ -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('周前肩颈紧张')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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('李同学你好,前两天的普拉提体验课感觉怎么样?')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -54,10 +54,16 @@ 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 },
|
||||||
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)
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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('请完成必答题后再生成画像')
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
throw new ForbiddenException('画像报告不属于当前用户')
|
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.
|
// 5. Create booking or revive a previously cancelled booking.
|
||||||
|
|||||||
Reference in New Issue
Block a user