4 Commits

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 22:45:44 +08:00
richarjiang
ab6602e41d feat: 新增个人身体画像评估与馆主经营助手
把 3 分钟身体状态评估做成独立获客链路,匿名测评后登录认领完整报告,并接入体验预约与今日待办。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 22:09:19 +08:00
richarjiang
b8c0dd6781 fix(server): hide CLOSED rows from preview for legacy mini-program compatibility
The previous fix made publishDaySlots CLOSE rows instead of DELETE them.
That works correctly on the server, but the live mini-program still ships
the old schedule.vue which only knows about isPublished (no status
field). It would re-render the CLOSED rows with the '已发布' badge,
making it look like 'clear day' did nothing.

Hide CLOSED rows from getSchedulePreview so the legacy client shows
'当日暂无排课' for rest days — the same UX it had before this fix
shipped. All-CLOSED days return []; days with a mix return only the
non-CLOSED rows; days the cron hasn't touched still return the ghost
template.

The republished mini-program (with status-aware rendering) will see the
same [] for rest days, which is consistent. Tests: 4 new specs covering
all-OPEN / all-CLOSED / mixed / empty-day cases.
2026-09-10 22:36:41 +08:00
richarjiang
806f3ee770 fix(server): preserve admin 'rest day' intent across cron runs
publishDaySlots used to DELETE the TimeSlot rows of a day that an admin
just 'cleared', which made the nightly slot-generation cron resurrect
the default schedule the next morning — a real production bug where
classes would reappear on a teacher's rest day.

Changes:
- publishDaySlots now CLOSEs (never DELETEs) orphaned rows, and materializes
  the default template for days with no rows so an empty publish still
  records intent. update on existing rows preserves their current status,
  so re-publishing the page cannot silently reopen a CLOSED rest day.
- SlotGeneratorService.generateSlots pre-fetches dates that already have any
  TimeSlot row and skips them, so the cron no longer overwrites admin-touched
  days even when admin only set status without changing times.
- SlotGeneratorService.prunePastClosedSlots (new) deletes past CLOSED slots
  with no bookings, scheduled nightly at 02:35, so the 'no DELETE' rule
  above does not leak rows forever.
- schedule.vue surfaces the CLOSED state with a grey '已关闭' badge and
  card style.

Tests: 12 new specs across time-slot.service.spec.ts (publishDaySlots
behaviour) and slot-generator.service.spec.ts (skip touched dates +
prune conditions). Full suite: 315 / 315 passing.
2026-09-10 22:32:32 +08:00
64 changed files with 5500 additions and 52 deletions

View File

@@ -114,3 +114,9 @@ pnpm deploy:server # 部署后端到生产环境
- 新增迁移目录只放 migration.sql测试沿用各模块 __tests__;部署配置与验收清单放 docs/member-care.md。
- 成长档案的两端共用 `components/MemberProgress.vue`,仅此跨端组件直接请求 progress API避免主包引用 admin 分包 Store馆主评价页面仍通过 admin Store 访问。
### 个人身体画像
- 线上获客归属独立 `body-portrait` 模块,不是成长档案的一个页面。共享契约在 `packages/shared/src/types/body-portrait.ts`,规格在 `docs/body-portrait.md`
- 评分只在服务端规则引擎计算;匿名测评用访问令牌哈希,不用可枚举 ID 做权限。
- 安全分流不计分。完整报告需登录认领,体验预约需手机号。现有 TRIAL 体验卡承接转化。
- 馆主「今日经营助手」只展示待办,不做成通用 CRM。主包不得引用 admin store。

118
docs/body-portrait.md Normal file
View File

@@ -0,0 +1,118 @@
# 个人身体画像增长系统
入口小程序「3 分钟身体状态评估」、报告页、体验预约,以及管理中心「今日经营助手」。线上获客归属独立 `body-portrait` 模块;线下专业评估与改善计划复用成长档案口径,不把问卷结果写成医疗诊断。
## 产品口径
- 对外名称:**3 分钟身体状态评估 / 个人身体画像**。只使用「关注度、可能表现、建议进一步评估」。禁止「诊断、疾病检测、患病风险、准确率」。
- 分数 0100 只表示**问卷关注度**,不表示身体能力、疼痛程度或患病概率。
- 陌生用户可匿名完成问卷。完成后先看摘要;微信登录并认领后看完整报告。点击体验预约时必须授权手机号。
- 安全分流不计分。命中持续明显疼痛、近期受伤、手脚明显麻木、医生限制运动或医疗恢复期时,不展示动作建议和强销售 CTA。
- 评分只在服务端用版本化规则引擎计算。客户端不得提交分数、画像类型或线索阶段。LLM 不决定结论。
- 现有 `TRIAL` 体验卡就是「评估 + 普拉提体验」。画像只补来源关联,不新增商品类型。
- 成长照片沿用私有 COS、短时签名和学员授权。馆主可见不等于可公开分享。
## 免责声明
各入口固定文案:
> 本评估不是医疗诊断或体态疾病检测,仅用于运动训练参考。若你正处于明显疼痛、伤病或医生限制运动的阶段,请先由医生或相应专业人士确认运动条件。
保存说明:匿名未认领草稿默认 30 天后清理。认领后的健康相关数据仅用于训练评估与跟进;学员可申请删除,审计事件保留必要边界。
## 问卷版本 `portrait-q-v1`
每屏一个决策,可多选的题目明确标注。不询问「骨盆前倾」等专业病名。
| 步骤 | ID | 题干 | 类型 |
| --- | --- | --- | --- |
| 1 | `concerns` | 最近身体哪里最困扰你? | 人体图多选 |
| 2 | `goal` | 如果训练有效果,你最希望看到什么变化? | 单选 |
| 3 | `sittingHours` | 每天坐多久? | 单选 |
| 4 | `exerciseFreq` | 平时运动频率? | 单选 |
| 5 | `workPosture` | 工作时最常见状态? | 单选 |
| 6 | `endOfDayFatigue` | 一天结束后,身体哪里最累? | 人体图多选 |
| 7 | `afterSitting` | 长时间坐着后,你会出现? | 多选 |
| 8 | `standingNotice` | 自然站立时,你有没有注意过? | 多选 |
| 9 | `safety` | 最近是否存在以下情况? | 多选,不计分 |
部位:头颈 `neck`、肩 `shoulder`、上背 `upperBack`、腰 `lowBack`、骨盆 `pelvis`、髋 `hip`、膝 `knee`、腿 `leg`
目标:看起来更挺拔 `posture`、肩颈没那么累 `neckRelief`、久坐更舒服 `backComfort`、腹部更有力量 `core`、身体线条更好 `shaping`、腿型/髋部状态改善 `hipLeg`、动作更稳定 `stability`、我有自己的目标 `other`
## 五维与关注度
维度:肩颈状态 `cervicalShoulder`、脊柱活动 `spinalMobility`、核心控制 `coreControl`、髋骨盆状态 `hipPelvis`、下肢稳定 `lowerLimb`
| 分数 | 关注度 |
| ---: | --- |
| 030 | 低关注 |
| 3150 | 轻度关注 |
| 5170 | 中度关注 |
| 7185 | 高关注 |
| 86100 | 重点关注 |
规则版本 `portrait-r-v1`:先按答案累加,再把每一维 clamp 到 0100。安全选项不加分。
## 画像模板8 种)
主类型 1 个,次类型最多 1 个。
| ID | 标签 |
| --- | --- |
| `sedentaryNeckTension` | 久坐肩颈紧张型 |
| `sedentaryCoreWeak` | 久坐核心弱化型 |
| `neckCompensation` | 肩颈代偿型 |
| `hipTight` | 髋部紧张型 |
| `upperLowerCross` | 上下交叉混合型 |
| `lowerLimbUnstable` | 下肢稳定不足型 |
| `mixedFatigue` | 全身疲劳累积型 |
| `lowOverallConcern` | 整体关注度较低型 |
## 报告结构
1. 一句话结论 + 主/次类型 + 问卷依据(充分/一般,不是准确率)
2. 最多 3 个优先关注点,必须引用用户原话/选项
3. 问题之间的关联(例如久坐 → 胸椎活动减少 → 肩颈承担更多)
4. 三条日常建议;安全分流不下发动作。动作建议含停止条件
5. 线上局限 → 到店评估项目 → 现有体验卡
匹配度只展示「问卷依据:充分 / 一般」,由有效回答数和证据条数计算。
## 漏斗事件
服务端事实:`assessment_started` `assessment_completed` `assessment_claimed` `phone_bound` `trial_purchased` `trial_booked` `trial_attended` `plan_purchased`
客户端可报但需幂等:`report_viewed` `advice_viewed` `trial_clicked` `report_shared`
`trial_attended` 只由体验卡预约被核销COMPLETED产生不等于自动完成也不含补录。
渠道:`xhs` `wechat` `dianping` `member_share` `organic` `other`。分享链接只用邀请码/活动码,不暴露内部用户 ID。
## 线索阶段
只允许前进:`VISIT``STARTED``COMPLETED``CLAIMED``PHONE_BOUND``TRIAL_PURCHASED``TRIAL_BOOKED``TRIAL_ATTENDED``PLAN_PURCHASED``TRAINING``RENEWAL_DUE``RENEWED`。馆主可记跟进备注,不能把阶段任意倒退覆盖历史。
## 线下评估与改善计划V2
到店 60 分钟:沟通目标 → 静态体态 → 活动度 → 动作控制 → 普拉提体验 → 解释结果。协议版本 `offline-v1`。operatorId 不是授课老师。
12 周计划三阶段14 重新建立控制58 改善活动能力912 整合身体动作。卖的是改善计划不是「20 节课」。
## 复测与分享V3
默认可在第 4 / 8 / 12 节生成复测待办。对比必须同协议、同观察项。成长卡片默认不含照片;公开导出需学员单次确认。分享落地进入 `source=member_share` 的新测评。
## 部署
新增表见 `packages/server/prisma/migrations/20260911180000_body_portrait/migration.sql`。发布顺序:`prisma migrate deploy` → 后端 → 小程序。回退先回退应用,表可保留。
## 验收清单
- 匿名可完成、可续填;登录后同一会话只能认领一次。
- 完整报告需登录;体验预约需手机号。
- 安全分流不计分、无动作建议、无强销售。
- 分数与画像只来自服务端快照。
- 馆主今日待办能看到「测完未约 / 体验后未购 / 待复测」。
- 漏斗按渠道聚合;补录不进入获客转化。
- 照片与分享遵守成长档案隐私规则。

View File

@@ -0,0 +1,45 @@
<template>
<view class="sil">
<text class="hint">点选身体区域可多选</text>
<view class="figure">
<view class="head zone" :class="{ on: selected.includes(BodyRegion.NECK) }" @tap="toggle(BodyRegion.NECK)">头颈</view>
<view class="row">
<view class="shoulder zone" :class="{ on: selected.includes(BodyRegion.SHOULDER) }" @tap="toggle(BodyRegion.SHOULDER)"></view>
</view>
<view class="torso zone" :class="{ on: selected.includes(BodyRegion.UPPER_BACK) }" @tap="toggle(BodyRegion.UPPER_BACK)">上背</view>
<view class="waist zone" :class="{ on: selected.includes(BodyRegion.LOW_BACK) }" @tap="toggle(BodyRegion.LOW_BACK)"></view>
<view class="pelvis zone" :class="{ on: selected.includes(BodyRegion.PELVIS) }" @tap="toggle(BodyRegion.PELVIS)">骨盆</view>
<view class="row">
<view class="hip zone" :class="{ on: selected.includes(BodyRegion.HIP) }" @tap="toggle(BodyRegion.HIP)"></view>
</view>
<view class="row">
<view class="knee zone" :class="{ on: selected.includes(BodyRegion.KNEE) }" @tap="toggle(BodyRegion.KNEE)"></view>
</view>
<view class="leg zone" :class="{ on: selected.includes(BodyRegion.LEG) }" @tap="toggle(BodyRegion.LEG)"></view>
</view>
</view>
</template>
<script setup lang="ts">
import { BodyRegion } from '@mp-pilates/shared'
const props = defineProps<{ selected: readonly string[] }>()
const emit = defineEmits<{ (e: 'change', value: BodyRegion[]): void }>()
function toggle(region: BodyRegion) {
const current = new Set(props.selected)
if (current.has(region)) current.delete(region)
else current.add(region)
emit('change', [...current] as BodyRegion[])
}
</script>
<style lang="scss" scoped>
.sil { padding: 12rpx 0 8rpx; }
.hint { display: block; text-align: center; color: #8a7b6e; font-size: 22rpx; margin-bottom: 16rpx; }
.figure { display: flex; flex-direction: column; align-items: center; gap: 10rpx; }
.zone { min-width: 160rpx; padding: 16rpx 28rpx; border-radius: 999rpx; background: #f3eee8; color: #6b5c50; font-size: 24rpx; text-align: center; }
.zone.on { background: #6b8276; color: #fff; }
.head { min-width: 120rpx; }
.row { display: flex; gap: 12rpx; }
</style>

View File

@@ -0,0 +1,19 @@
<template>
<view class="progress">
<view class="bar"><view class="fill" :style="{ width: `${percent}%` }" /></view>
<text class="label">{{ step }}/{{ total }}</text>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ step: number; total: number }>()
const percent = computed(() => Math.round((props.step / props.total) * 100))
</script>
<style lang="scss" scoped>
.progress { display: flex; align-items: center; gap: 16rpx; margin: 8rpx 0 24rpx; }
.bar { flex: 1; height: 8rpx; background: #efe8e1; border-radius: 8rpx; overflow: hidden; }
.fill { height: 100%; background: #6b8276; }
.label { font-size: 22rpx; color: #8a7b6e; }
</style>

View File

@@ -0,0 +1,69 @@
<template>
<canvas canvas-id="portraitRadar" class="radar" />
</template>
<script setup lang="ts">
import { getCurrentInstance, onMounted, watch } from 'vue'
import { BODY_DIMENSION_LABELS, type BodyPortraitScores } from '@mp-pilates/shared'
const props = defineProps<{ scores: BodyPortraitScores }>()
const instance = getCurrentInstance()
const labels = ['肩颈', '脊柱', '核心', '髋骨盆', '下肢']
const keys = ['cervicalShoulder', 'spinalMobility', 'coreControl', 'hipPelvis', 'lowerLimb'] as const
function draw() {
const ctx = uni.createCanvasContext('portraitRadar', instance?.proxy)
const w = 280
const h = 280
const cx = w / 2
const cy = h / 2
const radius = 96
ctx.clearRect(0, 0, w, h)
ctx.setStrokeStyle('#d9cfc5')
ctx.setFillStyle('#edf2ec')
for (let ring = 1; ring <= 4; ring++) {
ctx.beginPath()
for (let i = 0; i < 5; i++) {
const angle = -Math.PI / 2 + (Math.PI * 2 * i) / 5
const x = cx + Math.cos(angle) * radius * (ring / 4)
const y = cy + Math.sin(angle) * radius * (ring / 4)
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.closePath()
ctx.stroke()
}
ctx.setFillStyle('rgba(107,130,118,0.35)')
ctx.setStrokeStyle('#6b8276')
ctx.beginPath()
keys.forEach((key, i) => {
const angle = -Math.PI / 2 + (Math.PI * 2 * i) / 5
const value = Math.max(8, props.scores[key]) / 100
const x = cx + Math.cos(angle) * radius * value
const y = cy + Math.sin(angle) * radius * value
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
})
ctx.closePath()
ctx.fill()
ctx.stroke()
ctx.setFillStyle('#5d5148')
ctx.setFontSize(11)
ctx.setTextAlign('center')
labels.forEach((label, i) => {
const angle = -Math.PI / 2 + (Math.PI * 2 * i) / 5
const x = cx + Math.cos(angle) * (radius + 22)
const y = cy + Math.sin(angle) * (radius + 22)
ctx.fillText(label, x, y)
})
ctx.draw()
}
onMounted(draw)
watch(() => props.scores, draw, { deep: true })
void BODY_DIMENSION_LABELS
</script>
<style lang="scss" scoped>
.radar { width: 560rpx; height: 560rpx; margin: 0 auto; }
</style>

View File

@@ -42,6 +42,8 @@ const emit = defineEmits<{
const menuItems = computed<MenuItem[]>(() => {
const items: MenuItem[] = [
{ key: 'progress', type: 'item', title: '我的成长档案', path: '/pages/profile/progress', requireAuth: true },
{ key: 'portrait', type: 'item', title: '身体状态评估', path: '/pages/portrait/index' },
{ key: 'plan', type: 'item', title: '我的改善计划', path: '/pages/portrait/plan', requireAuth: true },
...(props.isAdmin
? [{
key: 'teaching-schedule',

View File

@@ -1,27 +1,14 @@
<template>
<view class="quick-entry">
<!-- Not logged in -->
<view v-if="!userStore.loggedIn" class="entry-pill pill-login" @tap="handleLogin">
<text class="pill-label">欢迎来到工作室</text>
<view v-if="!userStore.loggedIn || userStore.memberships.length === 0" class="entry-pill pill-login" @tap="handlePortrait">
<text class="pill-label">3 分钟了解自己的身体状态</text>
<view class="pill-action action-login">
<text class="pill-action-text">微信登录</text>
<text class="pill-action-text">开始评估</text>
</view>
</view>
<!-- Logged in, no memberships new user -->
<view
v-else-if="userStore.loggedIn && userStore.memberships.length === 0"
class="entry-pill pill-trial"
@tap="handleTrialEntry"
>
<text class="pill-label">首次体验专属课程</text>
<view class="pill-action action-trial">
<text class="pill-action-text">预约体验课</text>
</view>
</view>
<!-- Has valid active card -->
<!-- Has valid active card -->
<template v-else-if="userStore.hasValidMembership">
<view class="entry-pill pill-active" @tap="handleBooking">
<text class="pill-label pill-label-active">{{ activeMembershipLabel }}</text>
@@ -75,6 +62,10 @@ async function handleLogin() {
}
}
function handlePortrait() {
uni.navigateTo({ url: '/pages/portrait/index' })
}
function handleTrialEntry() {
uni.navigateTo({ url: '/pages/card/detail?trial=1' })
}

View File

@@ -0,0 +1,16 @@
<template>
<view class="notice">
<text class="title">建议先确认运动条件</text>
<text class="body">{{ message }}</text>
</view>
</template>
<script setup lang="ts">
defineProps<{ message: string }>()
</script>
<style lang="scss" scoped>
.notice { margin: 24rpx 0; padding: 28rpx; border-radius: 20rpx; background: #f7eee8; }
.title { display: block; font-size: 30rpx; color: #7a4e3e; margin-bottom: 12rpx; }
.body { display: block; font-size: 26rpx; line-height: 1.7; color: #8a6458; }
</style>

View File

@@ -176,6 +176,77 @@
"style": {
"navigationStyle": "custom"
}
},
{
"path": "portrait-today",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "portrait-leads",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "portrait-lead-detail",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "portrait-assessment",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "portrait-plan",
"style": {
"navigationStyle": "custom"
}
}
]
},
{
"root": "pages/portrait",
"pages": [
{
"path": "index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "assessment",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "report",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "advice",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "trial",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "plan",
"style": {
"navigationStyle": "custom"
}
}
]
}

View File

@@ -2,6 +2,21 @@
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="管理中心" show-back />
<!-- Section: 课务运营 -->
<view class="section-header">
<text class="section-title">今日经营</text>
</view>
<view class="list">
<view class="list-item" @tap="navigate('/pages/admin/portrait-today')">
<text class="item-title">今日经营助手</text>
<text class="arrow-text"></text>
</view>
<view class="list-item" @tap="navigate('/pages/admin/portrait-leads')">
<text class="item-title">身体画像线索</text>
<text class="arrow-text"></text>
</view>
</view>
<!-- Section: 课务运营 -->
<view class="section-header">
<text class="section-title">课务运营</text>

View File

@@ -0,0 +1,81 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="专业评估" show-back />
<view class="card" v-for="field in fields" :key="field.key">
<text class="name">{{ field.label }} {{ form.observations[field.key] }}/5</text>
<slider :min="1" :max="5" :value="form.observations[field.key]" @change="onObserve(field.key, $event)" />
</view>
<view class="card">
<text class="name">主观紧张 0-10</text>
<slider :min="0" :max="10" :value="form.subjectiveTension || 0" @change="onTension" />
</view>
<view class="card">
<textarea v-model="form.coachSummary" placeholder="教练观察" class="area" />
<textarea v-model="form.trainingFocus" placeholder="训练重点" class="area" />
<textarea v-model="form.phaseGoal" placeholder="第一阶段目标" class="area" />
</view>
<view class="cta" @tap="save">保存评估报告</view>
</view>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from './stores/admin'
import { getErrorMessage } from '../../utils/auth'
import { ProfessionalAssessmentKind } from '@mp-pilates/shared'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const admin = useAdminStore()
const userId = ref('')
const form = reactive({
kind: ProfessionalAssessmentKind.INITIAL,
recordedAt: new Date().toISOString().slice(0, 10),
observations: { headPosition: 3, shoulderPosition: 3, thoracicExtension: 3, shoulderFlexion: 3, breathing: 3, pelvis: 3, coreControl: 3, hipMobility: 3, singleLeg: 3 },
subjectiveTension: 5,
coachSummary: '',
trainingFocus: '',
phaseGoal: '',
})
const fields = [
{ key: 'headPosition', label: '头部位置' },
{ key: 'shoulderPosition', label: '肩部位置' },
{ key: 'thoracicExtension', label: '胸椎活动' },
{ key: 'shoulderFlexion', label: '肩屈活动' },
{ key: 'breathing', label: '呼吸模式' },
{ key: 'pelvis', label: '骨盆位置' },
{ key: 'coreControl', label: '核心控制' },
{ key: 'hipMobility', label: '髋部活动' },
{ key: 'singleLeg', label: '单腿稳定' },
] as const
onLoad((query) => { userId.value = String(query?.userId || '') })
function onObserve(key: keyof typeof form.observations, event: { detail: { value: number } }) {
form.observations[key] = Number(event.detail.value)
}
function onTension(event: { detail: { value: number } }) {
form.subjectiveTension = Number(event.detail.value)
}
async function save() {
try {
await admin.createProfessionalAssessment(userId.value, form)
uni.showToast({ title: '已保存', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '保存失败'), icon: 'none' })
}
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 32rpx 80rpx; }
.card { background: #fff; border-radius: 20rpx; padding: 24rpx; margin-top: 16rpx; }
.name { display: block; font-size: 26rpx; color: #4a4035; margin-bottom: 8rpx; }
.area { width: 100%; min-height: 120rpx; font-size: 26rpx; margin-top: 12rpx; }
.cta { margin-top: 32rpx; height: 88rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; }
</style>

View File

@@ -0,0 +1,67 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="线索详情" show-back />
<view v-if="lead" class="card">
<text class="name">{{ lead.nickname }} · {{ lead.phone || '未留手机' }}</text>
<text class="detail">来源 {{ lead.source || 'organic' }} · {{ lead.campaignId || '无活动' }}</text>
<text class="detail">画像 {{ lead.report?.headline || '尚未生成' }}</text>
</view>
<view v-if="lead?.report" class="card">
<text class="name">五维关注度</text>
<text class="detail">肩颈 {{ lead.report.scores.cervicalShoulder }} · 脊柱 {{ lead.report.scores.spinalMobility }} · 核心 {{ lead.report.scores.coreControl }} · 髋骨盆 {{ lead.report.scores.hipPelvis }} · 下肢 {{ lead.report.scores.lowerLimb }}</text>
</view>
<view v-if="lead?.safetyFlagged" class="card"><text class="name">安全分流</text><text class="detail">不要强推体验课先确认运动条件</text></view>
<view class="card">
<text class="name">首次评估建议</text>
<text v-for="hint in lead?.firstAssessmentHints || []" :key="hint" class="detail"> {{ hint }}</text>
</view>
<view class="card">
<text class="name">跟进文案</text>
<text class="draft">{{ lead?.followUpDraft }}</text>
<view class="cta" @tap="copy">复制 微信发送</view>
</view>
<view class="cta ghost" @tap="goAssess">记录线下评估</view>
<view class="cta ghost" @tap="goPlan">生成 12 周计划</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from './stores/admin'
import type { GrowthLeadDetail } from '@mp-pilates/shared'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const admin = useAdminStore()
const lead = ref<GrowthLeadDetail | null>(null)
const leadId = ref('')
onLoad(async (query) => {
leadId.value = String(query?.id || '')
lead.value = await admin.fetchGrowthLead(leadId.value)
})
function copy() {
if (!lead.value) return
uni.setClipboardData({ data: lead.value.followUpDraft })
}
function goAssess() {
if (!lead.value) return
uni.navigateTo({ url: `/pages/admin/portrait-assessment?userId=${lead.value.userId}` })
}
function goPlan() {
if (!lead.value) return
uni.navigateTo({ url: `/pages/admin/portrait-plan?userId=${lead.value.userId}` })
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 32rpx 80rpx; }
.card { background: #fff; border-radius: 20rpx; padding: 28rpx; margin-top: 16rpx; }
.name { display: block; font-size: 30rpx; color: #4a4035; }
.detail, .draft { display: block; margin-top: 10rpx; color: #7a6a5a; font-size: 24rpx; line-height: 1.7; }
.cta { margin-top: 20rpx; height: 84rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; }
.ghost { background: #efe8e1; color: #5d5148; }
</style>

View File

@@ -0,0 +1,41 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="身体画像线索" show-back />
<view v-for="item in items" :key="item.id" class="card" @tap="open(item.id)">
<text class="name">{{ item.nickname }} · {{ stageLabel(item.stage) }}</text>
<text class="detail">{{ item.phone || '未留手机号' }} · {{ item.source || 'organic' }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from './stores/admin'
import { GROWTH_LEAD_STAGE_LABELS, type GrowthLeadStage, type GrowthLeadSummary } from '@mp-pilates/shared'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const admin = useAdminStore()
const items = ref<GrowthLeadSummary[]>([])
onShow(async () => {
const result = await admin.fetchGrowthLeads()
items.value = [...result.items]
})
function stageLabel(stage: GrowthLeadStage) {
return GROWTH_LEAD_STAGE_LABELS[stage] || stage
}
function open(id: string) {
uni.navigateTo({ url: `/pages/admin/portrait-lead-detail?id=${id}` })
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 32rpx 40rpx; }
.card { background: #fff; border-radius: 20rpx; padding: 28rpx; margin-top: 16rpx; }
.name { display: block; font-size: 30rpx; color: #4a4035; }
.detail { display: block; margin-top: 8rpx; color: #7a6a5a; font-size: 24rpx; }
</style>

View File

@@ -0,0 +1,46 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="12 周改善计划" show-back />
<text class="lead">生成后学员可在小程序看到三阶段计划而不是 12 节课</text>
<view class="cta" @tap="create">生成计划</view>
<view v-if="plan" class="card">
<text class="name">{{ plan.title }}</text>
<text v-for="phase in plan.phases" :key="phase.id" class="detail">{{ phase.name }} · {{ phase.lessonStart }}-{{ phase.lessonEnd }} </text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from './stores/admin'
import { getErrorMessage } from '../../utils/auth'
import type { TrainingPlanRecord } from '@mp-pilates/shared'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const admin = useAdminStore()
const userId = ref('')
const plan = ref<TrainingPlanRecord | null>(null)
onLoad((query) => { userId.value = String(query?.userId || '') })
async function create() {
try {
plan.value = await admin.createTrainingPlan(userId.value, { title: '你的 12 周身体改善计划', weeks: 12 })
uni.showToast({ title: '已生成', icon: 'success' })
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '生成失败'), icon: 'none' })
}
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 32rpx 60rpx; }
.lead { display: block; padding: 24rpx 8rpx; color: #7a6a5a; font-size: 26rpx; line-height: 1.6; }
.cta { height: 88rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; }
.card { margin-top: 24rpx; background: #fff; border-radius: 20rpx; padding: 28rpx; }
.name { display: block; font-size: 30rpx; color: #4a4035; }
.detail { display: block; margin-top: 10rpx; color: #7a6a5a; font-size: 24rpx; }
</style>

View File

@@ -0,0 +1,55 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="今日经营助手" show-back />
<view class="hero">
<text class="title">今天有 {{ dashboard.tasks.length }} 件值得关注的事</text>
</view>
<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="detail">{{ task.detail }}</text>
</view>
<view class="card">
<text class="name">渠道转化</text>
<text v-for="row in dashboard.funnel" :key="row.source" class="detail">
{{ row.source }} · 测评 {{ row.completed }} · 预约 {{ row.booked }} · 到店 {{ row.attended }} · 成交 {{ row.purchased }}
</text>
</view>
<view class="link" @tap="navigate('/pages/admin/portrait-leads')">全部线索 </view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from './stores/admin'
import type { GrowthTodayDashboard, GrowthTodayTask } from '@mp-pilates/shared'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const admin = useAdminStore()
const dashboard = ref<GrowthTodayDashboard>({ tasks: [], funnel: [] })
onShow(async () => {
dashboard.value = await admin.fetchGrowthToday()
})
function handleTask(task: GrowthTodayTask) {
if (task.kind === 'reassessment_due') {
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 }) }
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 32rpx 60rpx; }
.hero { padding: 24rpx 8rpx; }
.title { font-size: 36rpx; color: #4a4035; }
.card { background: #fff; border-radius: 20rpx; padding: 28rpx; margin-bottom: 16rpx; }
.name { display: block; font-size: 30rpx; color: #4a4035; }
.detail { display: block; margin-top: 10rpx; color: #7a6a5a; font-size: 24rpx; line-height: 1.6; }
.link { padding: 24rpx 8rpx; color: #6b8276; }
</style>

View File

@@ -160,6 +160,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { ScheduleSlotPreview } from '@mp-pilates/shared'
import { TimeSlotStatus } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from './stores/admin'
@@ -181,6 +182,7 @@ interface EditableSlot {
endTime: string
capacity: number
bookedCount: number
status: TimeSlotStatus
isPublished: boolean
isNew: boolean
isRemoved: boolean
@@ -222,6 +224,7 @@ function mapPreviewToEditable(previews: readonly ScheduleSlotPreview[]): Editabl
endTime: p.endTime,
capacity: p.capacity,
bookedCount: p.bookedCount,
status: (p.status ?? TimeSlotStatus.OPEN) as TimeSlotStatus,
isPublished: p.isPublished,
isNew: false,
isRemoved: false,
@@ -323,6 +326,7 @@ function submitAdd() {
endTime: addForm.value.endTime,
capacity,
bookedCount: 0,
status: TimeSlotStatus.OPEN,
isPublished: false,
isNew: true,
isRemoved: false,
@@ -396,18 +400,21 @@ async function doPublish(slots: readonly EditableSlot[]) {
// ── Style helpers ─────────────────────────────────────────
function slotCardClass(slot: EditableSlot): string {
if (slot.status === TimeSlotStatus.CLOSED) return 'slot-card--closed'
if (slot.isNew) return 'slot-card--new'
if (slot.isPublished) return 'slot-card--published'
return 'slot-card--template'
}
function slotBadgeClass(slot: EditableSlot): string {
if (slot.status === TimeSlotStatus.CLOSED) return 'badge--closed'
if (slot.isNew) return 'badge--new'
if (slot.isPublished) return 'badge--published'
return 'badge--template'
}
function slotBadgeText(slot: EditableSlot): string {
if (slot.status === TimeSlotStatus.CLOSED) return '已关闭'
if (slot.isNew) return '新增'
if (slot.isPublished) return '已发布'
return '默认时段'
@@ -495,6 +502,12 @@ onMounted(() => {
border-color: #3498db;
background: rgba(52, 152, 219, 0.04);
}
&--closed {
opacity: 0.55;
background: #fafafa;
border-color: #e5e5e5;
}
}
/* ── Slot header ─────────────────────────── */
@@ -516,6 +529,8 @@ onMounted(() => {
.badge--template .slot-badge-text { font-size: 22rpx; color: #b8860b; font-weight: 600; }
.badge--new { background: rgba(52, 152, 219, 0.1); }
.badge--new .slot-badge-text { font-size: 22rpx; color: #3498db; font-weight: 600; }
.badge--closed { background: rgba(0, 0, 0, 0.06); }
.badge--closed .slot-badge-text { font-size: 22rpx; color: #888; font-weight: 600; }
.booked-info { }
.booked-text { font-size: 22rpx; color: #e67e22; }

View File

@@ -25,6 +25,13 @@ import type {
AdminArrangeBookingDto,
MembershipWithCardType,
BookingWithDetails,
GrowthTodayDashboard,
GrowthLeadSummary,
GrowthLeadDetail,
ProfessionalAssessmentSessionRecord,
CreateProfessionalAssessmentDto,
TrainingPlanRecord,
CreateTrainingPlanDto,
} from '@mp-pilates/shared'
interface LegacyPaginatedData<T> {
@@ -263,6 +270,32 @@ export const useAdminStore = defineStore('admin', () => {
return get<TeachingAnalytics>('/admin/teaching-analytics', { month })
}
async function fetchGrowthToday() {
return get<GrowthTodayDashboard>('/admin/growth/today')
}
async function fetchGrowthLeads(params: { page?: number; search?: string; stage?: string } = {}) {
return get<PaginatedData<GrowthLeadSummary>>('/admin/growth/leads', params)
}
async function fetchGrowthLead(id: string) {
return get<GrowthLeadDetail>(`/admin/growth/leads/${id}`)
}
async function createProfessionalAssessment(userId: string, dto: CreateProfessionalAssessmentDto) {
return post<ProfessionalAssessmentSessionRecord>(
`/admin/members/${userId}/professional-assessments`,
dto as unknown as Record<string, unknown>,
)
}
async function createTrainingPlan(userId: string, dto: CreateTrainingPlanDto) {
return post<TrainingPlanRecord>(
`/admin/members/${userId}/training-plans`,
dto as unknown as Record<string, unknown>,
)
}
return {
fetchReviews, fetchReviewTrend,
fetchTeachingAnalytics,
@@ -304,5 +337,10 @@ export const useAdminStore = defineStore('admin', () => {
fetchSchedulePreview,
previewScheduleByDate,
publishDaySlots,
fetchGrowthToday,
fetchGrowthLeads,
fetchGrowthLead,
createProfessionalAssessment,
createTrainingPlan,
}
})

View File

@@ -0,0 +1,45 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="日常建议" show-back />
<SafetyNotice v-if="report?.safety.flagged" :message="report.safety.message || ''" />
<view v-else-if="report?.advice">
<text class="title">给你的三个日常建议</text>
<view v-for="item in report.advice.items" :key="item.title" class="card">
<text class="name">{{ item.title }}</text>
<text class="body">{{ item.detail }}</text>
<text class="stop">如出现{{ item.stopIf }}</text>
</view>
</view>
<text class="disclaimer">这些建议用于日常活动参考不能替代现场评估</text>
<view class="cta" @tap="next">了解线上画像的局限</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { onShow } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import SafetyNotice from '../../components/SafetyNotice.vue'
import { getSystemLayout } from '../../utils/system'
import { useBodyPortraitStore } from '../../stores/body-portrait'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const portrait = useBodyPortraitStore()
const { report } = storeToRefs(portrait)
onShow(() => { portrait.track('advice_viewed') })
function next() {
uni.navigateTo({ url: '/pages/portrait/trial' })
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 40rpx 120rpx; }
.title { display: block; font-size: 40rpx; color: #4a4035; margin: 12rpx 0 24rpx; }
.card { background: #fff; border-radius: 24rpx; padding: 28rpx; margin-bottom: 20rpx; }
.name { display: block; font-size: 30rpx; color: #4a4035; }
.body, .stop, .disclaimer { display: block; margin-top: 12rpx; color: #7a6a5a; font-size: 26rpx; line-height: 1.7; }
.cta { margin-top: 32rpx; height: 92rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; }
</style>

View File

@@ -0,0 +1,156 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="身体状态评估" show-back />
<PortraitProgress :step="step + 1" :total="steps.length" />
<text class="q">{{ current.title }}</text>
<text class="d">{{ current.hint }}</text>
<BodySilhouette v-if="current.body" :selected="bodyValue" @change="onBody" />
<view v-else class="options">
<view
v-for="option in current.options"
:key="option.value"
class="opt"
:class="{ on: isOn(option.value) }"
@tap="pick(option.value)"
>
{{ option.label }}
</view>
</view>
<text class="disclaimer">非医疗诊断仅用于运动训练参考</text>
<view class="nav">
<view v-if="step > 0" class="ghost" @tap="step -= 1">上一步</view>
<view class="next" @tap="next">{{ step === steps.length - 1 ? '生成画像' : '继续' }}</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import BodySilhouette from '../../components/BodySilhouette.vue'
import PortraitProgress from '../../components/PortraitProgress.vue'
import { getSystemLayout } from '../../utils/system'
import { getErrorMessage } from '../../utils/auth'
import { useBodyPortraitStore } from '../../stores/body-portrait'
import {
AFTER_SITTING_LABELS,
AfterSitting,
BodyRegion,
EXERCISE_FREQ_LABELS,
ExerciseFreq,
PORTRAIT_GOAL_LABELS,
PortraitGoal,
SAFETY_FLAG_LABELS,
SafetyFlag,
SITTING_HOURS_LABELS,
SittingHours,
STANDING_NOTICE_LABELS,
StandingNotice,
WORK_POSTURE_LABELS,
WorkPosture,
} from '@mp-pilates/shared'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const portrait = useBodyPortraitStore()
const step = ref(0)
const steps = [
{ key: 'concerns', title: '最近身体哪里最困扰你?', hint: '可以多选,我们会据此调整后面的问题。', body: true },
{ key: 'goal', title: '如果训练有效果,你最希望看到什么变化?', hint: '后面的报告会用你自己的目标来写。', options: enumOptions(PORTRAIT_GOAL_LABELS) },
{ key: 'sittingHours', title: '每天坐多久?', hint: '这能帮助理解当前身体状态是怎么形成的。', options: enumOptions(SITTING_HOURS_LABELS) },
{ key: 'exerciseFreq', title: '平时运动频率?', hint: '没有对错,如实选择即可。', options: enumOptions(EXERCISE_FREQ_LABELS) },
{ key: 'workPosture', title: '工作时最常见状态?', hint: '选最常出现的一种。', options: enumOptions(WORK_POSTURE_LABELS) },
{ key: 'endOfDayFatigue', title: '一天结束后,身体哪里最累?', hint: '可以和困扰区域不同。', body: true },
{ key: 'afterSitting', title: '长时间坐着后,你会出现?', hint: '可多选。', options: enumOptions(AFTER_SITTING_LABELS), multi: true },
{ key: 'standingNotice', title: '自然站立时,你有没有注意过?', hint: '用日常语言描述,不需要专业判断。', options: enumOptions(STANDING_NOTICE_LABELS), multi: true },
{ key: 'safety', title: '最近是否存在以下情况?', hint: '这一题不计入关注度,只用于安全分流。', options: enumOptions(SAFETY_FLAG_LABELS), multi: true },
]
const current = computed(() => steps[step.value])
const bodyValue = computed(() => (current.value.key === 'concerns' ? portrait.answers.concerns : portrait.answers.endOfDayFatigue) as string[])
function enumOptions(labels: Record<string, string>) {
return Object.entries(labels).map(([value, label]) => ({ value, label }))
}
function isOn(value: string) {
const answers = portrait.answers as unknown as Record<string, unknown>
const currentValue = answers[current.value.key]
return Array.isArray(currentValue) ? currentValue.includes(value) : currentValue === value
}
async function persist(patch: Record<string, unknown>) {
await portrait.saveAnswers(patch as never)
}
async function onBody(value: BodyRegion[]) {
await persist({ [current.value.key]: value })
}
async function pick(value: string) {
if (current.value.multi) {
const answers = portrait.answers as unknown as Record<string, unknown>
const list = [...((answers[current.value.key] as string[]) || [])]
const exclusiveNone = current.value.key === 'safety' || current.value.key === 'afterSitting' || current.value.key === 'standingNotice'
if (value === 'none' || value === 'unnoticed') {
await persist({ [current.value.key]: [value] })
return
}
const next = list.includes(value) ? list.filter((item) => item !== value) : [...list.filter((item) => item !== 'none' && item !== 'unnoticed'), value]
if (exclusiveNone && !next.length) return
await persist({ [current.value.key]: next })
return
}
await persist({ [current.value.key]: value })
}
async function next() {
try {
if (step.value < steps.length - 1) {
step.value += 1
return
}
uni.showLoading({ title: '生成画像...' })
await portrait.complete()
uni.hideLoading()
uni.redirectTo({ url: '/pages/portrait/report' })
} catch (err) {
uni.hideLoading()
uni.showToast({ title: getErrorMessage(err, '请先完成必答题'), icon: 'none' })
}
}
onLoad(async () => {
try {
await portrait.ensureSession()
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '测评暂时无法开始'), icon: 'none' })
}
})
void AfterSitting
void ExerciseFreq
void PortraitGoal
void SafetyFlag
void SittingHours
void StandingNotice
void WorkPosture
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 40rpx 160rpx; }
.q { display: block; font-size: 40rpx; line-height: 1.4; color: #4a4035; }
.d { display: block; margin: 16rpx 0 28rpx; color: #8a7b6e; font-size: 26rpx; line-height: 1.6; }
.options { display: flex; flex-direction: column; gap: 16rpx; }
.opt { padding: 28rpx; border-radius: 20rpx; background: #fff; color: #5d5148; font-size: 28rpx; }
.opt.on { background: #6b8276; color: #fff; }
.disclaimer { display: block; margin-top: 36rpx; color: #a09080; font-size: 22rpx; }
.nav { position: fixed; left: 0; right: 0; bottom: 0; padding: 24rpx 40rpx 48rpx; display: flex; gap: 16rpx; background: #fbf9f6; }
.ghost, .next { flex: 1; height: 88rpx; border-radius: 999rpx; display: flex; align-items: center; justify-content: center; }
.ghost { background: #efe8e1; color: #6b5c50; }
.next { background: #6b8276; color: #fff; }
</style>

View File

@@ -0,0 +1,50 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="身体状态评估" :show-back="false" />
<view class="hero">
<text class="kicker">3 分钟身体状态评估</text>
<text class="title">最近身体哪里最让你困扰</text>
<text class="lead">用一份对话式问卷生成你的身体画像不是医疗诊断只用于运动训练参考</text>
<text v-if="stats?.completedCount" class="count">已有 {{ stats.completedCount }} 人完成评估</text>
<view class="cta" @tap="start">开始测试</view>
</view>
<view class="trust">
<text>STOTT 认证教练</text>
<text>一对一专业评估</text>
<text>非医疗诊断仅用于运动训练参考</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { onLoad } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useBodyPortraitStore } from '../../stores/body-portrait'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const portrait = useBodyPortraitStore()
const { stats } = storeToRefs(portrait)
onLoad((query) => {
portrait.captureAttribution((query || {}) as Record<string, string>)
portrait.fetchStats().catch(() => {})
})
function start() {
uni.navigateTo({ url: '/pages/portrait/assessment' })
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 40rpx 80rpx; }
.hero { padding-top: 48rpx; }
.kicker { display: block; color: #8a7b6e; font-size: 22rpx; letter-spacing: 3rpx; }
.title { display: block; margin-top: 20rpx; font-size: 48rpx; line-height: 1.35; color: #4a4035; }
.lead { display: block; margin-top: 20rpx; font-size: 28rpx; line-height: 1.7; color: #7a6a5a; }
.count { display: block; margin-top: 24rpx; color: #6b8276; font-size: 24rpx; }
.cta { margin-top: 48rpx; height: 96rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 30rpx; }
.trust { margin-top: 56rpx; padding: 28rpx; border-radius: 24rpx; background: #fff; display: flex; flex-direction: column; gap: 12rpx; color: #7a6a5a; font-size: 24rpx; }
</style>

View File

@@ -0,0 +1,70 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="改善计划" show-back />
<view v-if="plan" class="card">
<text class="title">{{ plan.title }}</text>
<view v-for="phase in plan.phases" :key="phase.id" class="phase">
<text class="name">{{ phase.name }}</text>
<text class="meta"> {{ phase.lessonStart }}{{ phase.lessonEnd }} · {{ phase.focus.join(' / ') }}</text>
<text class="body">{{ phase.summary }}</text>
</view>
</view>
<view v-if="todos.length" class="card">
<text class="name">阶段复测</text>
<text v-for="todo in todos" :key="todo.id" class="body"> {{ todo.lessonCheckpoint }} {{ todo.completedAt ? '已完成' : '待安排' }}</text>
</view>
<view v-if="share" class="card">
<text class="title">{{ share.title }}</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 ? row.first : `${row.first} ${row.latest}` }}</text>
</view>
<view v-if="plan" class="cta" @tap="makeShare">生成我的成长卡片</view>
<view v-else class="body">完成到店评估后教练会为你生成 12 周改善计划</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { get, post } from '../../utils/request'
import { getErrorMessage } from '../../utils/auth'
import type { GrowthShareCardRecord, ReassessmentTodoRecord, TrainingPlanRecord } from '@mp-pilates/shared'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const plan = ref<TrainingPlanRecord | null>(null)
const todos = ref<ReassessmentTodoRecord[]>([])
const share = ref<GrowthShareCardRecord | null>(null)
onShow(async () => {
try {
const plans = await get<TrainingPlanRecord[]>('/body-portrait/plans')
plan.value = plans[0] || null
todos.value = await get<ReassessmentTodoRecord[]>('/body-portrait/todos')
} catch {}
})
async function makeShare() {
try {
share.value = await post<GrowthShareCardRecord>('/body-portrait/share-cards', { includePhotos: false })
uni.showToast({ title: '已生成,可分享给朋友', icon: 'none' })
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '暂时无法生成'), icon: 'none' })
}
}
onShareAppMessage(() => ({
title: share.value?.title || '我的普拉提变化',
path: share.value ? `/pages/portrait/index?source=member_share&shareCode=${share.value.shareCode}` : '/pages/portrait/index?source=member_share',
}))
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 40rpx 80rpx; }
.card { background: #fff; border-radius: 24rpx; padding: 28rpx; margin-bottom: 20rpx; }
.title { display: block; font-size: 36rpx; color: #4a4035; }
.name { display: block; margin-top: 16rpx; font-size: 30rpx; color: #4a4035; }
.meta, .body { display: block; margin-top: 10rpx; color: #7a6a5a; font-size: 26rpx; line-height: 1.6; }
.cta { height: 92rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; }
</style>

View File

@@ -0,0 +1,75 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="你的身体画像" show-back />
<view v-if="teaser" class="card">
<text class="kicker">你的身体画像</text>
<text class="title">{{ teaser.headline }}</text>
<text class="summary">{{ claimed && report ? report.summary : '登录后查看完整个性化报告。问卷画像只能反映生活习惯和主观感受。' }}</text>
<text class="match">问卷依据{{ teaser.matchQuality === 'full' ? '充分' : '一般' }}这表示回答是否足够形成解释不是诊断准确率</text>
</view>
<SafetyNotice v-if="report?.safety.flagged" :message="report.safety.message || ''" />
<PortraitRadar v-if="claimed && report" :scores="report.scores" />
<view v-if="claimed && report" class="block">
<view v-for="(item, index) in report.evidence" :key="item.title" class="evidence">
<text class="etitle">{{ index + 1 }} {{ item.title }}</text>
<text class="eans">根据你的答案{{ item.answers.join('、') }}</text>
<text class="ebody">{{ item.explanation }}</text>
</view>
</view>
<view v-if="claimed && report" class="block">
<text class="etitle">这些问题是有关联的</text>
<text v-for="stepItem in report.chain.steps" :key="stepItem" class="chain">{{ stepItem }}</text>
<text class="ebody">{{ report.chain.takeaway }}</text>
</view>
<text class="disclaimer">非医疗诊断仅用于运动训练参考</text>
<view class="cta" @tap="continueFlow">{{ claimed ? '查看建议' : '登录查看完整报告' }}</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { onShow } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import PortraitRadar from '../../components/PortraitRadar.vue'
import SafetyNotice from '../../components/SafetyNotice.vue'
import { getSystemLayout } from '../../utils/system'
import { getErrorMessage } from '../../utils/auth'
import { useBodyPortraitStore } from '../../stores/body-portrait'
import { useUserStore } from '../../stores/user'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const portrait = useBodyPortraitStore()
const userStore = useUserStore()
const { teaser, report, claimed } = storeToRefs(portrait)
onShow(async () => {
if (!teaser.value && !portrait.session) {
await portrait.loadMine().catch(() => {})
}
if (teaser.value) await portrait.track('report_viewed')
})
async function continueFlow() {
try {
if (!claimed.value) {
if (!userStore.loggedIn) await userStore.login()
await portrait.claim()
}
uni.navigateTo({ url: '/pages/portrait/advice' })
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '请先登录后查看完整报告'), icon: 'none' })
}
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 40rpx 120rpx; }
.card, .block, .evidence { background: #fff; border-radius: 24rpx; padding: 32rpx; margin-bottom: 24rpx; }
.kicker { color: #8a7b6e; font-size: 22rpx; }
.title { display: block; margin-top: 12rpx; font-size: 40rpx; color: #4a4035; line-height: 1.4; }
.summary, .match, .ebody, .eans, .chain { display: block; margin-top: 16rpx; color: #7a6a5a; font-size: 26rpx; line-height: 1.7; }
.etitle { display: block; font-size: 30rpx; color: #4a4035; }
.disclaimer { display: block; color: #a09080; font-size: 22rpx; margin: 12rpx 0 24rpx; }
.cta { height: 92rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; }
</style>

View File

@@ -0,0 +1,68 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="到店评估" show-back />
<text class="title">线上画像只能看到一部分</text>
<text class="lead">问卷能够帮助我们了解你的生活习惯和主观感受但身体真正如何运动需要通过现场观察进一步判断</text>
<view class="card">
<text class="name">到店专业评估会进一步看</text>
<text class="row">静态体态脊柱骨盆</text>
<text class="row">呼吸活动度稳定性与基础动作</text>
<text class="row">明确个人训练重点</text>
</view>
<view v-if="report?.safety.flagged" class="card">
<text class="name">当前不适合直接预约体验训练</text>
<text class="lead">建议先确认运动条件你也可以联系工作室我们会帮你判断下一步</text>
</view>
<button v-else-if="!userStore.user?.phone" class="cta" open-type="getPhoneNumber" @getphonenumber="book">预约身体评估体验</button>
<view v-else class="cta" @tap="goTrial">预约身体评估体验</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { getErrorMessage, wxBindPhone } from '../../utils/auth'
import { useBodyPortraitStore } from '../../stores/body-portrait'
import { useUserStore } from '../../stores/user'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const portrait = useBodyPortraitStore()
const userStore = useUserStore()
const { report } = storeToRefs(portrait)
async function goTrial() {
try {
if (!userStore.loggedIn) await userStore.login()
await portrait.track('trial_clicked')
uni.navigateTo({ url: '/pages/card/detail?trial=1&fromPortrait=1' })
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '请先登录'), icon: 'none' })
}
}
async function book(e: { detail: { encryptedData: string; iv: string; errMsg: string } }) {
try {
if (!userStore.loggedIn) await userStore.login()
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
uni.showToast({ title: '预约体验需要授权手机号', icon: 'none' })
return
}
await wxBindPhone(e as Parameters<typeof wxBindPhone>[0])
await userStore.fetchProfile()
await goTrial()
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '请先授权手机号'), icon: 'none' })
}
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 40rpx 80rpx; }
.title { display: block; font-size: 40rpx; color: #4a4035; margin-top: 12rpx; }
.lead, .row { display: block; margin-top: 16rpx; color: #7a6a5a; font-size: 26rpx; line-height: 1.7; }
.card { margin-top: 28rpx; background: #fff; border-radius: 24rpx; padding: 28rpx; }
.name { display: block; font-size: 30rpx; color: #4a4035; }
.cta { margin-top: 48rpx; height: 92rpx; border-radius: 999rpx; background: #6b8276; color: #fff; font-size: 30rpx; display: flex; align-items: center; justify-content: center; }
</style>

View File

@@ -0,0 +1,143 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type {
BodyPortraitAnswers,
BodyPortraitSessionResponse,
BodyPortraitSource,
BodyPortraitStats,
BodyPortraitTeaser,
BodyPortraitReport,
} from '@mp-pilates/shared'
import { EMPTY_ANSWERS } from './portrait-empty'
import { get, post, put } from '../utils/request'
const VISIT_KEY = 'portrait_visit_token'
const ACCESS_KEY = 'portrait_access_token'
const SOURCE_KEY = 'portrait_source'
const CAMPAIGN_KEY = 'portrait_campaign'
const REFERRAL_KEY = 'portrait_referral'
export const useBodyPortraitStore = defineStore('body-portrait', () => {
const visitToken = ref(String(uni.getStorageSync(VISIT_KEY) || ''))
const accessToken = ref(String(uni.getStorageSync(ACCESS_KEY) || ''))
const session = ref<BodyPortraitSessionResponse | null>(null)
const stats = ref<BodyPortraitStats | null>(null)
const loading = ref(false)
const answers = computed(() => session.value?.answers || EMPTY_ANSWERS)
const teaser = computed<BodyPortraitTeaser | null>(() => session.value?.teaser || null)
const report = computed<BodyPortraitReport | null>(() => session.value?.report || null)
const claimed = computed(() => !!session.value?.claimed && !!session.value?.report)
const assessmentId = computed(() => session.value?.assessmentId || '')
function persist() {
if (visitToken.value) uni.setStorageSync(VISIT_KEY, visitToken.value)
if (accessToken.value) uni.setStorageSync(ACCESS_KEY, accessToken.value)
}
function captureAttribution(query: Record<string, string | undefined> = {}) {
const source = query.source || query.utm_source
const campaign = query.campaign || query.campaign_id
const referral = query.inviteCode || query.ref || query.shareCode
if (source) uni.setStorageSync(SOURCE_KEY, source)
if (campaign) uni.setStorageSync(CAMPAIGN_KEY, campaign)
if (referral) uni.setStorageSync(REFERRAL_KEY, referral)
}
async function fetchStats() {
stats.value = await get<BodyPortraitStats>('/body-portrait/stats')
return stats.value
}
async function ensureSession() {
if (session.value?.accessToken) return session.value
loading.value = true
try {
if (!visitToken.value) {
const visit = await post<{ visitId: string; visitToken: string; source: BodyPortraitSource }>('/body-portrait/visits', {
source: String(uni.getStorageSync(SOURCE_KEY) || 'organic'),
campaignId: String(uni.getStorageSync(CAMPAIGN_KEY) || '') || undefined,
referralCode: String(uni.getStorageSync(REFERRAL_KEY) || '') || undefined,
landingPath: '/pages/portrait/index',
})
visitToken.value = visit.visitToken
}
const started = await post<BodyPortraitSessionResponse>('/body-portrait/assessments', { visitToken: visitToken.value })
accessToken.value = started.accessToken
session.value = started
persist()
return started
} finally {
loading.value = false
}
}
async function saveAnswers(next: Partial<BodyPortraitAnswers>) {
if (!accessToken.value) await ensureSession()
const updated = await put<BodyPortraitSessionResponse>('/body-portrait/assessments', {
accessToken: accessToken.value,
answers: { ...answers.value, ...next },
})
accessToken.value = updated.accessToken || accessToken.value
session.value = { ...updated, accessToken: accessToken.value }
persist()
return updated
}
async function complete() {
const updated = await post<BodyPortraitSessionResponse>('/body-portrait/assessments/complete', { accessToken: accessToken.value })
session.value = { ...updated, accessToken: accessToken.value }
return updated
}
async function claim() {
const updated = await post<BodyPortraitSessionResponse>('/body-portrait/assessments/claim', { accessToken: accessToken.value })
session.value = { ...updated, accessToken: accessToken.value, claimed: true }
return updated
}
async function loadMine() {
const latest = await get<BodyPortraitSessionResponse | null>('/body-portrait/me')
if (latest) session.value = { ...latest, accessToken: accessToken.value }
return latest
}
async function track(name: string) {
try {
const scopeKey = assessmentId.value || visitToken.value || `anon_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
await post('/body-portrait/events', {
name,
accessToken: accessToken.value || undefined,
idempotencyKey: `${name}:${scopeKey}`.slice(0, 80),
})
} catch {
// tracking must never block the user flow
}
}
function keepAnonymousOnLogout() {
session.value = session.value?.claimed ? null : session.value
}
return {
visitToken,
accessToken,
session,
stats,
loading,
answers,
teaser,
report,
claimed,
assessmentId,
captureAttribution,
fetchStats,
ensureSession,
saveAnswers,
complete,
claim,
loadMine,
track,
keepAnonymousOnLogout,
}
})

View File

@@ -9,6 +9,7 @@ import type {
TeachingScheduleSlot,
} from '@mp-pilates/shared'
import { get, post, put } from '../utils/request'
import { useBodyPortraitStore } from './body-portrait'
/** Server paginated responses use `data` field, not `items` from the shared type */
interface ServerPaginatedResult<T> {
@@ -40,7 +41,12 @@ export const useBookingStore = defineStore('booking', () => {
}
async function createBooking(dto: CreateBookingDto) {
const result = await post<BookingWithDetails>('/booking', dto as unknown as Record<string, unknown>)
const portraitStore = useBodyPortraitStore()
const originAssessmentId = dto.originAssessmentId || (portraitStore.claimed ? portraitStore.assessmentId : undefined)
const result = await post<BookingWithDetails>('/booking', {
...dto,
...(originAssessmentId ? { originAssessmentId } : {}),
} as unknown as Record<string, unknown>)
return result
}

View File

@@ -0,0 +1,13 @@
import type { BodyPortraitAnswers } from '@mp-pilates/shared'
export const EMPTY_ANSWERS: BodyPortraitAnswers = {
concerns: [],
goal: null,
sittingHours: null,
exerciseFreq: null,
workPosture: null,
endOfDayFatigue: [],
afterSitting: [],
standingNotice: [],
safety: [],
}

View File

@@ -1,4 +1,5 @@
import { useInviteStore } from './invite'
import { useBodyPortraitStore } from './body-portrait'
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type {
@@ -161,6 +162,7 @@ export const useUserStore = defineStore('user', () => {
stats.value = null
memberships.value = []
resetSubscriptionMessageTemplateCache()
useBodyPortraitStore().keepAnonymousOnLogout()
}
function logout() {

View File

@@ -0,0 +1,196 @@
-- AlterTable
ALTER TABLE `bookings` ADD COLUMN `origin_assessment_id` VARCHAR(191) NULL;
ALTER TABLE `progress_photos` ADD COLUMN `angle` VARCHAR(16) NULL,
ADD COLUMN `session_id` VARCHAR(191) NULL;
-- CreateTable
CREATE TABLE `body_portrait_visits` (
`id` VARCHAR(191) NOT NULL,
`token_hash` VARCHAR(64) NOT NULL,
`source` VARCHAR(32) NOT NULL DEFAULT 'organic',
`campaign_id` VARCHAR(40) NULL,
`referral_code` VARCHAR(12) NULL,
`referrer_user_id` VARCHAR(191) NULL,
`landing_path` VARCHAR(120) NOT NULL DEFAULT '',
`user_id` VARCHAR(191) NULL,
`first_seen_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`last_seen_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `body_portrait_visits_token_hash_key`(`token_hash`),
INDEX `body_portrait_visits_source_created_at_idx`(`source`, `created_at`),
INDEX `body_portrait_visits_user_id_idx`(`user_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `body_portrait_assessments` (
`id` VARCHAR(191) NOT NULL,
`visit_id` VARCHAR(191) NOT NULL,
`user_id` VARCHAR(191) NULL,
`token_hash` VARCHAR(64) NOT NULL,
`questionnaire_version` VARCHAR(32) NOT NULL,
`rules_version` VARCHAR(32) NOT NULL,
`status` VARCHAR(16) NOT NULL DEFAULT 'DRAFT',
`answers` JSON NOT NULL,
`safety_flagged` BOOLEAN NOT NULL DEFAULT false,
`report` JSON NULL,
`completed_at` DATETIME(3) NULL,
`claimed_at` DATETIME(3) NULL,
`expires_at` DATETIME(3) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `body_portrait_assessments_token_hash_key`(`token_hash`),
INDEX `body_portrait_assessments_visit_id_idx`(`visit_id`),
INDEX `body_portrait_assessments_user_id_created_at_idx`(`user_id`, `created_at`),
INDEX `body_portrait_assessments_status_expires_at_idx`(`status`, `expires_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `growth_leads` (
`id` VARCHAR(191) NOT NULL,
`user_id` VARCHAR(191) NOT NULL,
`latest_assessment_id` VARCHAR(191) NULL,
`stage` VARCHAR(32) NOT NULL DEFAULT 'CLAIMED',
`source` VARCHAR(32) NULL,
`campaign_id` VARCHAR(40) NULL,
`last_active_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`coach_note` VARCHAR(500) NOT NULL DEFAULT '',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `growth_leads_user_id_key`(`user_id`),
INDEX `growth_leads_stage_last_active_at_idx`(`stage`, `last_active_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `growth_events` (
`id` VARCHAR(191) NOT NULL,
`name` VARCHAR(40) NOT NULL,
`idempotency_key` VARCHAR(120) NOT NULL,
`visit_id` VARCHAR(191) NULL,
`assessment_id` VARCHAR(191) NULL,
`user_id` VARCHAR(191) NULL,
`properties` JSON NOT NULL,
`occurred_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `growth_events_idempotency_key_key`(`idempotency_key`),
INDEX `growth_events_name_occurred_at_idx`(`name`, `occurred_at`),
INDEX `growth_events_user_id_occurred_at_idx`(`user_id`, `occurred_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `professional_assessment_sessions` (
`id` VARCHAR(191) NOT NULL,
`user_id` VARCHAR(191) NOT NULL,
`kind` VARCHAR(16) NOT NULL,
`protocol_version` VARCHAR(32) NOT NULL DEFAULT 'offline-v1',
`recorded_at` DATE NOT NULL,
`booking_id` VARCHAR(191) NULL,
`origin_assessment_id` VARCHAR(191) NULL,
`observations` JSON NOT NULL,
`subjective_tension` DOUBLE NULL,
`coach_summary` VARCHAR(1000) NOT NULL DEFAULT '',
`training_focus` VARCHAR(500) NOT NULL DEFAULT '',
`phase_goal` VARCHAR(500) NOT NULL DEFAULT '',
`photo_angle` VARCHAR(16) NULL,
`operator_id` VARCHAR(191) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `professional_assessment_sessions_user_id_recorded_at_idx`(`user_id`, `recorded_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `training_plans` (
`id` VARCHAR(191) NOT NULL,
`user_id` VARCHAR(191) NOT NULL,
`session_id` VARCHAR(191) NULL,
`title` VARCHAR(80) NOT NULL,
`status` VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
`weeks` INTEGER NOT NULL DEFAULT 12,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
INDEX `training_plans_user_id_created_at_idx`(`user_id`, `created_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `training_plan_phases` (
`id` VARCHAR(191) NOT NULL,
`plan_id` VARCHAR(191) NOT NULL,
`name` VARCHAR(40) NOT NULL,
`lesson_start` INTEGER NOT NULL,
`lesson_end` INTEGER NOT NULL,
`focus` JSON NOT NULL,
`summary` VARCHAR(300) NOT NULL DEFAULT '',
`sort_order` INTEGER NOT NULL DEFAULT 0,
INDEX `training_plan_phases_plan_id_idx`(`plan_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `reassessment_todos` (
`id` VARCHAR(191) NOT NULL,
`user_id` VARCHAR(191) NOT NULL,
`plan_id` VARCHAR(191) NOT NULL,
`lesson_checkpoint` INTEGER NOT NULL,
`due_at` DATETIME(3) NOT NULL,
`completed_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `reassessment_todos_plan_id_lesson_checkpoint_key`(`plan_id`, `lesson_checkpoint`),
INDEX `reassessment_todos_user_id_completed_at_idx`(`user_id`, `completed_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `growth_share_cards` (
`id` VARCHAR(191) NOT NULL,
`user_id` VARCHAR(191) NOT NULL,
`plan_id` VARCHAR(191) NULL,
`share_code` VARCHAR(12) NOT NULL,
`title` VARCHAR(80) NOT NULL,
`caption` VARCHAR(200) NOT NULL,
`completed_count` INTEGER NOT NULL,
`weeks` INTEGER NOT NULL DEFAULT 12,
`rows` JSON NOT NULL,
`include_photos` BOOLEAN NOT NULL DEFAULT false,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `growth_share_cards_share_code_key`(`share_code`),
INDEX `growth_share_cards_user_id_created_at_idx`(`user_id`, `created_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE INDEX `bookings_origin_assessment_id_idx` ON `bookings`(`origin_assessment_id`);
CREATE INDEX `progress_photos_session_id_idx` ON `progress_photos`(`session_id`);
-- AddForeignKey
ALTER TABLE `body_portrait_visits` ADD CONSTRAINT `body_portrait_visits_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `body_portrait_visits` ADD CONSTRAINT `body_portrait_visits_referrer_user_id_fkey` FOREIGN KEY (`referrer_user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `body_portrait_assessments` ADD CONSTRAINT `body_portrait_assessments_visit_id_fkey` FOREIGN KEY (`visit_id`) REFERENCES `body_portrait_visits`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `body_portrait_assessments` ADD CONSTRAINT `body_portrait_assessments_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `growth_leads` ADD CONSTRAINT `growth_leads_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `growth_leads` ADD CONSTRAINT `growth_leads_latest_assessment_id_fkey` FOREIGN KEY (`latest_assessment_id`) REFERENCES `body_portrait_assessments`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `growth_events` ADD CONSTRAINT `growth_events_visit_id_fkey` FOREIGN KEY (`visit_id`) REFERENCES `body_portrait_visits`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `growth_events` ADD CONSTRAINT `growth_events_assessment_id_fkey` FOREIGN KEY (`assessment_id`) REFERENCES `body_portrait_assessments`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `growth_events` ADD CONSTRAINT `growth_events_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `bookings` ADD CONSTRAINT `bookings_origin_assessment_id_fkey` FOREIGN KEY (`origin_assessment_id`) REFERENCES `body_portrait_assessments`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `professional_assessment_sessions` ADD CONSTRAINT `professional_assessment_sessions_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `professional_assessment_sessions` ADD CONSTRAINT `professional_assessment_sessions_booking_id_fkey` FOREIGN KEY (`booking_id`) REFERENCES `bookings`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `professional_assessment_sessions` ADD CONSTRAINT `professional_assessment_sessions_origin_assessment_id_fkey` FOREIGN KEY (`origin_assessment_id`) REFERENCES `body_portrait_assessments`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `progress_photos` ADD CONSTRAINT `progress_photos_session_id_fkey` FOREIGN KEY (`session_id`) REFERENCES `professional_assessment_sessions`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `training_plans` ADD CONSTRAINT `training_plans_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `training_plans` ADD CONSTRAINT `training_plans_session_id_fkey` FOREIGN KEY (`session_id`) REFERENCES `professional_assessment_sessions`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `training_plan_phases` ADD CONSTRAINT `training_plan_phases_plan_id_fkey` FOREIGN KEY (`plan_id`) REFERENCES `training_plans`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE `reassessment_todos` ADD CONSTRAINT `reassessment_todos_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `reassessment_todos` ADD CONSTRAINT `reassessment_todos_plan_id_fkey` FOREIGN KEY (`plan_id`) REFERENCES `training_plans`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE `growth_share_cards` ADD CONSTRAINT `growth_share_cards_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `growth_share_cards` ADD CONSTRAINT `growth_share_cards_plan_id_fkey` FOREIGN KEY (`plan_id`) REFERENCES `training_plans`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -84,6 +84,15 @@ model User {
sentInviteReferrals InviteReferral[] @relation("InviteReferralInviter")
receivedInviteReferral InviteReferral[] @relation("InviteReferralInvitee")
inviteRewardGrants InviteRewardGrant[] @relation("InviteRewardGrantInviter")
bodyPortraitVisits BodyPortraitVisit[] @relation("PortraitVisitUser")
referredPortraitVisits BodyPortraitVisit[] @relation("PortraitReferrer")
bodyPortraitAssessments BodyPortraitAssessment[]
growthLead GrowthLead?
growthEvents GrowthEvent[]
professionalAssessments ProfessionalAssessmentSession[]
trainingPlans TrainingPlan[]
reassessmentTodos ReassessmentTodo[]
growthShareCards GrowthShareCard[]
@@map("users")
}
@@ -207,13 +216,16 @@ model Booking {
confirmedAt DateTime? @map("confirmed_at")
completedAt DateTime? @map("completed_at")
operatorId String? @map("operator_id")
originAssessmentId String? @map("origin_assessment_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
timeSlot TimeSlot @relation(fields: [timeSlotId], references: [id])
membership Membership @relation(fields: [membershipId], references: [id])
originAssessment BodyPortraitAssessment? @relation(fields: [originAssessmentId], references: [id])
qualifiedInviteReferrals InviteReferral[]
professionalAssessments ProfessionalAssessmentSession[]
review BookingReview?
memberNotes MemberNote[]
@@ -228,6 +240,7 @@ model Booking {
@@unique([userId, timeSlotId])
@@index([userId])
@@index([status])
@@index([originAssessmentId])
@@map("bookings")
}
@@ -407,8 +420,203 @@ model ProgressPhoto {
consentedAt DateTime? @map("consented_at")
revokedAt DateTime? @map("revoked_at")
operatorId String @map("operator_id")
angle String? @db.VarChar(16)
sessionId String? @map("session_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
session ProfessionalAssessmentSession? @relation(fields: [sessionId], references: [id])
@@index([userId, recordedAt])
@@index([sessionId])
@@map("progress_photos")
}
model BodyPortraitVisit {
id String @id @default(uuid())
tokenHash String @unique @map("token_hash") @db.VarChar(64)
source String @default("organic") @db.VarChar(32)
campaignId String? @map("campaign_id") @db.VarChar(40)
referralCode String? @map("referral_code") @db.VarChar(12)
referrerUserId String? @map("referrer_user_id")
landingPath String @default("") @map("landing_path") @db.VarChar(120)
userId String? @map("user_id")
firstSeenAt DateTime @default(now()) @map("first_seen_at")
lastSeenAt DateTime @default(now()) @map("last_seen_at")
createdAt DateTime @default(now()) @map("created_at")
user User? @relation("PortraitVisitUser", fields: [userId], references: [id])
referrer User? @relation("PortraitReferrer", fields: [referrerUserId], references: [id])
assessments BodyPortraitAssessment[]
events GrowthEvent[]
@@index([source, createdAt])
@@index([userId])
@@map("body_portrait_visits")
}
model BodyPortraitAssessment {
id String @id @default(uuid())
visitId String @map("visit_id")
userId String? @map("user_id")
tokenHash String @unique @map("token_hash") @db.VarChar(64)
questionnaireVersion String @map("questionnaire_version") @db.VarChar(32)
rulesVersion String @map("rules_version") @db.VarChar(32)
status String @default("DRAFT") @db.VarChar(16)
answers Json @default("{}")
safetyFlagged Boolean @default(false) @map("safety_flagged")
report Json?
completedAt DateTime? @map("completed_at")
claimedAt DateTime? @map("claimed_at")
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
visit BodyPortraitVisit @relation(fields: [visitId], references: [id])
user User? @relation(fields: [userId], references: [id])
bookings Booking[]
growthLeads GrowthLead[]
events GrowthEvent[]
professionalAssessments ProfessionalAssessmentSession[]
@@index([visitId])
@@index([userId, createdAt])
@@index([status, expiresAt])
@@map("body_portrait_assessments")
}
model GrowthLead {
id String @id @default(uuid())
userId String @unique @map("user_id")
latestAssessmentId String? @map("latest_assessment_id")
stage String @default("CLAIMED") @db.VarChar(32)
source String? @db.VarChar(32)
campaignId String? @map("campaign_id") @db.VarChar(40)
lastActiveAt DateTime @default(now()) @map("last_active_at")
coachNote String @default("") @map("coach_note") @db.VarChar(500)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
latestAssessment BodyPortraitAssessment? @relation(fields: [latestAssessmentId], references: [id])
@@index([stage, lastActiveAt])
@@map("growth_leads")
}
model GrowthEvent {
id String @id @default(uuid())
name String @db.VarChar(40)
idempotencyKey String @unique @map("idempotency_key") @db.VarChar(120)
visitId String? @map("visit_id")
assessmentId String? @map("assessment_id")
userId String? @map("user_id")
properties Json @default("{}")
occurredAt DateTime @default(now()) @map("occurred_at")
visit BodyPortraitVisit? @relation(fields: [visitId], references: [id])
assessment BodyPortraitAssessment? @relation(fields: [assessmentId], references: [id])
user User? @relation(fields: [userId], references: [id])
@@index([name, occurredAt])
@@index([userId, occurredAt])
@@map("growth_events")
}
model ProfessionalAssessmentSession {
id String @id @default(uuid())
userId String @map("user_id")
kind String @db.VarChar(16)
protocolVersion String @default("offline-v1") @map("protocol_version") @db.VarChar(32)
recordedAt DateTime @db.Date @map("recorded_at")
bookingId String? @map("booking_id")
originAssessmentId String? @map("origin_assessment_id")
observations Json
subjectiveTension Float? @map("subjective_tension")
coachSummary String @default("") @map("coach_summary") @db.VarChar(1000)
trainingFocus String @default("") @map("training_focus") @db.VarChar(500)
phaseGoal String @default("") @map("phase_goal") @db.VarChar(500)
photoAngle String? @map("photo_angle") @db.VarChar(16)
operatorId String @map("operator_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
booking Booking? @relation(fields: [bookingId], references: [id])
originAssessment BodyPortraitAssessment? @relation(fields: [originAssessmentId], references: [id])
photos ProgressPhoto[]
trainingPlans TrainingPlan[]
@@index([userId, recordedAt])
@@map("professional_assessment_sessions")
}
model TrainingPlan {
id String @id @default(uuid())
userId String @map("user_id")
sessionId String? @map("session_id")
title String @db.VarChar(80)
status String @default("ACTIVE") @db.VarChar(16)
weeks Int @default(12)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
session ProfessionalAssessmentSession? @relation(fields: [sessionId], references: [id])
phases TrainingPlanPhase[]
todos ReassessmentTodo[]
shares GrowthShareCard[]
@@index([userId, createdAt])
@@map("training_plans")
}
model TrainingPlanPhase {
id String @id @default(uuid())
planId String @map("plan_id")
name String @db.VarChar(40)
lessonStart Int @map("lesson_start")
lessonEnd Int @map("lesson_end")
focus Json
summary String @default("") @db.VarChar(300)
sortOrder Int @default(0) @map("sort_order")
plan TrainingPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
@@index([planId])
@@map("training_plan_phases")
}
model ReassessmentTodo {
id String @id @default(uuid())
userId String @map("user_id")
planId String @map("plan_id")
lessonCheckpoint Int @map("lesson_checkpoint")
dueAt DateTime @map("due_at")
completedAt DateTime? @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
plan TrainingPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
@@unique([planId, lessonCheckpoint])
@@index([userId, completedAt])
@@map("reassessment_todos")
}
model GrowthShareCard {
id String @id @default(uuid())
userId String @map("user_id")
planId String? @map("plan_id")
shareCode String @unique @map("share_code") @db.VarChar(12)
title String @db.VarChar(80)
caption String @db.VarChar(200)
completedCount Int @map("completed_count")
weeks Int @default(12)
rows Json
includePhotos Boolean @default(false) @map("include_photos")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
plan TrainingPlan? @relation(fields: [planId], references: [id])
@@index([userId, createdAt])
@@map("growth_share_cards")
}

View File

@@ -12,6 +12,7 @@ import { SchedulerModule } from './scheduler/scheduler.module'
import { PaymentModule } from './payment/payment.module'
import { AdminModule } from './admin/admin.module'
import { InviteModule } from './invite/invite.module'
import { BodyPortraitModule } from './body-portrait/body-portrait.module'
@Module({
imports: [
@@ -30,6 +31,7 @@ import { InviteModule } from './invite/invite.module'
PaymentModule,
AdminModule,
InviteModule,
BodyPortraitModule,
],
controllers: [AppController],
})

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common'
import { Module, forwardRef } from '@nestjs/common'
import { PassportModule } from '@nestjs/passport'
import { JwtModule } from '@nestjs/jwt'
import { ConfigModule, ConfigService } from '@nestjs/config'
@@ -10,6 +10,7 @@ import { JwtStrategy } from './jwt.strategy'
import { JwtAuthGuard } from './jwt-auth.guard'
import { RolesGuard } from './roles.guard'
import { InviteModule } from '../invite/invite.module'
import { BodyPortraitModule } from '../body-portrait/body-portrait.module'
@Module({
imports: [
@@ -17,6 +18,7 @@ import { InviteModule } from '../invite/invite.module'
InviteModule,
ConfigModule,
MembershipModule,
forwardRef(() => BodyPortraitModule),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],

View File

@@ -1,4 +1,4 @@
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'
import { Inject, Injectable, Optional, UnauthorizedException, forwardRef } from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import { User } from '@prisma/client'
import {
@@ -13,6 +13,7 @@ import { ConfigService } from '@nestjs/config'
import { PrismaService } from '../prisma/prisma.service'
import { WechatService } from './wechat.service'
import { InviteService } from '../invite/invite.service'
import { BodyPortraitLifecycleService } from '../body-portrait/body-portrait-lifecycle.service'
export interface LoginResult {
token: string
@@ -67,6 +68,9 @@ export class AuthService {
private readonly inviteService: InviteService,
private readonly configService: ConfigService,
@Inject(RANDOM_FN_TOKEN) private readonly randomFn: () => number = Math.random,
@Optional()
@Inject(forwardRef(() => BodyPortraitLifecycleService))
private readonly portraitLifecycle?: BodyPortraitLifecycleService,
) {}
private buildSubscriptionTemplateConfig(): SubscriptionMessageTemplateConfig {
@@ -179,9 +183,11 @@ export class AuthService {
iv,
)
return this.prisma.user.update({
const updated = await this.prisma.user.update({
where: { id: userId },
data: { phone: phoneInfo.phoneNumber },
})
await this.portraitLifecycle?.onPhoneBound(userId)
return updated
}
}

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

@@ -0,0 +1,146 @@
import { ForbiddenException } from '@nestjs/common'
import { BodyPortraitAssessmentStatus, GrowthEventName, GrowthLeadStage, PortraitGoal, SafetyFlag, SittingHours } from '@mp-pilates/shared'
import { BodyPortraitService } from '../body-portrait.service'
import { BodyPortraitLifecycleService } from '../body-portrait-lifecycle.service'
import { PrismaService } from '../../prisma/prisma.service'
import { hashToken } from '../body-portrait.token'
describe('BodyPortraitService', () => {
const prisma: any = {
bodyPortraitVisit: { create: jest.fn(), findUnique: jest.fn(), update: jest.fn() },
bodyPortraitAssessment: {
create: jest.fn(),
findFirst: jest.fn(),
findUnique: jest.fn(),
findUniqueOrThrow: jest.fn(),
update: jest.fn(),
updateMany: jest.fn(),
count: jest.fn(),
},
growthEvent: { create: jest.fn() },
growthLead: { findUnique: jest.fn(), create: jest.fn(), update: jest.fn() },
user: { findFirst: jest.fn(), findUnique: jest.fn() },
$transaction: jest.fn(),
}
const lifecycle = new BodyPortraitLifecycleService(prisma as unknown as PrismaService)
let service: BodyPortraitService
beforeEach(() => {
jest.resetAllMocks()
prisma.growthEvent.create.mockResolvedValue({})
prisma.growthLead.findUnique.mockResolvedValue(null)
service = new BodyPortraitService(prisma as unknown as PrismaService, lifecycle)
})
it('creates hashed visit tokens and does not store plaintext', async () => {
prisma.user.findFirst.mockResolvedValue({ id: 'ref' })
prisma.bodyPortraitVisit.create.mockImplementation(async ({ data }: { data: { tokenHash: string } }) => ({ id: 'visit', ...data }))
const result = await service.createVisit('1.1.1.1', { source: 'xhs', referralCode: 'AB12CD' })
expect(result.visitToken).toHaveLength(64)
expect(prisma.bodyPortraitVisit.create.mock.calls[0][0].data.tokenHash).toBe(hashToken(result.visitToken))
expect(prisma.bodyPortraitVisit.create.mock.calls[0][0].data.tokenHash).not.toBe(result.visitToken)
expect(prisma.bodyPortraitVisit.create.mock.calls[0][0].data.referrerUserId).toBe('ref')
})
it('refuses to claim another user assessment', async () => {
prisma.bodyPortraitAssessment.findUnique.mockResolvedValue({
id: 'a1',
userId: 'other',
status: BodyPortraitAssessmentStatus.COMPLETED,
report: { scores: {} },
visitId: 'v1',
answers: { goal: PortraitGoal.POSTURE, sittingHours: SittingHours.GT8, safety: [SafetyFlag.NONE] },
})
await expect(service.claim('me', 'token-token-token-token-token-token-token')).rejects.toThrow(ForbiddenException)
})
it('completes with server-side scores and ignores client-sent scores', async () => {
prisma.bodyPortraitAssessment.findUnique.mockResolvedValue({
id: 'a1',
userId: null,
visitId: 'v1',
status: BodyPortraitAssessmentStatus.DRAFT,
expiresAt: new Date(Date.now() + 10000),
completedAt: null,
answers: {
concerns: ['neck'],
goal: 'neckRelief',
sittingHours: 'gt8',
exerciseFreq: 'none',
workPosture: 'computer',
endOfDayFatigue: ['neck'],
afterSitting: ['neckTight'],
standingNotice: ['headForward'],
safety: ['none'],
},
})
prisma.bodyPortraitAssessment.update.mockImplementation(async ({ data }: { data: Record<string, unknown> }) => ({
id: 'a1',
userId: null,
status: data.status,
answers: data.answers,
report: data.report,
}))
const session = await service.complete('token-token-token-token-token-token-token')
expect(session.teaser?.primaryType).toBeTruthy()
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', () => {
const prisma: any = {
growthEvent: { create: jest.fn() },
growthLead: { findUnique: jest.fn(), create: jest.fn(), update: jest.fn() },
bodyPortraitAssessment: { findUnique: jest.fn(), findFirst: jest.fn() },
}
it('does not move stages backwards', async () => {
const service = new BodyPortraitLifecycleService(prisma as unknown as PrismaService)
prisma.growthLead.findUnique.mockResolvedValue({
userId: 'u1',
stage: GrowthLeadStage.PLAN_PURCHASED,
latestAssessmentId: 'a1',
})
await service.advanceLead('u1', GrowthLeadStage.CLAIMED, 'a1')
expect(prisma.growthLead.update.mock.calls[0][0].data.stage).toBe(GrowthLeadStage.PLAN_PURCHASED)
})
it('uses unique event keys for paid orders', async () => {
const service = new BodyPortraitLifecycleService(prisma as unknown as PrismaService)
prisma.growthEvent.create.mockResolvedValue({})
prisma.bodyPortraitAssessment.findFirst.mockResolvedValue({ id: 'a1', visitId: 'v1' })
prisma.growthLead.findUnique.mockResolvedValue({ userId: 'u1', stage: GrowthLeadStage.CLAIMED, latestAssessmentId: 'a1' })
await service.onOrderPaid('u1', 'TRIAL', '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)
})
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

@@ -0,0 +1,85 @@
import {
AfterSitting,
BodyRegion,
ExerciseFreq,
PortraitGoal,
PortraitType,
SafetyFlag,
SittingHours,
StandingNotice,
WorkPosture,
} from '@mp-pilates/shared'
import { isSafetyFlagged, scorePortrait } from '../scoring/score-v1'
const base = {
concerns: [BodyRegion.SHOULDER],
goal: PortraitGoal.POSTURE,
sittingHours: SittingHours.H6_TO_8,
exerciseFreq: ExerciseFreq.W1_TO_2,
workPosture: WorkPosture.COMPUTER,
endOfDayFatigue: [BodyRegion.NECK],
afterSitting: [AfterSitting.NECK_TIGHT],
standingNotice: [StandingNotice.HEAD_FORWARD],
safety: [SafetyFlag.NONE],
}
describe('scorePortrait v1', () => {
const fixtures: Array<{ name: string; answers: typeof base; type: PortraitType; safety?: boolean; minCervical?: number }> = [
{ name: '久坐肩颈紧张', answers: { ...base, sittingHours: SittingHours.GT8, standingNotice: [StandingNotice.HEAD_FORWARD, StandingNotice.ROUNDED_SHOULDERS] }, type: PortraitType.SEDENTARY_NECK_TENSION, minCervical: 70 },
{ name: '久坐核心弱化', answers: { ...base, concerns: [BodyRegion.LOW_BACK], goal: PortraitGoal.CORE, afterSitting: [AfterSitting.LOW_BACK_ACHE], standingNotice: [StandingNotice.BELLY_FORWARD], endOfDayFatigue: [BodyRegion.LOW_BACK] }, type: PortraitType.SEDENTARY_CORE_WEAK },
{ name: '肩颈代偿', answers: { ...base, sittingHours: SittingHours.LT4, workPosture: WorkPosture.WALKING, exerciseFreq: ExerciseFreq.W3_TO_4 }, type: PortraitType.NECK_COMPENSATION },
{ name: '髋部紧张', answers: { ...base, concerns: [BodyRegion.HIP, BodyRegion.PELVIS], goal: PortraitGoal.HIP_LEG, afterSitting: [AfterSitting.HIP_TIGHT], standingNotice: [StandingNotice.UNNOTICED], endOfDayFatigue: [BodyRegion.HIP], workPosture: WorkPosture.WALKING }, type: PortraitType.HIP_TIGHT },
{ name: '下肢稳定不足', answers: { ...base, concerns: [BodyRegion.KNEE, BodyRegion.LEG], goal: PortraitGoal.STABILITY, afterSitting: [AfterSitting.NONE], standingNotice: [StandingNotice.LOCKED_KNEES], endOfDayFatigue: [BodyRegion.KNEE], sittingHours: SittingHours.LT4, workPosture: WorkPosture.STANDING }, type: PortraitType.LOWER_LIMB_UNSTABLE },
{ name: '上下交叉', answers: { ...base, concerns: [BodyRegion.NECK, BodyRegion.SHOULDER, BodyRegion.LOW_BACK, BodyRegion.HIP], goal: PortraitGoal.POSTURE, sittingHours: SittingHours.GT8, exerciseFreq: ExerciseFreq.NONE, afterSitting: [AfterSitting.NECK_TIGHT, AfterSitting.LOW_BACK_ACHE, AfterSitting.HIP_TIGHT], standingNotice: [StandingNotice.ROUNDED_SHOULDERS, StandingNotice.BELLY_FORWARD], endOfDayFatigue: [BodyRegion.NECK, BodyRegion.LOW_BACK] }, type: PortraitType.UPPER_LOWER_CROSS },
{ name: '整体关注较低', answers: { ...base, concerns: [], goal: PortraitGoal.OTHER, sittingHours: SittingHours.LT4, exerciseFreq: ExerciseFreq.W5_PLUS, workPosture: WorkPosture.WALKING, endOfDayFatigue: [], afterSitting: [AfterSitting.NONE], standingNotice: [StandingNotice.UNNOTICED] }, type: PortraitType.LOW_OVERALL_CONCERN },
{ name: '全身疲劳', answers: { ...base, concerns: [BodyRegion.NECK, BodyRegion.LOW_BACK, BodyRegion.HIP, BodyRegion.KNEE], goal: PortraitGoal.OTHER, sittingHours: SittingHours.H4_TO_6, exerciseFreq: ExerciseFreq.NONE, workPosture: WorkPosture.HOLDING_CHILD, afterSitting: [AfterSitting.NECK_TIGHT, AfterSitting.BACK_STIFF, AfterSitting.HIP_TIGHT], standingNotice: [StandingNotice.UNEVEN_SHOULDERS], endOfDayFatigue: [BodyRegion.SHOULDER, BodyRegion.LEG] }, type: PortraitType.NECK_COMPENSATION },
{ name: '低头手机', answers: { ...base, workPosture: WorkPosture.PHONE, sittingHours: SittingHours.GT8 }, type: PortraitType.SEDENTARY_NECK_TENSION },
{ name: '抱娃', answers: { ...base, concerns: [BodyRegion.LOW_BACK, BodyRegion.SHOULDER], workPosture: WorkPosture.HOLDING_CHILD, goal: PortraitGoal.BACK_COMFORT, afterSitting: [AfterSitting.LOW_BACK_ACHE], standingNotice: [StandingNotice.BELLY_FORWARD] }, type: PortraitType.SEDENTARY_CORE_WEAK },
{ name: '塑形目标', answers: { ...base, goal: PortraitGoal.SHAPING, concerns: [BodyRegion.LOW_BACK], afterSitting: [AfterSitting.LOW_BACK_ACHE], standingNotice: [StandingNotice.BELLY_FORWARD] }, type: PortraitType.SEDENTARY_CORE_WEAK },
{ name: '腰背久坐舒服', answers: { ...base, goal: PortraitGoal.BACK_COMFORT, concerns: [BodyRegion.UPPER_BACK], afterSitting: [AfterSitting.BACK_STIFF], standingNotice: [StandingNotice.ROUNDED_SHOULDERS] }, type: PortraitType.SEDENTARY_CORE_WEAK },
{ name: '运动很少的肩颈', answers: { ...base, exerciseFreq: ExerciseFreq.NONE, sittingHours: SittingHours.H6_TO_8 }, type: PortraitType.SEDENTARY_NECK_TENSION },
{ name: '膝腿久站', answers: { ...base, concerns: [BodyRegion.KNEE], goal: PortraitGoal.STABILITY, workPosture: WorkPosture.STANDING, sittingHours: SittingHours.LT4, afterSitting: [AfterSitting.NONE], standingNotice: [StandingNotice.LOCKED_KNEES], endOfDayFatigue: [BodyRegion.LEG] }, type: PortraitType.LOWER_LIMB_UNSTABLE },
{ name: '骨盆困扰', answers: { ...base, concerns: [BodyRegion.PELVIS], goal: PortraitGoal.HIP_LEG, afterSitting: [AfterSitting.HIP_TIGHT], standingNotice: [StandingNotice.BELLY_FORWARD], endOfDayFatigue: [BodyRegion.PELVIS], workPosture: WorkPosture.WALKING, sittingHours: SittingHours.H4_TO_6 }, type: PortraitType.HIP_TIGHT },
{ name: '上背僵硬', answers: { ...base, concerns: [BodyRegion.UPPER_BACK], afterSitting: [AfterSitting.BACK_STIFF], standingNotice: [StandingNotice.ROUNDED_SHOULDERS] }, type: PortraitType.NECK_COMPENSATION },
{ name: '肩颈目标明确', answers: { ...base, goal: PortraitGoal.NECK_RELIEF, concerns: [BodyRegion.NECK, BodyRegion.SHOULDER] }, type: PortraitType.SEDENTARY_NECK_TENSION },
{ name: '安全分流不计分仍出类型', answers: { ...base, safety: [SafetyFlag.PERSISTENT_PAIN] }, type: PortraitType.SEDENTARY_NECK_TENSION, safety: true },
{ name: '医生限制运动', answers: { ...base, safety: [SafetyFlag.DOCTOR_LIMIT, SafetyFlag.RECENT_INJURY] }, type: PortraitType.SEDENTARY_NECK_TENSION, safety: true },
{ name: '麻木分流', answers: { ...base, safety: [SafetyFlag.NUMBNESS] }, type: PortraitType.SEDENTARY_NECK_TENSION, safety: true },
{ name: '医疗恢复期', answers: { ...base, safety: [SafetyFlag.MEDICAL_RECOVERY] }, type: PortraitType.SEDENTARY_NECK_TENSION, safety: true },
{ name: '高频运动降低关注', answers: { ...base, exerciseFreq: ExerciseFreq.W5_PLUS, sittingHours: SittingHours.H4_TO_6, afterSitting: [AfterSitting.NONE], standingNotice: [StandingNotice.UNNOTICED] }, type: PortraitType.LOW_OVERALL_CONCERN },
]
it.each(fixtures)('$name', ({ answers, type, safety, minCervical }) => {
const report = scorePortrait(answers)
expect(report.primaryType).toBe(type)
expect(report.safety.flagged).toBe(!!safety)
expect(isSafetyFlagged(answers)).toBe(!!safety)
expect(report.advice === null).toBe(!!safety)
if (minCervical) expect(report.scores.cervicalShoulder).toBeGreaterThanOrEqual(minCervical)
expect(report.scores.cervicalShoulder).toBeLessThanOrEqual(100)
expect(report.headline.length).toBeGreaterThan(0)
expect(report.summary.includes('诊断')).toBe(false)
expect(report.summary.includes('准确率')).toBe(false)
})
it('clamps scores and ignores unknown answers', () => {
const report = scorePortrait({
...base,
concerns: ['alien' as never, BodyRegion.NECK, BodyRegion.NECK],
sittingHours: SittingHours.GT8,
exerciseFreq: ExerciseFreq.NONE,
afterSitting: [AfterSitting.NECK_TIGHT, AfterSitting.BACK_STIFF, AfterSitting.LOW_BACK_ACHE, AfterSitting.HIP_TIGHT],
standingNotice: [StandingNotice.HEAD_FORWARD, StandingNotice.ROUNDED_SHOULDERS, StandingNotice.BELLY_FORWARD, StandingNotice.LOCKED_KNEES],
})
for (const value of Object.values(report.scores)) {
expect(value).toBeGreaterThanOrEqual(0)
expect(value).toBeLessThanOrEqual(100)
}
expect(report.evidence.length).toBeLessThanOrEqual(3)
})
it('does not treat none safety as flagged', () => {
expect(scorePortrait(base).safety.flagged).toBe(false)
expect(scorePortrait({ ...base, safety: [] }).safety.flagged).toBe(false)
})
})

View File

@@ -0,0 +1,80 @@
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'
import { UserRole } from '@mp-pilates/shared'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { RolesGuard } from '../auth/roles.guard'
import { Roles } from '../auth/roles.decorator'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { BodyPortraitAdminService } from './body-portrait-admin.service'
import { BodyPortraitOfflineService } from './body-portrait-offline.service'
import { CreateProfessionalAssessmentDto, CreateTrainingPlanDto, UpdateLeadNoteDto } from './dto/body-portrait.dto'
@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
export class BodyPortraitAdminController {
constructor(
private readonly admin: BodyPortraitAdminService,
private readonly offline: BodyPortraitOfflineService,
) {}
@Get('growth/today')
today() {
return this.admin.dashboard()
}
@Get('growth/leads')
leads(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('search') search?: string,
@Query('stage') stage?: string,
@Query('source') source?: string,
) {
return this.admin.listLeads({
page: page ? Number(page) : 1,
limit: limit ? Number(limit) : 20,
search,
stage,
source,
})
}
@Get('growth/leads/:id')
detail(@Param('id') id: string) {
return this.admin.leadDetail(id)
}
@Put('growth/leads/:id/note')
note(@Param('id') id: string, @Body() dto: UpdateLeadNoteDto) {
return this.admin.updateNote(id, dto.coachNote)
}
@Get('members/:userId/professional-assessments')
sessions(@Param('userId') userId: string) {
return this.offline.listSessions(userId)
}
@Post('members/:userId/professional-assessments')
createSession(
@Param('userId') userId: string,
@CurrentUser('sub') operatorId: string,
@Body() dto: CreateProfessionalAssessmentDto,
) {
return this.offline.createSession(userId, operatorId, dto)
}
@Get('members/:userId/training-plans')
plans(@Param('userId') userId: string) {
return this.offline.listPlans(userId)
}
@Post('members/:userId/training-plans')
createPlan(@Param('userId') userId: string, @Body() dto: CreateTrainingPlanDto) {
return this.offline.createPlan(userId, dto)
}
@Get('members/:userId/reassessment-todos')
todos(@Param('userId') userId: string) {
return this.offline.todos(userId)
}
}

View File

@@ -0,0 +1,271 @@
import { Injectable, NotFoundException } from '@nestjs/common'
import {
BODY_DIMENSION_LABELS,
BodyDimension,
BodyPortraitAnswers,
BodyPortraitReport,
BodyPortraitSource,
GrowthEventName,
GrowthFunnelRow,
GrowthLeadDetail,
GrowthLeadStage,
GrowthLeadSummary,
GrowthTodayDashboard,
GrowthTodayTask,
} from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { BodyPortraitLifecycleService } from './body-portrait-lifecycle.service'
import { normalizeAnswers } from './scoring/score-v1'
const TERMINAL = new Set<string>([
GrowthLeadStage.TRIAL_BOOKED,
GrowthLeadStage.TRIAL_ATTENDED,
GrowthLeadStage.PLAN_PURCHASED,
GrowthLeadStage.TRAINING,
GrowthLeadStage.RENEWAL_DUE,
GrowthLeadStage.RENEWED,
])
@Injectable()
export class BodyPortraitAdminService {
constructor(
private readonly prisma: PrismaService,
private readonly lifecycle: BodyPortraitLifecycleService,
) {}
async dashboard(): Promise<GrowthTodayDashboard> {
const [leads, todos, funnel] = await Promise.all([
this.prisma.growthLead.findMany({
include: {
user: { select: { nickname: true, phone: true } },
latestAssessment: { include: { visit: true } },
},
orderBy: { lastActiveAt: 'desc' },
take: 80,
}),
this.prisma.reassessmentTodo.findMany({
where: { completedAt: null, dueAt: { lte: new Date() } },
include: { user: { select: { nickname: true } } },
take: 20,
}),
this.funnel(),
])
const tasks: GrowthTodayTask[] = []
const twoDaysAgo = Date.now() - 2 * 24 * 60 * 60 * 1000
for (const lead of leads) {
const report = (lead.latestAssessment?.report || null) as BodyPortraitReport | null
const answers = normalizeAnswers(lead.latestAssessment?.answers as Partial<BodyPortraitAnswers>)
const draft = this.lifecycle.followUpDraft({
nickname: lead.user.nickname,
report,
sittingHours: answers.sittingHours,
workPosture: answers.workPosture,
})
if (!TERMINAL.has(lead.stage) && lead.latestAssessment?.completedAt && ['CLAIMED', 'PHONE_BOUND', 'COMPLETED'].includes(lead.stage)) {
const top = report ? this.topDimension(report) : null
tasks.push({
kind: 'completed_unbooked',
leadId: lead.id,
userId: lead.userId,
nickname: lead.user.nickname || '未命名',
title: `${lead.user.nickname || '学员'}完成身体测试但未预约`,
detail: top ? `${BODY_DIMENSION_LABELS[top[0]]}关注度 ${top[1]}` : '已完成线上画像',
happenedAt: lead.lastActiveAt.toISOString(),
followUpDraft: draft,
})
}
if (lead.stage === GrowthLeadStage.TRIAL_ATTENDED && lead.lastActiveAt.getTime() <= twoDaysAgo) {
tasks.push({
kind: 'trial_unpurchased',
leadId: lead.id,
userId: lead.userId,
nickname: lead.user.nickname || '未命名',
title: `${lead.user.nickname || '学员'}完成体验课后未购买`,
detail: '建议回访,确认是否需要 12 周改善计划',
happenedAt: lead.lastActiveAt.toISOString(),
followUpDraft: this.lifecycle.followUpAfterTrialDraft({
nickname: lead.user.nickname,
report,
}),
})
}
}
for (const todo of todos) {
tasks.push({
kind: 'reassessment_due',
leadId: todo.planId,
userId: todo.userId,
nickname: todo.user.nickname || '未命名',
title: `${todo.user.nickname || '学员'}到达第 ${todo.lessonCheckpoint} 节复测节点`,
detail: '建议安排阶段复测',
happenedAt: todo.dueAt.toISOString(),
followUpDraft: `训练已经走到第 ${todo.lessonCheckpoint} 节附近了,可以安排一次阶段复测,看看身体状态有没有变化。`,
})
}
return { tasks: tasks.slice(0, 12), funnel }
}
async listLeads(query: { page?: number; limit?: number; search?: string; stage?: string; source?: string }) {
const page = Math.max(1, Number(query.page) || 1)
const limit = Math.min(50, Math.max(1, Number(query.limit) || 20))
const where: Record<string, unknown> = {}
if (query.stage) where.stage = query.stage
if (query.source) where.source = query.source
if (query.search) {
where.user = {
OR: [
{ nickname: { contains: query.search } },
{ phone: { contains: query.search } },
],
}
}
const [items, total] = await Promise.all([
this.prisma.growthLead.findMany({
where,
include: { user: { select: { nickname: true, phone: true } }, latestAssessment: true },
orderBy: { lastActiveAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.growthLead.count({ where }),
])
return {
items: items.map((lead) => this.toSummary(lead)),
total,
page,
limit,
}
}
async leadDetail(id: string): Promise<GrowthLeadDetail> {
const lead = await this.prisma.growthLead.findUnique({
where: { id },
include: {
user: { select: { nickname: true, phone: true } },
latestAssessment: { include: { visit: true } },
},
})
if (!lead) throw new NotFoundException('线索不存在')
const events = await this.prisma.growthEvent.findMany({
where: { userId: lead.userId },
orderBy: { occurredAt: 'asc' },
take: 50,
})
const report = (lead.latestAssessment?.report || null) as BodyPortraitReport | null
const answers = lead.latestAssessment
? normalizeAnswers(lead.latestAssessment.answers as Partial<BodyPortraitAnswers>)
: null
const summary = this.toSummary(lead)
return {
...summary,
goalLabel: report?.goalLabel || null,
scores: report?.scores || null,
levels: report?.levels || null,
evidence: report?.evidence || [],
answers,
report,
events: events.map((event) => ({
id: event.id,
name: event.name as GrowthEventName,
occurredAt: event.occurredAt.toISOString(),
})),
coachNote: lead.coachNote,
firstAssessmentHints: report?.firstAssessmentHints || [],
}
}
async updateNote(id: string, coachNote: string) {
const lead = await this.prisma.growthLead.findUnique({ where: { id } })
if (!lead) throw new NotFoundException('线索不存在')
await this.prisma.growthLead.update({ where: { id }, data: { coachNote: coachNote.slice(0, 500) } })
return this.leadDetail(id)
}
private async funnel(): Promise<GrowthFunnelRow[]> {
const visits = await this.prisma.bodyPortraitVisit.findMany({
select: { id: true, source: true },
})
const byVisit = new Map(visits.map((visit) => [visit.id, visit.source as BodyPortraitSource]))
const events = await this.prisma.growthEvent.findMany({
select: { name: true, visitId: true, userId: true },
})
const rows = new Map<BodyPortraitSource, { source: BodyPortraitSource; started: number; completed: number; claimed: number; booked: number; attended: number; purchased: number }>()
const ensure = (source: BodyPortraitSource) => {
const current = rows.get(source) || {
source, started: 0, completed: 0, claimed: 0, booked: 0, attended: 0, purchased: 0,
}
rows.set(source, current)
return current
}
for (const source of Object.values(BodyPortraitSource)) ensure(source)
for (const visit of visits) ensure(visit.source as BodyPortraitSource).started += 0
const counted = new Set<string>()
for (const event of events) {
const source = (event.visitId && byVisit.get(event.visitId)) || BodyPortraitSource.ORGANIC
const row = ensure(source)
const key = `${source}:${event.name}:${event.userId || event.visitId}`
if (counted.has(key)) continue
counted.add(key)
if (event.name === GrowthEventName.ASSESSMENT_STARTED) row.started += 1
if (event.name === GrowthEventName.ASSESSMENT_COMPLETED) row.completed += 1
if (event.name === GrowthEventName.ASSESSMENT_CLAIMED) row.claimed += 1
if (event.name === GrowthEventName.TRIAL_BOOKED) row.booked += 1
if (event.name === GrowthEventName.TRIAL_ATTENDED) row.attended += 1
if (event.name === GrowthEventName.PLAN_PURCHASED) row.purchased += 1
}
return [...rows.values()]
}
private toSummary(lead: {
id: string
userId: string
stage: string
source: string | null
campaignId: string | null
lastActiveAt: Date
user: { nickname: string; phone: string | null }
latestAssessment: { completedAt: Date | null; safetyFlagged: boolean; report: unknown } | null
}): GrowthLeadSummary {
const report = (lead.latestAssessment?.report || null) as BodyPortraitReport | null
const top = report ? this.topDimension(report) : null
const answers = lead.latestAssessment
? normalizeAnswers((lead.latestAssessment as { answers?: unknown }).answers as Partial<BodyPortraitAnswers>)
: EMPTY_SAFE
return {
id: lead.id,
userId: lead.userId,
nickname: lead.user.nickname || '未命名',
phone: lead.user.phone,
stage: lead.stage as GrowthLeadStage,
source: (lead.source as BodyPortraitSource | null) || null,
campaignId: lead.campaignId,
primaryType: report?.primaryType || null,
topDimension: top?.[0] || null,
safetyFlagged: lead.latestAssessment?.safetyFlagged || false,
lastActiveAt: lead.lastActiveAt.toISOString(),
completedAt: lead.latestAssessment?.completedAt?.toISOString() || null,
followUpDraft: this.lifecycle.followUpDraft({
nickname: lead.user.nickname,
report,
sittingHours: answers.sittingHours,
workPosture: answers.workPosture,
}),
}
}
private topDimension(report: BodyPortraitReport): [BodyDimension, number] {
return (Object.entries(report.scores) as [BodyDimension, number][]).sort((a, b) => b[1] - a[1])[0]
}
}
const EMPTY_SAFE: BodyPortraitAnswers = {
concerns: [],
goal: null,
sittingHours: null,
exerciseFreq: null,
workPosture: null,
endOfDayFatigue: [],
afterSitting: [],
standingNotice: [],
safety: [],
}

View File

@@ -0,0 +1,218 @@
import { Injectable, Logger } from '@nestjs/common'
import {
BODY_DIMENSION_LABELS,
BodyDimension,
BodyPortraitReport,
BodyPortraitSource,
CardTypeCategory,
GrowthEventName,
GrowthLeadStage,
PORTRAIT_TYPE_LABELS,
SITTING_HOURS_LABELS,
SittingHours,
WORK_POSTURE_LABELS,
WorkPosture,
} from '@mp-pilates/shared'
import { Prisma } from '@prisma/client'
import { PrismaService } from '../prisma/prisma.service'
const STAGE_RANK: Record<GrowthLeadStage, number> = {
[GrowthLeadStage.VISIT]: 0,
[GrowthLeadStage.STARTED]: 1,
[GrowthLeadStage.COMPLETED]: 2,
[GrowthLeadStage.CLAIMED]: 3,
[GrowthLeadStage.PHONE_BOUND]: 4,
[GrowthLeadStage.TRIAL_PURCHASED]: 5,
[GrowthLeadStage.TRIAL_BOOKED]: 6,
[GrowthLeadStage.TRIAL_ATTENDED]: 7,
[GrowthLeadStage.PLAN_PURCHASED]: 8,
[GrowthLeadStage.TRAINING]: 9,
[GrowthLeadStage.RENEWAL_DUE]: 10,
[GrowthLeadStage.RENEWED]: 11,
}
const SAFE_EVENT_PROPS = new Set(['source', 'campaignId', 'cardType', 'bookingId', 'orderId', 'checkpoint'])
@Injectable()
export class BodyPortraitLifecycleService {
private readonly logger = new Logger(BodyPortraitLifecycleService.name)
constructor(private readonly prisma: PrismaService) {}
async recordEvent(input: {
name: GrowthEventName
idempotencyKey: string
visitId?: string | null
assessmentId?: string | null
userId?: string | null
properties?: Record<string, unknown>
stage?: GrowthLeadStage
}) {
const properties = this.sanitize(input.properties)
try {
await this.prisma.growthEvent.create({
data: {
name: input.name,
idempotencyKey: input.idempotencyKey.slice(0, 120),
visitId: input.visitId || undefined,
assessmentId: input.assessmentId || undefined,
userId: input.userId || undefined,
properties: properties as Prisma.InputJsonValue,
},
})
} catch (error) {
if (this.isUnique(error)) return
this.logger.warn(`growth event failed: ${input.name}`)
return
}
if (input.userId && input.stage) {
await this.advanceLead(input.userId, input.stage, input.assessmentId)
}
}
async advanceLead(userId: string, stage: GrowthLeadStage, assessmentId?: string | null) {
const existing = await this.prisma.growthLead.findUnique({ where: { userId } })
if (!existing) {
if (STAGE_RANK[stage] < STAGE_RANK[GrowthLeadStage.CLAIMED]) return
const assessment = assessmentId
? await this.prisma.bodyPortraitAssessment.findUnique({ where: { id: assessmentId }, include: { visit: true } })
: await this.prisma.bodyPortraitAssessment.findFirst({ where: { userId }, orderBy: { createdAt: 'desc' }, include: { visit: true } })
await this.prisma.growthLead.create({
data: {
userId,
latestAssessmentId: assessment?.id,
stage,
source: assessment?.visit.source,
campaignId: assessment?.visit.campaignId,
lastActiveAt: new Date(),
},
})
return
}
const nextStage = STAGE_RANK[stage] > STAGE_RANK[existing.stage as GrowthLeadStage] ? stage : existing.stage
await this.prisma.growthLead.update({
where: { userId },
data: {
stage: nextStage,
lastActiveAt: new Date(),
latestAssessmentId: assessmentId || existing.latestAssessmentId,
},
})
}
async onPhoneBound(userId: string) {
await this.recordEvent({
name: GrowthEventName.PHONE_BOUND,
idempotencyKey: `phone_bound:${userId}`,
userId,
stage: GrowthLeadStage.PHONE_BOUND,
})
}
async onOrderPaid(userId: string, cardType: string, orderId: string) {
const assessment = await this.latestAssessment(userId)
const trial = cardType === CardTypeCategory.TRIAL
await this.recordEvent({
name: trial ? GrowthEventName.TRIAL_PURCHASED : GrowthEventName.PLAN_PURCHASED,
idempotencyKey: `${trial ? 'trial_purchased' : 'plan_purchased'}:${orderId}`,
userId,
assessmentId: assessment?.id,
visitId: assessment?.visitId,
properties: { cardType, orderId },
stage: trial ? GrowthLeadStage.TRIAL_PURCHASED : GrowthLeadStage.PLAN_PURCHASED,
})
if (!trial) {
await this.advanceLead(userId, GrowthLeadStage.TRAINING, assessment?.id)
}
}
async onBookingCreated(userId: string, bookingId: string, cardType: string, originAssessmentId?: string | null) {
if (cardType !== CardTypeCategory.TRIAL) return
const assessmentId = originAssessmentId || (await this.latestAssessment(userId))?.id
const assessment = assessmentId
? await this.prisma.bodyPortraitAssessment.findUnique({ where: { id: assessmentId } })
: null
await this.recordEvent({
name: GrowthEventName.TRIAL_BOOKED,
idempotencyKey: `trial_booked:${bookingId}`,
userId,
assessmentId: assessment?.id,
visitId: assessment?.visitId,
properties: { bookingId },
stage: GrowthLeadStage.TRIAL_BOOKED,
})
}
async onBookingCompleted(userId: string, bookingId: string, cardType: string) {
if (cardType !== CardTypeCategory.TRIAL) return
const assessment = await this.latestAssessment(userId)
await this.recordEvent({
name: GrowthEventName.TRIAL_ATTENDED,
idempotencyKey: `trial_attended:${bookingId}`,
userId,
assessmentId: assessment?.id,
visitId: assessment?.visitId,
properties: { bookingId },
stage: GrowthLeadStage.TRIAL_ATTENDED,
})
}
followUpDraft(input: {
nickname: string
report: BodyPortraitReport | null
sittingHours?: SittingHours | null
workPosture?: WorkPosture | null
}): string {
const greeting = input.nickname ? `${input.nickname}你好,` : ''
if (!input.report) {
return `${greeting}看到你刚刚做了身体状态评估。线上只能做基础判断,如果方便的话,可以来工作室做一次实际的活动度和动作评估,我可以再帮你具体看看。`
}
const top = (Object.entries(input.report.scores) as [BodyDimension, number][])
.sort((a, b) => b[1] - a[1])[0]
const lifestyle = [
input.workPosture ? WORK_POSTURE_LABELS[input.workPosture] : '',
input.sittingHours ? SITTING_HOURS_LABELS[input.sittingHours] : '',
].filter(Boolean).join('、')
const type = PORTRAIT_TYPE_LABELS[input.report.primaryType]
const dimension = BODY_DIMENSION_LABELS[top[0]]
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 {
const value = String(raw || '').toLowerCase()
return (Object.values(BodyPortraitSource) as string[]).includes(value)
? value as BodyPortraitSource
: BodyPortraitSource.ORGANIC
}
private async latestAssessment(userId: string) {
return this.prisma.bodyPortraitAssessment.findFirst({
where: { userId },
orderBy: { createdAt: 'desc' },
})
}
private sanitize(properties?: Record<string, unknown>) {
const result: Record<string, string> = {}
if (!properties) return result
for (const [key, value] of Object.entries(properties)) {
if (!SAFE_EVENT_PROPS.has(key)) continue
if (value == null) continue
result[key] = String(value).slice(0, 80)
}
return result
}
private isUnique(error: unknown) {
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002'
}
}

View File

@@ -0,0 +1,315 @@
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) {
const nextTodo = await this.prisma.reassessmentTodo.findFirst({
where: { userId, completedAt: null },
orderBy: { lessonCheckpoint: 'asc' },
})
if (nextTodo) {
await this.prisma.reassessmentTodo.update({
where: { id: nextTodo.id },
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 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 = 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,
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 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: '肩屈活动' },
{ 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('日期无效')
const todayChina = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10)
if (value > todayChina) throw new BadRequestException('日期不能晚于今天')
return date
}
}

View File

@@ -0,0 +1,109 @@
import { Body, Controller, Get, Param, Post, Put, Req, UseGuards } from '@nestjs/common'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { BodyPortraitService } from './body-portrait.service'
import { BodyPortraitOfflineService } from './body-portrait-offline.service'
import {
AccessTokenDto,
CreateAssessmentDto,
CreateShareCardDto,
CreateVisitDto,
SaveAnswersDto,
TrackEventDto,
} from './dto/body-portrait.dto'
interface RequestLike {
ip?: string
headers: Record<string, string | string[] | undefined>
}
function clientIp(req: RequestLike): string {
const forwarded = req.headers['x-forwarded-for']
const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded
return (raw?.split(',')[0] || req.ip || 'unknown').trim()
}
@Controller('body-portrait')
export class BodyPortraitController {
constructor(
private readonly portraits: BodyPortraitService,
private readonly offline: BodyPortraitOfflineService,
) {}
@Get('stats')
stats() {
return this.portraits.stats()
}
@Post('visits')
createVisit(@Req() req: RequestLike, @Body() dto: CreateVisitDto) {
return this.portraits.createVisit(clientIp(req), dto)
}
@Post('assessments')
start(@Req() req: RequestLike, @Body() dto: CreateAssessmentDto) {
return this.portraits.startAssessment(clientIp(req), dto.visitToken)
}
@Put('assessments')
save(@Body() dto: SaveAnswersDto) {
return this.portraits.saveAnswers(dto.accessToken, dto.answers)
}
@Post('assessments/complete')
complete(@Body() dto: AccessTokenDto) {
return this.portraits.complete(dto.accessToken)
}
@Post('assessments/claim')
@UseGuards(JwtAuthGuard)
claim(@CurrentUser('sub') userId: string, @Body() dto: AccessTokenDto) {
return this.portraits.claim(userId, dto.accessToken)
}
@Get('me')
@UseGuards(JwtAuthGuard)
me(@CurrentUser('sub') userId: string) {
return this.portraits.myLatest(userId)
}
@Get('assessments/:id')
@UseGuards(JwtAuthGuard)
one(@CurrentUser('sub') userId: string, @Param('id') id: string) {
return this.portraits.myReport(userId, id)
}
@Post('events')
track(@Body() dto: TrackEventDto) {
return this.portraits.track(undefined, dto)
}
@Get('sessions')
@UseGuards(JwtAuthGuard)
sessions(@CurrentUser('sub') userId: string) {
return this.offline.listSessions(userId)
}
@Get('plans')
@UseGuards(JwtAuthGuard)
plans(@CurrentUser('sub') userId: string) {
return this.offline.listPlans(userId)
}
@Get('todos')
@UseGuards(JwtAuthGuard)
todos(@CurrentUser('sub') userId: string) {
return this.offline.todos(userId)
}
@Post('share-cards')
@UseGuards(JwtAuthGuard)
shareCard(@CurrentUser('sub') userId: string, @Body() dto: CreateShareCardDto) {
return this.offline.createShareCard(userId, dto.includePhotos === true)
}
@Get('share/:code')
publicShare(@Param('code') code: string) {
return this.offline.publicShare(code)
}
}

View File

@@ -0,0 +1,23 @@
import { Module, forwardRef } from '@nestjs/common'
import { AuthModule } from '../auth/auth.module'
import { BodyPortraitAdminController } from './body-portrait-admin.controller'
import { BodyPortraitAdminService } from './body-portrait-admin.service'
import { BodyPortraitController } from './body-portrait.controller'
import { BodyPortraitLifecycleService } from './body-portrait-lifecycle.service'
import { BodyPortraitOfflineService } from './body-portrait-offline.service'
import { BodyPortraitScheduler } from './body-portrait.scheduler'
import { BodyPortraitService } from './body-portrait.service'
@Module({
imports: [forwardRef(() => AuthModule)],
controllers: [BodyPortraitController, BodyPortraitAdminController],
providers: [
BodyPortraitLifecycleService,
BodyPortraitService,
BodyPortraitAdminService,
BodyPortraitOfflineService,
BodyPortraitScheduler,
],
exports: [BodyPortraitLifecycleService, BodyPortraitService],
})
export class BodyPortraitModule {}

View File

@@ -0,0 +1,33 @@
import { HttpException, HttpStatus } from '@nestjs/common'
interface Bucket {
count: number
resetAt: number
}
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 })
return
}
if (current.count >= limit) {
throw new HttpException('请求过于频繁,请稍后再试', HttpStatus.TOO_MANY_REQUESTS)
}
current.count += 1
}

View File

@@ -0,0 +1,16 @@
import { Injectable, Logger } from '@nestjs/common'
import { Cron } from '@nestjs/schedule'
import { BodyPortraitService } from './body-portrait.service'
@Injectable()
export class BodyPortraitScheduler {
private readonly logger = new Logger(BodyPortraitScheduler.name)
constructor(private readonly portraits: BodyPortraitService) {}
@Cron('15 3 * * *')
async expireDrafts() {
const result = await this.portraits.expireDrafts()
if (result.expired) this.logger.log(`expired ${result.expired} anonymous portrait drafts`)
}
}

View File

@@ -0,0 +1,300 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common'
import {
BODY_PORTRAIT_QUESTIONNAIRE_VERSION,
BODY_PORTRAIT_RULES_VERSION,
BodyPortraitAnswers,
BodyPortraitAssessmentStatus,
BodyPortraitReport,
BodyPortraitSessionResponse,
GrowthEventName,
GrowthLeadStage,
} from '@mp-pilates/shared'
import { Prisma } from '@prisma/client'
import { PrismaService } from '../prisma/prisma.service'
import { BodyPortraitLifecycleService } from './body-portrait-lifecycle.service'
import { assertRateLimit } from './body-portrait.rate-limit'
import { createAccessToken, hashToken } from './body-portrait.token'
import { EMPTY_ANSWERS, normalizeAnswers, scorePortrait, toTeaser } from './scoring/score-v1'
const DRAFT_TTL_MS = 30 * 24 * 60 * 60 * 1000
const ANSWER_LIMIT = 8000
@Injectable()
export class BodyPortraitService {
constructor(
private readonly prisma: PrismaService,
private readonly lifecycle: BodyPortraitLifecycleService,
) {}
async stats() {
const completedCount = await this.prisma.bodyPortraitAssessment.count({
where: { status: { in: [BodyPortraitAssessmentStatus.COMPLETED, BodyPortraitAssessmentStatus.CLAIMED] } },
})
return { completedCount }
}
async createVisit(ip: string, dto: {
source?: string
campaignId?: string
referralCode?: string
landingPath?: string
}) {
assertRateLimit(`visit:${ip}`, 30)
const visitToken = createAccessToken()
const source = this.lifecycle.parseSource(dto.source)
const referralCode = dto.referralCode?.trim().toUpperCase() || null
const referrer = referralCode
? await this.prisma.user.findFirst({ where: { inviteCode: referralCode }, select: { id: true } })
: null
const visit = await this.prisma.bodyPortraitVisit.create({
data: {
tokenHash: hashToken(visitToken),
source,
campaignId: dto.campaignId?.slice(0, 40) || null,
referralCode,
referrerUserId: referrer?.id,
landingPath: (dto.landingPath || '/pages/portrait/index').slice(0, 120),
},
})
return { visitId: visit.id, visitToken, source }
}
async startAssessment(ip: string, visitToken: string): Promise<BodyPortraitSessionResponse> {
assertRateLimit(`start:${ip}`, 40)
const visit = await this.visitByToken(visitToken)
const existing = await this.prisma.bodyPortraitAssessment.findFirst({
where: {
visitId: visit.id,
status: { in: [BodyPortraitAssessmentStatus.DRAFT, BodyPortraitAssessmentStatus.COMPLETED] },
expiresAt: { gt: new Date() },
},
orderBy: { createdAt: 'desc' },
})
if (existing && !existing.userId) {
const accessToken = createAccessToken()
const rotated = await this.prisma.bodyPortraitAssessment.update({
where: { id: existing.id },
data: { tokenHash: hashToken(accessToken) },
})
return this.sessionFrom(rotated, accessToken, visitToken, false)
}
const accessToken = createAccessToken()
const assessment = await this.prisma.bodyPortraitAssessment.create({
data: {
visitId: visit.id,
tokenHash: hashToken(accessToken),
questionnaireVersion: BODY_PORTRAIT_QUESTIONNAIRE_VERSION,
rulesVersion: BODY_PORTRAIT_RULES_VERSION,
status: BodyPortraitAssessmentStatus.DRAFT,
answers: EMPTY_ANSWERS as unknown as Prisma.InputJsonValue,
expiresAt: new Date(Date.now() + DRAFT_TTL_MS),
},
})
await this.lifecycle.recordEvent({
name: GrowthEventName.ASSESSMENT_STARTED,
idempotencyKey: `assessment_started:${assessment.id}`,
visitId: visit.id,
assessmentId: assessment.id,
stage: GrowthLeadStage.STARTED,
})
await this.prisma.bodyPortraitVisit.update({
where: { id: visit.id },
data: { lastSeenAt: new Date() },
})
return this.sessionFrom(assessment, accessToken, visitToken, false)
}
async saveAnswers(accessToken: string, raw: unknown): Promise<BodyPortraitSessionResponse> {
if (JSON.stringify(raw || {}).length > ANSWER_LIMIT) {
throw new BadRequestException('答卷内容过长')
}
const assessment = await this.assessmentByToken(accessToken)
if (assessment.status === BodyPortraitAssessmentStatus.CLAIMED) {
throw new ForbiddenException('报告已认领,不能再修改')
}
if (assessment.status === BodyPortraitAssessmentStatus.EXPIRED || assessment.expiresAt < new Date()) {
throw new BadRequestException('测评已过期,请重新开始')
}
const answers = normalizeAnswers(raw as Partial<BodyPortraitAnswers>)
const updated = await this.prisma.bodyPortraitAssessment.update({
where: { id: assessment.id },
data: { answers: answers as unknown as Prisma.InputJsonValue },
})
return this.sessionFrom(updated, accessToken, '', false)
}
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('请完成必答题后再生成画像')
}
const report = scorePortrait(answers)
const updated = await this.prisma.bodyPortraitAssessment.update({
where: { id: assessment.id },
data: {
answers: answers as unknown as Prisma.InputJsonValue,
report: report as unknown as Prisma.InputJsonValue,
safetyFlagged: report.safety.flagged,
status: assessment.status === BodyPortraitAssessmentStatus.CLAIMED
? BodyPortraitAssessmentStatus.CLAIMED
: BodyPortraitAssessmentStatus.COMPLETED,
completedAt: assessment.completedAt || new Date(),
rulesVersion: BODY_PORTRAIT_RULES_VERSION,
},
})
await this.lifecycle.recordEvent({
name: GrowthEventName.ASSESSMENT_COMPLETED,
idempotencyKey: `assessment_completed:${assessment.id}`,
visitId: assessment.visitId,
assessmentId: assessment.id,
userId: assessment.userId,
stage: GrowthLeadStage.COMPLETED,
})
return this.sessionFrom(updated, accessToken, '', false)
}
async claim(userId: string, accessToken: string): Promise<BodyPortraitSessionResponse> {
const assessment = await this.assessmentByToken(accessToken)
if (assessment.userId && assessment.userId !== userId) {
throw new ForbiddenException('这份测评已经属于其他用户')
}
if (assessment.status === BodyPortraitAssessmentStatus.DRAFT || !assessment.report) {
throw new BadRequestException('请先完成测评')
}
const updated = await this.prisma.$transaction(async (tx) => {
const claimed = await tx.bodyPortraitAssessment.updateMany({
where: {
id: assessment.id,
OR: [{ userId: null }, { userId }],
},
data: {
userId,
status: BodyPortraitAssessmentStatus.CLAIMED,
claimedAt: assessment.claimedAt || new Date(),
},
})
if (!claimed.count) throw new ForbiddenException('认领失败')
await tx.bodyPortraitVisit.update({
where: { id: assessment.visitId },
data: { userId, lastSeenAt: new Date() },
})
return tx.bodyPortraitAssessment.findUniqueOrThrow({ where: { id: assessment.id } })
})
await this.lifecycle.recordEvent({
name: GrowthEventName.ASSESSMENT_CLAIMED,
idempotencyKey: `assessment_claimed:${assessment.id}:${userId}`,
visitId: assessment.visitId,
assessmentId: assessment.id,
userId,
stage: GrowthLeadStage.CLAIMED,
})
const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { phone: true } })
if (user?.phone) await this.lifecycle.onPhoneBound(userId)
return this.sessionFrom(updated, accessToken, '', true)
}
async myLatest(userId: string): Promise<BodyPortraitSessionResponse | null> {
const assessment = await this.prisma.bodyPortraitAssessment.findFirst({
where: { userId },
orderBy: { createdAt: 'desc' },
})
if (!assessment) return null
return this.sessionFrom(assessment, '', '', true)
}
async myReport(userId: string, assessmentId: string): Promise<BodyPortraitSessionResponse> {
const assessment = await this.prisma.bodyPortraitAssessment.findFirst({
where: { id: assessmentId, userId },
})
if (!assessment) throw new NotFoundException('报告不存在')
return this.sessionFrom(assessment, '', '', true)
}
async track(userId: string | undefined, dto: { name: GrowthEventName; accessToken?: string; idempotencyKey?: string }) {
const assessment = dto.accessToken ? await this.assessmentByToken(dto.accessToken) : null
if (assessment && userId && assessment.userId && assessment.userId !== userId) {
throw new ForbiddenException('无权记录该测评事件')
}
const owner = userId || assessment?.userId
await this.lifecycle.recordEvent({
name: dto.name,
idempotencyKey: dto.idempotencyKey || `${dto.name}:${assessment?.id || owner || 'anon'}`,
visitId: assessment?.visitId,
assessmentId: assessment?.id,
userId: owner,
})
return { recorded: true }
}
async assertOwnedAssessment(userId: string, assessmentId: string) {
const assessment = await this.prisma.bodyPortraitAssessment.findFirst({
where: { id: assessmentId, userId },
})
if (!assessment) throw new ForbiddenException('画像报告不属于当前用户')
return assessment
}
async expireDrafts(now = new Date()) {
const result = await this.prisma.bodyPortraitAssessment.updateMany({
where: {
status: BodyPortraitAssessmentStatus.DRAFT,
expiresAt: { lte: now },
},
data: { status: BodyPortraitAssessmentStatus.EXPIRED },
})
return { expired: result.count }
}
private async visitByToken(visitToken: string) {
const visit = await this.prisma.bodyPortraitVisit.findUnique({
where: { tokenHash: hashToken(visitToken) },
})
if (!visit) throw new NotFoundException('访问凭证无效')
return visit
}
private async assessmentByToken(accessToken: string) {
const assessment = await this.prisma.bodyPortraitAssessment.findUnique({
where: { tokenHash: hashToken(accessToken) },
})
if (!assessment) throw new NotFoundException('测评凭证无效')
return assessment
}
private sessionFrom(
assessment: {
id: string
status: string
answers: Prisma.JsonValue
report: Prisma.JsonValue | null
userId: string | null
},
accessToken: string,
visitToken: string,
claimedView: boolean,
): BodyPortraitSessionResponse {
const answers = normalizeAnswers(assessment.answers as Partial<BodyPortraitAnswers>)
const report = (assessment.report || null) as BodyPortraitReport | null
const claimed = assessment.status === BodyPortraitAssessmentStatus.CLAIMED && claimedView
return {
assessmentId: assessment.id,
accessToken,
visitToken,
questionnaireVersion: BODY_PORTRAIT_QUESTIONNAIRE_VERSION,
status: assessment.status as BodyPortraitAssessmentStatus,
answers,
teaser: report ? toTeaser(assessment.id, report) : null,
report: claimedView ? report : null,
claimed,
}
}
}

View File

@@ -0,0 +1,13 @@
import { createHash, randomBytes } from 'crypto'
export function createAccessToken(): string {
return randomBytes(32).toString('hex')
}
export function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex')
}
export function createShareCode(): string {
return randomBytes(6).toString('hex')
}

View File

@@ -0,0 +1,179 @@
import { Type } from 'class-transformer'
import {
IsBoolean,
IsDateString,
IsEnum,
IsIn,
IsNumber,
IsObject,
IsOptional,
IsString,
IsUUID,
Matches,
Max,
MaxLength,
Min,
MinLength,
ValidateNested,
} from 'class-validator'
import {
BodyPortraitSource,
GrowthEventName,
ProfessionalAssessmentKind,
ProgressPhotoAngle,
} from '@mp-pilates/shared'
const CLIENT_EVENTS = [
GrowthEventName.REPORT_VIEWED,
GrowthEventName.ADVICE_VIEWED,
GrowthEventName.TRIAL_CLICKED,
GrowthEventName.REPORT_SHARED,
]
export class CreateVisitDto {
@IsOptional()
@IsString()
@MaxLength(32)
source?: string
@IsOptional()
@IsString()
@MaxLength(40)
campaignId?: string
@IsOptional()
@IsString()
@MaxLength(12)
referralCode?: string
@IsOptional()
@IsString()
@MaxLength(120)
landingPath?: string
}
export class CreateAssessmentDto {
@IsString()
@MinLength(16)
@MaxLength(128)
visitToken!: string
}
export class SaveAnswersDto {
@IsString()
@MinLength(16)
@MaxLength(128)
accessToken!: string
@IsObject()
answers!: Record<string, unknown>
}
export class AccessTokenDto {
@IsString()
@MinLength(16)
@MaxLength(128)
accessToken!: string
}
export class TrackEventDto {
@IsIn(CLIENT_EVENTS)
name!: GrowthEventName
@IsOptional()
@IsString()
@MinLength(16)
@MaxLength(128)
accessToken?: string
@IsOptional()
@IsString()
@MaxLength(80)
idempotencyKey?: string
}
export class UpdateLeadNoteDto {
@IsString()
@MaxLength(500)
coachNote!: string
}
class ObservationDto {
@IsNumber() @Min(1) @Max(5) headPosition!: number
@IsNumber() @Min(1) @Max(5) shoulderPosition!: number
@IsNumber() @Min(1) @Max(5) thoracicExtension!: number
@IsNumber() @Min(1) @Max(5) shoulderFlexion!: number
@IsNumber() @Min(1) @Max(5) breathing!: number
@IsNumber() @Min(1) @Max(5) pelvis!: number
@IsNumber() @Min(1) @Max(5) coreControl!: number
@IsNumber() @Min(1) @Max(5) hipMobility!: number
@IsNumber() @Min(1) @Max(5) singleLeg!: number
}
export class CreateProfessionalAssessmentDto {
@IsEnum(ProfessionalAssessmentKind)
kind!: ProfessionalAssessmentKind
@IsDateString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
recordedAt!: string
@IsOptional()
@IsUUID()
bookingId?: string
@IsOptional()
@IsUUID()
originAssessmentId?: string
@ValidateNested()
@Type(() => ObservationDto)
observations!: ObservationDto
@IsOptional()
@IsNumber()
@Min(0)
@Max(10)
subjectiveTension?: number | null
@IsString()
@MaxLength(1000)
coachSummary!: string
@IsString()
@MaxLength(500)
trainingFocus!: string
@IsString()
@MaxLength(500)
phaseGoal!: string
@IsOptional()
@IsEnum(ProgressPhotoAngle)
photoAngle?: ProgressPhotoAngle | null
}
export class CreateTrainingPlanDto {
@IsOptional()
@IsUUID()
sessionId?: string
@IsOptional()
@IsString()
@MaxLength(80)
title?: string
@IsOptional()
@IsNumber()
@Min(4)
@Max(24)
weeks?: number
}
export class CreateShareCardDto {
@IsOptional()
@IsBoolean()
includePhotos?: boolean
}
export { BodyPortraitSource }

View File

@@ -0,0 +1,548 @@
import {
AfterSitting,
BODY_DIMENSION_LABELS,
BODY_PORTRAIT_DISCLAIMER,
BODY_PORTRAIT_QUESTIONNAIRE_VERSION,
BODY_PORTRAIT_RULES_VERSION,
BODY_REGION_LABELS,
BodyDimension,
BodyPortraitAdvice,
BodyPortraitAnswers,
BodyPortraitChain,
BodyPortraitEvidence,
BodyPortraitLevels,
BodyPortraitReport,
BodyPortraitSafetyResult,
BodyPortraitScores,
BodyRegion,
ConcernLevel,
ExerciseFreq,
PortraitGoal,
PORTRAIT_GOAL_LABELS,
PortraitMatchQuality,
PortraitType,
PORTRAIT_TYPE_LABELS,
SafetyFlag,
SittingHours,
StandingNotice,
WorkPosture,
AFTER_SITTING_LABELS,
EXERCISE_FREQ_LABELS,
SITTING_HOURS_LABELS,
STANDING_NOTICE_LABELS,
WORK_POSTURE_LABELS,
} from '@mp-pilates/shared'
export const EMPTY_ANSWERS: BodyPortraitAnswers = {
concerns: [],
goal: null,
sittingHours: null,
exerciseFreq: null,
workPosture: null,
endOfDayFatigue: [],
afterSitting: [],
standingNotice: [],
safety: [],
}
type MutableScores = {
-readonly [K in keyof BodyPortraitScores]: number
}
interface EvidenceCandidate {
readonly dimension: BodyDimension
readonly title: string
readonly answers: readonly string[]
readonly explanation: string
readonly weight: number
}
const DIMENSIONS: readonly BodyDimension[] = [
BodyDimension.CERVICAL_SHOULDER,
BodyDimension.SPINAL_MOBILITY,
BodyDimension.CORE_CONTROL,
BodyDimension.HIP_PELVIS,
BodyDimension.LOWER_LIMB,
]
const REGION_VALUES = new Set(Object.values(BodyRegion))
const GOAL_VALUES = new Set(Object.values(PortraitGoal))
const SITTING_VALUES = new Set(Object.values(SittingHours))
const EXERCISE_VALUES = new Set(Object.values(ExerciseFreq))
const WORK_VALUES = new Set(Object.values(WorkPosture))
const AFTER_VALUES = new Set(Object.values(AfterSitting))
const STANDING_VALUES = new Set(Object.values(StandingNotice))
const SAFETY_VALUES = new Set(Object.values(SafetyFlag))
function emptyScores(): MutableScores {
return {
cervicalShoulder: 0,
spinalMobility: 0,
coreControl: 0,
hipPelvis: 0,
lowerLimb: 0,
}
}
function add(scores: MutableScores, dimension: BodyDimension, amount: number) {
scores[dimension] += amount
}
function clamp(value: number): number {
return Math.max(0, Math.min(100, value))
}
export function concernLevel(score: number): ConcernLevel {
if (score >= 86) return ConcernLevel.PRIORITY
if (score >= 71) return ConcernLevel.HIGH
if (score >= 51) return ConcernLevel.MODERATE
if (score >= 31) return ConcernLevel.MILD
return ConcernLevel.LOW
}
export function safetyFlagsOf(answers: BodyPortraitAnswers): SafetyFlag[] {
return answers.safety.filter((flag) => flag !== SafetyFlag.NONE)
}
export function isSafetyFlagged(answers: BodyPortraitAnswers): boolean {
return safetyFlagsOf(answers).length > 0
}
function list<T>(values: readonly T[], labels: Record<string, string>): string[] {
return values.map((value) => labels[String(value)] || String(value))
}
function applyRegions(scores: MutableScores, regions: readonly BodyRegion[], weight: number, evidence: EvidenceCandidate[], title: string, explanation: string) {
for (const region of regions) {
if (region === BodyRegion.NECK || region === BodyRegion.SHOULDER) {
add(scores, BodyDimension.CERVICAL_SHOULDER, weight)
} else if (region === BodyRegion.UPPER_BACK) {
add(scores, BodyDimension.SPINAL_MOBILITY, weight)
} else if (region === BodyRegion.LOW_BACK) {
add(scores, BodyDimension.SPINAL_MOBILITY, Math.round(weight * 0.6))
add(scores, BodyDimension.CORE_CONTROL, weight)
} else if (region === BodyRegion.PELVIS || region === BodyRegion.HIP) {
add(scores, BodyDimension.HIP_PELVIS, weight)
} else {
add(scores, BodyDimension.LOWER_LIMB, weight)
}
}
if (regions.length) {
const top = regions[0] === BodyRegion.NECK || regions[0] === BodyRegion.SHOULDER
? BodyDimension.CERVICAL_SHOULDER
: regions[0] === BodyRegion.UPPER_BACK || regions[0] === BodyRegion.LOW_BACK
? regions[0] === BodyRegion.LOW_BACK ? BodyDimension.CORE_CONTROL : BodyDimension.SPINAL_MOBILITY
: regions[0] === BodyRegion.PELVIS || regions[0] === BodyRegion.HIP
? BodyDimension.HIP_PELVIS
: BodyDimension.LOWER_LIMB
evidence.push({
dimension: top,
title,
answers: list(regions, BODY_REGION_LABELS),
explanation,
weight: weight * regions.length,
})
}
}
function pickType(scores: BodyPortraitScores, answers: BodyPortraitAnswers): { primary: PortraitType; secondary: PortraitType | null } {
const entries = DIMENSIONS.map((dimension) => [dimension, scores[dimension]] as const)
.sort((a, b) => b[1] - a[1])
const max = entries[0][1]
const highCount = entries.filter(([, score]) => score >= 50).length
const sedentary = answers.sittingHours === SittingHours.GT8 || answers.sittingHours === SittingHours.H6_TO_8
const deskLike = answers.workPosture === WorkPosture.COMPUTER || answers.workPosture === WorkPosture.PHONE
if (max < 35) return { primary: PortraitType.LOW_OVERALL_CONCERN, secondary: null }
let primary: PortraitType
if (scores.cervicalShoulder >= 60 && scores.coreControl >= 45 && scores.hipPelvis >= 40) {
primary = PortraitType.UPPER_LOWER_CROSS
} else if (highCount >= 3) {
primary = PortraitType.MIXED_FATIGUE
} else if (entries[0][0] === BodyDimension.CERVICAL_SHOULDER && scores.cervicalShoulder >= 55 && (sedentary || deskLike)) {
primary = PortraitType.SEDENTARY_NECK_TENSION
} else if (entries[0][0] === BodyDimension.CORE_CONTROL && sedentary) {
primary = PortraitType.SEDENTARY_CORE_WEAK
} else if (entries[0][0] === BodyDimension.CERVICAL_SHOULDER) {
primary = PortraitType.NECK_COMPENSATION
} else if (entries[0][0] === BodyDimension.HIP_PELVIS) {
primary = PortraitType.HIP_TIGHT
} else if (entries[0][0] === BodyDimension.LOWER_LIMB) {
primary = PortraitType.LOWER_LIMB_UNSTABLE
} else if (entries[0][0] === BodyDimension.SPINAL_MOBILITY || entries[0][0] === BodyDimension.CORE_CONTROL) {
primary = sedentary ? PortraitType.SEDENTARY_CORE_WEAK : PortraitType.MIXED_FATIGUE
} else {
primary = PortraitType.MIXED_FATIGUE
}
let secondary: PortraitType | null = null
if (entries[1][1] >= 55) {
const mapped = mapDimensionType(entries[1][0], sedentary)
if (mapped !== primary) secondary = mapped
}
return { primary, secondary }
}
function mapDimensionType(dimension: BodyDimension, sedentary: boolean): PortraitType {
if (dimension === BodyDimension.CERVICAL_SHOULDER) {
return sedentary ? PortraitType.SEDENTARY_NECK_TENSION : PortraitType.NECK_COMPENSATION
}
if (dimension === BodyDimension.CORE_CONTROL || dimension === BodyDimension.SPINAL_MOBILITY) {
return sedentary ? PortraitType.SEDENTARY_CORE_WEAK : PortraitType.MIXED_FATIGUE
}
if (dimension === BodyDimension.HIP_PELVIS) return PortraitType.HIP_TIGHT
return PortraitType.LOWER_LIMB_UNSTABLE
}
function adviceFor(type: PortraitType, safety: boolean): BodyPortraitAdvice | null {
if (safety) return null
if (type === PortraitType.HIP_TIGHT || type === PortraitType.LOWER_LIMB_UNSTABLE) {
return {
id: 'hip_pack',
items: [
{ title: '每坐一段时间做一次髋部开合或体重转移', detail: '让髋部重新参与日常活动,而不是长时间保持同一个坐姿。', stopIf: '出现明显刺痛、发麻或关节卡住感时立即停止。' },
{ title: '站立时少用膝盖锁死来“站直”', detail: '膝盖微微有弹性,让髋和核心一起承担站姿。', stopIf: '膝盖疼痛或不稳时不要继续。' },
{ title: '训练时优先关注髋部活动与单腿稳定', detail: '先找回髋部的活动空间,再谈更高强度的下肢动作。', stopIf: '单腿站立明显失控或疼痛时停止,改到店评估。' },
],
}
}
if (type === PortraitType.SEDENTARY_CORE_WEAK || type === PortraitType.LOW_OVERALL_CONCERN) {
return {
id: 'core_pack',
items: [
{ title: '久坐时留意骨盆中立', detail: '避免长时间塌腰或过度挺腹,让躯干更均匀地参与支撑。', stopIf: '腰痛明显加重时停止,先改变姿势或起身。' },
{ title: '每天安排 5 组轻柔呼吸激活', detail: '用鼻吸口呼,让肋骨和腹部轻轻配合,而不是憋气挺腹。', stopIf: '头晕、胸闷或疼痛时停止。' },
{ title: '训练时优先关注核心参与', detail: '一些动作里身体可能更多依赖腰背,可以先放慢,让躯干整体参与。', stopIf: '腰部出现尖锐疼痛时立即停止。' },
],
}
}
return {
id: 'neck_pack',
items: [
{ title: '每坐 4560 分钟起来活动一次', detail: '短暂走动或做肩胛活动,减少肩颈持续承担支撑。', stopIf: '头晕、手臂发麻或疼痛加重时停止。' },
{ title: '每天安排 35 分钟上背活动', detail: '用缓慢的扩展和旋转,让上背部重新参与,而不是只拉伸脖子。', stopIf: '出现放射痛、明显弹响伴随疼痛时停止。' },
{ title: '训练时优先关注呼吸与肩胛运动', detail: '肩颈紧并不只是局部问题,通常需要同时看到胸椎、呼吸和肩胛。', stopIf: '颈部刺痛或手脚发麻时停止,并先确认运动条件。' },
],
}
}
function chainFor(type: PortraitType, answers: BodyPortraitAnswers): BodyPortraitChain {
if (type === PortraitType.HIP_TIGHT || type === PortraitType.LOWER_LIMB_UNSTABLE) {
return {
steps: ['久坐或单侧承重', '髋部活动变少', '骨盆更难保持中立', '膝盖或腰更容易代偿'],
takeaway: '所以只拉伸腿部往往只能短暂轻松。训练通常需要同时关注髋部活动、骨盆控制和单腿稳定。',
}
}
if (type === PortraitType.SEDENTARY_CORE_WEAK) {
return {
steps: ['长时间坐姿', '核心参与减少', '腰背更容易代偿', '站立或动作时腹部向前顶'],
takeaway: '所以只做腹肌练习不一定能让久坐更轻松。训练通常需要先找回呼吸、骨盆控制和躯干整体参与。',
}
}
if (answers.workPosture === WorkPosture.PHONE) {
return {
steps: ['频繁低头看手机', '头部位置前移', '肩部更容易向内', '肩颈承担更多压力'],
takeaway: '所以仅仅按摩肩颈往往只能短暂舒服。训练通常需要同时关注头部位置、肩胛运动和上背活动。',
}
}
return {
steps: ['久坐', '胸椎活动减少', '肩部更容易向前', '头部位置改变', '肩颈承担更多压力'],
takeaway: '所以仅仅按摩肩颈往往只能短暂舒服。训练通常需要同时关注胸椎活动、呼吸、核心控制以及肩胛运动模式。',
}
}
function headlineFor(primary: PortraitType, secondary: PortraitType | null): string {
const main = PORTRAIT_TYPE_LABELS[primary]
if (!secondary || secondary === primary) return main
return `${main} + ${PORTRAIT_TYPE_LABELS[secondary]}`
}
function summaryFor(answers: BodyPortraitAnswers, primary: PortraitType, safety: BodyPortraitSafetyResult): string {
if (safety.flagged) {
return '根据你的回答,当前更适合先确认运动条件,而不是继续给出训练建议。线上问卷只能反映主观感受,不能代替专业医疗评估。'
}
const goal = answers.goal ? PORTRAIT_GOAL_LABELS[answers.goal] : null
if (primary === PortraitType.SEDENTARY_NECK_TENSION) {
return goal
? `如果你的目标是${goal},目前最值得关注的是肩颈、上背活动和核心控制。你的身体并不是单纯“肩颈不好”,更可能是长时间坐姿让肩颈承担了更多工作。`
: '你的身体并不是单纯“肩颈不好”,更可能是长时间坐姿、上背活动减少以及核心参与不足,让肩颈承担了更多工作。'
}
if (primary === PortraitType.SEDENTARY_CORE_WEAK) {
return goal
? `如果你的目标是${goal},目前更值得关注的是核心参与和腰背是否在代偿,而不是先追求更高强度。`
: '一些动作中身体可能更多依赖腰背,而不是躯干整体参与。久坐会让这种情况更容易出现。'
}
if (primary === PortraitType.HIP_TIGHT) {
return goal
? `如果你的目标是${goal},目前最值得关注的是髋部灵活性和骨盆控制。`
: '髋部活动变少时,腰和膝常常会帮忙代偿。这值得进一步观察,而不是先下结论。'
}
if (primary === PortraitType.LOWER_LIMB_UNSTABLE) {
return goal
? `如果你的目标是${goal},目前更值得关注下肢稳定和髋部控制。`
: '单侧承重或膝盖锁死,会让下肢更难稳定地完成日常动作。'
}
if (primary === PortraitType.LOW_OVERALL_CONCERN) {
return '根据当前问卷,整体关注度不高。若你仍有具体目标,到店评估会比线上问卷更能看出动作细节。'
}
return goal
? `如果你的目标是${goal},目前最值得关注的是几个区域如何互相影响,而不是只处理最酸的那一处。`
: '肩颈、核心和髋部往往一起变化。线上问卷能帮我们抓住优先关注点,真正如何运动还需要现场观察。'
}
function hintsFor(type: PortraitType): string[] {
if (type === PortraitType.HIP_TIGHT || type === PortraitType.LOWER_LIMB_UNSTABLE) {
return ['骨盆位置', '髋部活动', '单腿稳定', '呼吸模式', '深蹲控制']
}
if (type === PortraitType.SEDENTARY_CORE_WEAK) {
return ['呼吸模式', '骨盆位置', 'Dead Bug 控制', '胸椎伸展', '自然站姿']
}
return ['自然站姿头部位置', '肩胛位置', '胸椎伸展', '肩屈活动', '呼吸模式']
}
function answeredCount(answers: BodyPortraitAnswers): number {
return [
answers.concerns.length > 0,
!!answers.goal,
!!answers.sittingHours,
!!answers.exerciseFreq,
!!answers.workPosture,
answers.endOfDayFatigue.length > 0,
answers.afterSitting.length > 0,
answers.standingNotice.length > 0,
answers.safety.length > 0,
].filter(Boolean).length
}
export function normalizeAnswers(input: Partial<BodyPortraitAnswers> | null | undefined): BodyPortraitAnswers {
const source = input || {}
return {
concerns: uniqueEnums(source.concerns, REGION_VALUES),
goal: pickEnum(source.goal, GOAL_VALUES),
sittingHours: pickEnum(source.sittingHours, SITTING_VALUES),
exerciseFreq: pickEnum(source.exerciseFreq, EXERCISE_VALUES),
workPosture: pickEnum(source.workPosture, WORK_VALUES),
endOfDayFatigue: uniqueEnums(source.endOfDayFatigue, REGION_VALUES),
afterSitting: uniqueEnums(source.afterSitting, AFTER_VALUES),
standingNotice: uniqueEnums(source.standingNotice, STANDING_VALUES),
safety: uniqueEnums(source.safety, SAFETY_VALUES),
}
}
function uniqueEnums<T>(values: readonly T[] | undefined, allowed: Set<string>): T[] {
if (!Array.isArray(values)) return []
const seen = new Set<string>()
const result: T[] = []
for (const value of values) {
if (typeof value !== 'string' || !allowed.has(value) || seen.has(value)) continue
seen.add(value)
result.push(value as T)
}
return result
}
function pickEnum<T>(value: T | null | undefined, allowed: Set<string>): T | null {
if (typeof value !== 'string' || !allowed.has(value)) return null
return value as T
}
export function scorePortrait(raw: Partial<BodyPortraitAnswers> | null | undefined): BodyPortraitReport {
const answers = normalizeAnswers(raw)
const scores = emptyScores()
const evidence: EvidenceCandidate[] = []
applyRegions(scores, answers.concerns, 8, evidence, '你标注的困扰区域', '这些部位是你目前最在意的地方,会作为优先关注线索。')
applyRegions(scores, answers.endOfDayFatigue, 6, evidence, '一天结束时更容易累的地方', '疲劳部位帮助我们理解压力如何在一天里累积。')
if (answers.goal === PortraitGoal.POSTURE) {
add(scores, BodyDimension.CERVICAL_SHOULDER, 10)
add(scores, BodyDimension.SPINAL_MOBILITY, 8)
add(scores, BodyDimension.CORE_CONTROL, 8)
evidence.push({ dimension: BodyDimension.CERVICAL_SHOULDER, title: '你希望看起来更挺拔', answers: [PORTRAIT_GOAL_LABELS[answers.goal]], explanation: '挺拔通常同时涉及肩颈位置、上背活动和核心控制。', weight: 10 })
} else if (answers.goal === PortraitGoal.NECK_RELIEF) {
add(scores, BodyDimension.CERVICAL_SHOULDER, 12)
evidence.push({ dimension: BodyDimension.CERVICAL_SHOULDER, title: '你希望肩颈没那么累', answers: [PORTRAIT_GOAL_LABELS[answers.goal]], explanation: '肩颈疲劳往往不是局部问题,需要看坐姿和上背是否在帮忙。', weight: 12 })
} else if (answers.goal === PortraitGoal.BACK_COMFORT) {
add(scores, BodyDimension.SPINAL_MOBILITY, 10)
add(scores, BodyDimension.CORE_CONTROL, 8)
evidence.push({ dimension: BodyDimension.SPINAL_MOBILITY, title: '你希望久坐更舒服', answers: [PORTRAIT_GOAL_LABELS[answers.goal]], explanation: '久坐不适通常和上背活动、核心参与一起出现。', weight: 10 })
} else if (answers.goal === PortraitGoal.CORE) {
add(scores, BodyDimension.CORE_CONTROL, 12)
evidence.push({ dimension: BodyDimension.CORE_CONTROL, title: '你希望腹部更有力量', answers: [PORTRAIT_GOAL_LABELS[answers.goal]], explanation: '核心控制指的是躯干整体参与,而不是单纯“腹肌薄弱”。', weight: 12 })
} else if (answers.goal === PortraitGoal.SHAPING) {
add(scores, BodyDimension.CORE_CONTROL, 8)
add(scores, BodyDimension.HIP_PELVIS, 6)
} else if (answers.goal === PortraitGoal.HIP_LEG) {
add(scores, BodyDimension.HIP_PELVIS, 12)
add(scores, BodyDimension.LOWER_LIMB, 8)
evidence.push({ dimension: BodyDimension.HIP_PELVIS, title: '你希望改善髋腿状态', answers: [PORTRAIT_GOAL_LABELS[answers.goal]], explanation: '髋部灵活性和下肢稳定会一起影响腿型与日常动作。', weight: 12 })
} else if (answers.goal === PortraitGoal.STABILITY) {
add(scores, BodyDimension.LOWER_LIMB, 10)
add(scores, BodyDimension.CORE_CONTROL, 8)
}
if (answers.sittingHours === SittingHours.GT8) {
add(scores, BodyDimension.CERVICAL_SHOULDER, 12)
add(scores, BodyDimension.SPINAL_MOBILITY, 10)
add(scores, BodyDimension.CORE_CONTROL, 8)
evidence.push({ dimension: BodyDimension.CERVICAL_SHOULDER, title: '每天坐超过 8 小时', answers: [SITTING_HOURS_LABELS[answers.sittingHours]], explanation: '长时间坐姿会减少上背活动,让肩颈和腰背承担更多。', weight: 12 })
} else if (answers.sittingHours === SittingHours.H6_TO_8) {
add(scores, BodyDimension.CERVICAL_SHOULDER, 8)
add(scores, BodyDimension.SPINAL_MOBILITY, 6)
add(scores, BodyDimension.CORE_CONTROL, 6)
evidence.push({ dimension: BodyDimension.CERVICAL_SHOULDER, title: '每天坐 68 小时', answers: [SITTING_HOURS_LABELS[answers.sittingHours]], explanation: '中长时间坐姿已经足以改变肩颈和核心的工作方式。', weight: 8 })
} else if (answers.sittingHours === SittingHours.H4_TO_6) {
add(scores, BodyDimension.CERVICAL_SHOULDER, 4)
add(scores, BodyDimension.SPINAL_MOBILITY, 3)
add(scores, BodyDimension.CORE_CONTROL, 3)
}
if (answers.exerciseFreq === ExerciseFreq.NONE) {
add(scores, BodyDimension.CERVICAL_SHOULDER, 8)
add(scores, BodyDimension.SPINAL_MOBILITY, 8)
add(scores, BodyDimension.CORE_CONTROL, 8)
add(scores, BodyDimension.HIP_PELVIS, 8)
add(scores, BodyDimension.LOWER_LIMB, 8)
evidence.push({ dimension: BodyDimension.CORE_CONTROL, title: '目前几乎不运动', answers: [EXERCISE_FREQ_LABELS[answers.exerciseFreq]], explanation: '缺少规律活动时,身体更容易用局部紧张来维持姿势。', weight: 8 })
} else if (answers.exerciseFreq === ExerciseFreq.W1_TO_2) {
add(scores, BodyDimension.CERVICAL_SHOULDER, 4)
add(scores, BodyDimension.CORE_CONTROL, 4)
add(scores, BodyDimension.HIP_PELVIS, 4)
} else if (answers.exerciseFreq === ExerciseFreq.W5_PLUS) {
add(scores, BodyDimension.CERVICAL_SHOULDER, -4)
add(scores, BodyDimension.SPINAL_MOBILITY, -4)
add(scores, BodyDimension.CORE_CONTROL, -4)
add(scores, BodyDimension.HIP_PELVIS, -4)
add(scores, BodyDimension.LOWER_LIMB, -4)
}
if (answers.workPosture === WorkPosture.COMPUTER) {
add(scores, BodyDimension.CERVICAL_SHOULDER, 8)
add(scores, BodyDimension.SPINAL_MOBILITY, 6)
evidence.push({ dimension: BodyDimension.CERVICAL_SHOULDER, title: '工作时以电脑办公为主', answers: [WORK_POSTURE_LABELS[answers.workPosture]], explanation: '屏幕前的固定姿势会让肩颈和上背持续处于同一模式。', weight: 8 })
} else if (answers.workPosture === WorkPosture.PHONE) {
add(scores, BodyDimension.CERVICAL_SHOULDER, 10)
evidence.push({ dimension: BodyDimension.CERVICAL_SHOULDER, title: '经常低头使用手机', answers: [WORK_POSTURE_LABELS[answers.workPosture]], explanation: '头部前移会让肩颈持续多做功。', weight: 10 })
} else if (answers.workPosture === WorkPosture.STANDING) {
add(scores, BodyDimension.LOWER_LIMB, 8)
add(scores, BodyDimension.HIP_PELVIS, 4)
} else if (answers.workPosture === WorkPosture.HOLDING_CHILD) {
add(scores, BodyDimension.CORE_CONTROL, 8)
add(scores, BodyDimension.HIP_PELVIS, 8)
add(scores, BodyDimension.CERVICAL_SHOULDER, 6)
}
const after = answers.afterSitting.filter((item) => item !== AfterSitting.NONE)
if (after.includes(AfterSitting.NECK_TIGHT)) {
add(scores, BodyDimension.CERVICAL_SHOULDER, 14)
evidence.push({ dimension: BodyDimension.CERVICAL_SHOULDER, title: '久坐后肩颈容易紧', answers: [AFTER_SITTING_LABELS[AfterSitting.NECK_TIGHT]], explanation: '这是肩颈正在承担过多支撑的常见感受。', weight: 14 })
}
if (after.includes(AfterSitting.LOW_BACK_ACHE)) {
add(scores, BodyDimension.CORE_CONTROL, 10)
add(scores, BodyDimension.HIP_PELVIS, 6)
add(scores, BodyDimension.SPINAL_MOBILITY, 6)
evidence.push({ dimension: BodyDimension.CORE_CONTROL, title: '久坐后容易腰酸', answers: [AFTER_SITTING_LABELS[AfterSitting.LOW_BACK_ACHE]], explanation: '腰酸常常提示身体更多依赖腰背,而不是躯干整体参与。', weight: 10 })
}
if (after.includes(AfterSitting.BACK_STIFF)) {
add(scores, BodyDimension.SPINAL_MOBILITY, 12)
evidence.push({ dimension: BodyDimension.SPINAL_MOBILITY, title: '坐久后背部发僵', answers: [AFTER_SITTING_LABELS[AfterSitting.BACK_STIFF]], explanation: '上背部参与变少时,挺胸或抬臂会更容易觉得受限。', weight: 12 })
}
if (after.includes(AfterSitting.HIP_TIGHT)) {
add(scores, BodyDimension.HIP_PELVIS, 12)
add(scores, BodyDimension.LOWER_LIMB, 6)
evidence.push({ dimension: BodyDimension.HIP_PELVIS, title: '久坐后臀腿发紧', answers: [AFTER_SITTING_LABELS[AfterSitting.HIP_TIGHT]], explanation: '髋部活动变少后,下肢和腰会更容易代偿。', weight: 12 })
}
const standing = answers.standingNotice.filter((item) => item !== StandingNotice.UNNOTICED)
if (standing.includes(StandingNotice.HEAD_FORWARD)) {
add(scores, BodyDimension.CERVICAL_SHOULDER, 10)
evidence.push({ dimension: BodyDimension.CERVICAL_SHOULDER, title: '自然站立时头容易往前', answers: [STANDING_NOTICE_LABELS[StandingNotice.HEAD_FORWARD]], explanation: '头部位置前移会持续增加肩颈负担。', weight: 10 })
}
if (standing.includes(StandingNotice.ROUNDED_SHOULDERS)) {
add(scores, BodyDimension.CERVICAL_SHOULDER, 10)
add(scores, BodyDimension.SPINAL_MOBILITY, 6)
evidence.push({ dimension: BodyDimension.CERVICAL_SHOULDER, title: '肩膀容易向内', answers: [STANDING_NOTICE_LABELS[StandingNotice.ROUNDED_SHOULDERS]], explanation: '肩部向前时,上背部往往会越来越少参与活动。', weight: 10 })
}
if (standing.includes(StandingNotice.UNEVEN_SHOULDERS)) {
add(scores, BodyDimension.CERVICAL_SHOULDER, 6)
}
if (standing.includes(StandingNotice.BELLY_FORWARD)) {
add(scores, BodyDimension.CORE_CONTROL, 14)
add(scores, BodyDimension.HIP_PELVIS, 8)
evidence.push({ dimension: BodyDimension.CORE_CONTROL, title: '站立时腹部容易向前突出', answers: [STANDING_NOTICE_LABELS[StandingNotice.BELLY_FORWARD]], explanation: '这提示核心和骨盆可能没有一起参与站姿,而不是简单的“肚子大”。', weight: 14 })
}
if (standing.includes(StandingNotice.LOCKED_KNEES)) {
add(scores, BodyDimension.LOWER_LIMB, 12)
evidence.push({ dimension: BodyDimension.LOWER_LIMB, title: '站立时膝盖容易锁死', answers: [STANDING_NOTICE_LABELS[StandingNotice.LOCKED_KNEES]], explanation: '用膝盖锁死来站直,会让下肢稳定和髋部控制都变少。', weight: 12 })
}
const normalized: BodyPortraitScores = {
cervicalShoulder: clamp(scores.cervicalShoulder),
spinalMobility: clamp(scores.spinalMobility),
coreControl: clamp(scores.coreControl),
hipPelvis: clamp(scores.hipPelvis),
lowerLimb: clamp(scores.lowerLimb),
}
const levels: BodyPortraitLevels = {
cervicalShoulder: concernLevel(normalized.cervicalShoulder),
spinalMobility: concernLevel(normalized.spinalMobility),
coreControl: concernLevel(normalized.coreControl),
hipPelvis: concernLevel(normalized.hipPelvis),
lowerLimb: concernLevel(normalized.lowerLimb),
}
const { primary, secondary } = pickType(normalized, answers)
const flags = safetyFlagsOf(answers)
const safety: BodyPortraitSafetyResult = {
flagged: flags.length > 0,
flags,
message: flags.length
? '建议先由医生或相应专业人士确认运动条件,再进行训练。线上问卷不能判断伤病或康复进度。'
: null,
}
const rankedEvidence = evidence
.sort((a, b) => b.weight - a.weight)
.filter((item, index, arr) => arr.findIndex((other) => other.title === item.title) === index)
.slice(0, 3)
.map(({ dimension, title, answers: used, explanation }): BodyPortraitEvidence => ({ dimension, title, answers: used, explanation }))
const matchQuality = answeredCount(answers) >= 8 && rankedEvidence.length >= 2
? PortraitMatchQuality.FULL
: PortraitMatchQuality.PARTIAL
return {
questionnaireVersion: BODY_PORTRAIT_QUESTIONNAIRE_VERSION,
rulesVersion: BODY_PORTRAIT_RULES_VERSION,
scores: normalized,
levels,
primaryType: primary,
secondaryType: secondary,
headline: headlineFor(primary, secondary),
summary: summaryFor(answers, primary, safety),
evidence: rankedEvidence,
chain: chainFor(primary, answers),
advice: adviceFor(primary, safety.flagged),
matchQuality,
safety,
firstAssessmentHints: hintsFor(primary),
goalLabel: answers.goal ? PORTRAIT_GOAL_LABELS[answers.goal] : null,
}
}
export function toTeaser(assessmentId: string, report: BodyPortraitReport) {
const top = DIMENSIONS
.map((dimension) => [dimension, report.scores[dimension]] as const)
.sort((a, b) => b[1] - a[1])[0]
return {
assessmentId,
headline: report.headline,
primaryType: report.primaryType,
primaryTypeLabel: PORTRAIT_TYPE_LABELS[report.primaryType],
topDimension: top[1] > 0 ? top[0] : null,
topDimensionLabel: top[1] > 0 ? BODY_DIMENSION_LABELS[top[0]] : null,
matchQuality: report.matchQuality,
safetyFlagged: report.safety.flagged,
disclaimer: BODY_PORTRAIT_DISCLAIMER,
}
}
export { BODY_PORTRAIT_DISCLAIMER }

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

@@ -7,9 +7,10 @@ import { MembershipModule } from '../membership/membership.module'
import { StudioModule } from '../studio/studio.module'
import { UserModule } from '../user/user.module'
import { InviteModule } from '../invite/invite.module'
import { BodyPortraitModule } from '../body-portrait/body-portrait.module'
@Module({
imports: [MembershipModule, StudioModule, UserModule, InviteModule],
imports: [MembershipModule, StudioModule, UserModule, InviteModule, BodyPortraitModule],
controllers: [BookingController, ReviewController, PublicReviewController],
providers: [BookingService, ReviewService],
exports: [BookingService],

View File

@@ -4,6 +4,7 @@ import {
ForbiddenException,
Injectable,
NotFoundException,
Optional,
} from '@nestjs/common'
import { Booking, Membership, TimeSlot, BookingStatusHistory } from '@prisma/client'
import {
@@ -21,6 +22,7 @@ import { SubscriptionMessageService } from '../user/subscription-message.service
import { CreateBookingDto } from './dto/create-booking.dto'
import { AdminArrangeBookingDto } from './dto/admin-arrange-booking.dto'
import { InviteService } from '../invite/invite.service'
import { BodyPortraitLifecycleService } from '../body-portrait/body-portrait-lifecycle.service'
// ─── Types ─────────────────────────────────────────────────────────────────
@@ -64,6 +66,7 @@ export class BookingService {
private readonly studioService: StudioService,
private readonly subscriptionMessageService: SubscriptionMessageService,
private readonly inviteService: InviteService,
@Optional() private readonly portraitLifecycle?: BodyPortraitLifecycleService,
) {}
// ─── Create Booking ──────────────────────────────────────────────────────
@@ -125,6 +128,28 @@ export class BookingService {
throw new BadRequestException('Membership has expired')
}
let originAssessmentId: string | undefined
if (dto.originAssessmentId) {
const assessment = await tx.bodyPortraitAssessment.findUnique({
where: { id: dto.originAssessmentId },
select: { id: true, userId: true },
})
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('画像报告不属于当前用户')
}
}
}
// 5. Create booking or revive a previously cancelled booking.
const newBooking = existing?.status === BookingStatus.CANCELLED
? await tx.booking.update({
@@ -137,6 +162,7 @@ export class BookingService {
confirmedAt: null,
completedAt: null,
operatorId: null,
originAssessmentId: originAssessmentId || existing.originAssessmentId,
},
})
: await tx.booking.create({
@@ -146,6 +172,7 @@ export class BookingService {
membershipId: dto.membershipId,
membershipTimesDeducted: false,
status: BookingStatus.PENDING_CONFIRMATION,
originAssessmentId,
},
})
@@ -170,6 +197,12 @@ export class BookingService {
// Re-fetch with relations after transaction
const bookingWithRelations = await this.fetchBookingWithRelations(booking.id)
await this.trySendAdminBookingCreatedSubscriptionMessages(bookingWithRelations)
await this.portraitLifecycle?.onBookingCreated(
userId,
bookingWithRelations.id,
bookingWithRelations.membership.cardType.type,
bookingWithRelations.originAssessmentId,
)
return bookingWithRelations
}
@@ -484,6 +517,11 @@ export class BookingService {
const result = await this.fetchBookingWithRelations(booking.id)
if (toStatus === BookingStatus.COMPLETED) {
await this.inviteService.recordQualifiedTrialBooking(result.id)
await this.portraitLifecycle?.onBookingCompleted(
result.userId,
result.id,
result.membership.cardType.type,
)
}
return result
}

View File

@@ -1,4 +1,4 @@
import { IsUUID } from 'class-validator'
import { IsOptional, IsUUID } from 'class-validator'
export class CreateBookingDto {
@IsUUID()
@@ -6,4 +6,8 @@ export class CreateBookingDto {
@IsUUID()
membershipId!: string
@IsOptional()
@IsUUID()
originAssessmentId?: string
}

View File

@@ -5,9 +5,10 @@ import { PaymentController } from './payment.controller'
import { WechatPayService } from './wechat-pay.service'
import { InviteModule } from '../invite/invite.module'
import { MembershipModule } from '../membership/membership.module'
import { BodyPortraitModule } from '../body-portrait/body-portrait.module'
@Module({
imports: [PrismaModule, InviteModule, MembershipModule],
imports: [PrismaModule, InviteModule, MembershipModule, BodyPortraitModule],
controllers: [PaymentController],
providers: [PaymentService, WechatPayService],
exports: [PaymentService, WechatPayService],

View File

@@ -3,6 +3,7 @@ import {
Injectable,
Logger,
NotFoundException,
Optional,
} from '@nestjs/common'
import { CardType, Order } from '@prisma/client'
import { OrderStatus } from '@mp-pilates/shared'
@@ -10,6 +11,7 @@ import { PrismaService } from '../prisma/prisma.service'
import { WechatPayService, WxPaymentParams } from './wechat-pay.service'
import { InviteService } from '../invite/invite.service'
import { MembershipService } from '../membership/membership.service'
import { BodyPortraitLifecycleService } from '../body-portrait/body-portrait-lifecycle.service'
export interface CreateOrderResult {
order: Order
@@ -32,6 +34,7 @@ export class PaymentService {
private readonly wechatPayService: WechatPayService,
private readonly inviteService: InviteService,
private readonly membershipService: MembershipService,
@Optional() private readonly portraitLifecycle?: BodyPortraitLifecycleService,
) {}
// ─── User: create order ────────────────────────────────────────────────────
@@ -151,6 +154,7 @@ export class PaymentService {
})
await this.inviteService.recordTrialOrderPaid(existingOrder.id)
await this.portraitLifecycle?.onOrderPaid(existingOrder.userId, cardType.type, existingOrder.id)
this.logger.log(`Order PAID and membership granted: orderNo=${notification.orderNo}`)

View File

@@ -32,6 +32,17 @@ export class SchedulerService {
}
}
/** 02:35 daily — delete past CLOSED slots with no bookings attached */
@Cron('35 2 * * *')
async handlePrunePastClosedSlots(): Promise<void> {
try {
const count = await this.slotGenerator.prunePastClosedSlots()
this.logger.log(`[handlePrunePastClosedSlots] Pruned ${count} past closed slots`)
} catch (err) {
this.logger.error('[handlePrunePastClosedSlots] Failed to prune slots', err)
}
}
/** 03:00 daily — expire memberships past their end date or with 0 sessions */
@Cron('0 3 * * *')
async handleCheckMemberships(): Promise<void> {

View File

@@ -15,8 +15,10 @@ import {
const mockPrisma = {
timeSlot: {
findMany: jest.fn(),
createMany: jest.fn(),
updateMany: jest.fn(),
deleteMany: jest.fn(),
},
membership: {
updateMany: jest.fn(),
@@ -35,6 +37,8 @@ describe('SlotGeneratorService', () => {
beforeEach(async () => {
jest.clearAllMocks()
// Default: no dates are pre-touched by an admin. Individual tests override.
mockPrisma.timeSlot.findMany.mockResolvedValue([])
const module: TestingModule = await Test.createTestingModule({
providers: [
@@ -110,6 +114,78 @@ describe('SlotGeneratorService', () => {
})
})
it('skips dates that already have any TimeSlot row (admin-touched)', async () => {
const defaultSlots = getDefaultTimeSlots()
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
tomorrow.setUTCHours(0, 0, 0, 0)
// Pre-mark day 0 and day 2 as touched (any status).
const touchedDay0 = new Date(tomorrow)
const touchedDay2 = new Date(tomorrow)
touchedDay2.setDate(touchedDay2.getDate() + 2)
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([
{ date: touchedDay0 },
{ date: touchedDay2 },
])
// createMany mock reports whatever it would actually insert.
// With 1 day not touched (13 default slots), that's defaultSlots.length.
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: defaultSlots.length })
const count = await service.generateSlots(3)
// Day 1 should be the only day generated (3 - 2 touched = 1).
expect(count).toBe(defaultSlots.length)
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: Array<{ date: Date }>
}
expect(data).toHaveLength(defaultSlots.length)
// All generated dates must be UTC midnights within the requested window.
const rangeEnd = new Date(tomorrow)
rangeEnd.setDate(rangeEnd.getDate() + 2)
rangeEnd.setUTCHours(23, 59, 59, 999)
for (const row of data) {
expect(row.date.getTime()).toBeGreaterThanOrEqual(tomorrow.getTime())
expect(row.date.getTime()).toBeLessThanOrEqual(rangeEnd.getTime())
}
// The two touched dates must not appear in the generated batch.
const generatedKeys = new Set(data.map((r) => r.date.toISOString().slice(0, 10)))
expect(generatedKeys.has(touchedDay0.toISOString().slice(0, 10))).toBe(false)
expect(generatedKeys.has(touchedDay2.toISOString().slice(0, 10))).toBe(false)
})
it('returns 0 and skips createMany when every date in the window is already touched', async () => {
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
tomorrow.setUTCHours(0, 0, 0, 0)
const allDays = [0, 1, 2].map((offset) => {
const d = new Date(tomorrow)
d.setDate(d.getDate() + offset)
return { date: d }
})
mockPrisma.timeSlot.findMany.mockResolvedValueOnce(allDays)
const count = await service.generateSlots(3)
expect(count).toBe(0)
expect(mockPrisma.timeSlot.createMany).not.toHaveBeenCalled()
})
it('still uses skipDuplicates on createMany when it does run', async () => {
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 13 })
await service.generateSlots(1)
const call = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
skipDuplicates: boolean
}
expect(call.skipDuplicates).toBe(true)
})
// -------------------------------------------------------------------------
// cleanupExpiredSlots
// -------------------------------------------------------------------------
@@ -148,6 +224,53 @@ describe('SlotGeneratorService', () => {
})
})
// -------------------------------------------------------------------------
// prunePastClosedSlots
// -------------------------------------------------------------------------
describe('prunePastClosedSlots', () => {
it('deletes past CLOSED slots that have no bookings', async () => {
mockPrisma.timeSlot.deleteMany.mockResolvedValueOnce({ count: 4 })
const count = await service.prunePastClosedSlots()
expect(count).toBe(4)
expect(mockPrisma.timeSlot.deleteMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
status: TimeSlotStatus.CLOSED,
}),
}),
)
})
it('only deletes slots with date strictly before today', async () => {
mockPrisma.timeSlot.deleteMany.mockResolvedValueOnce({ count: 0 })
await service.prunePastClosedSlots()
const where = (mockPrisma.timeSlot.deleteMany.mock.calls[0][0] as {
where: { date: { lt: Date } }
}).where
const today = new Date()
today.setUTCHours(0, 0, 0, 0)
const diff = Math.abs(where.date.lt.getTime() - today.getTime())
expect(diff).toBeLessThan(1000)
})
it('keeps CLOSED slots that still have bookings attached', async () => {
mockPrisma.timeSlot.deleteMany.mockResolvedValueOnce({ count: 0 })
await service.prunePastClosedSlots()
const where = (mockPrisma.timeSlot.deleteMany.mock.calls[0][0] as {
where: { bookings: { none: Record<string, never> } }
}).where
expect(where.bookings).toEqual({ none: {} })
})
})
// -------------------------------------------------------------------------
// checkExpiredMemberships
// -------------------------------------------------------------------------

View File

@@ -39,6 +39,8 @@ const mockPrisma = {
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
createMany: jest.fn(),
delete: jest.fn(),
},
weekTemplate: {
findMany: jest.fn(),
@@ -48,6 +50,27 @@ const mockPrisma = {
$transaction: jest.fn(),
}
/**
* Forward $transaction's callback to a tx object that shares the timeSlot
* mock implementations, so transactional calls are observable the same way
* as top-level ones.
*/
function bindTransaction() {
const mockTx = {
timeSlot: {
findMany: mockPrisma.timeSlot.findMany,
findUnique: mockPrisma.timeSlot.findUnique,
create: mockPrisma.timeSlot.create,
update: mockPrisma.timeSlot.update,
createMany: mockPrisma.timeSlot.createMany,
delete: mockPrisma.timeSlot.delete,
},
}
mockPrisma.$transaction.mockImplementation(
async (cb: (tx: typeof mockTx) => unknown) => cb(mockTx),
)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -57,6 +80,7 @@ describe('TimeSlotService', () => {
beforeEach(async () => {
jest.clearAllMocks()
bindTransaction()
const module: TestingModule = await Test.createTestingModule({
providers: [
@@ -207,6 +231,69 @@ describe('TimeSlotService', () => {
})
})
// -------------------------------------------------------------------------
// getSchedulePreview
// -------------------------------------------------------------------------
describe('getSchedulePreview', () => {
const date = '2026-04-07'
it('returns OPEN/FULL rows with isPublished: true', async () => {
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([
makeSlot({ id: 'slot-1', startTime: '09:00', endTime: '10:00', status: TimeSlotStatus.OPEN }),
makeSlot({ id: 'slot-2', startTime: '10:30', endTime: '11:30', status: TimeSlotStatus.FULL }),
])
const result = await service.getSchedulePreview(date)
expect(result).toHaveLength(2)
expect(result.every((s) => s.isPublished === true)).toBe(true)
})
it('HIDES CLOSED rows so the legacy client does not render them as "已发布"', async () => {
// Simulates an admin "clear day" — DB now has 13 CLOSED rows.
mockPrisma.timeSlot.findMany.mockResolvedValueOnce(
Array.from({ length: 13 }, (_, i) =>
makeSlot({
id: `slot-${i}`,
startTime: `${String(8 + Math.floor(i / 2)).padStart(2, '0')}:${i % 2 === 0 ? '00' : '30'}`,
endTime: `${String(8 + Math.floor(i / 2)).padStart(2, '0')}:${i % 2 === 0 ? '30' : '30'}`,
status: TimeSlotStatus.CLOSED,
}),
),
)
const result = await service.getSchedulePreview(date)
// Critical for online compatibility: the legacy client must see []
// so it shows "当日暂无排课", not 13 phantom "已发布" slots.
expect(result).toEqual([])
})
it('returns the OPEN subset and hides CLOSED when mixed', async () => {
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([
makeSlot({ id: 'open-1', status: TimeSlotStatus.OPEN }),
makeSlot({ id: 'closed-1', status: TimeSlotStatus.CLOSED, startTime: '10:00', endTime: '11:00' }),
makeSlot({ id: 'open-2', status: TimeSlotStatus.OPEN, startTime: '10:30', endTime: '11:30' }),
])
const result = await service.getSchedulePreview(date)
expect(result).toHaveLength(2)
expect(result.map((s) => s.id).sort()).toEqual(['open-1', 'open-2'])
expect(result.find((s) => s.id === 'closed-1')).toBeUndefined()
})
it('returns the ghost template when the day has no rows at all', async () => {
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([])
const result = await service.getSchedulePreview(date)
// Ghost = isPublished false, id null.
expect(result.length).toBeGreaterThan(0)
expect(result.every((s) => s.isPublished === false && s.id === null)).toBe(true)
})
})
// -------------------------------------------------------------------------
// createManualSlot
// -------------------------------------------------------------------------
@@ -270,4 +357,173 @@ describe('TimeSlotService', () => {
await expect(service.closeSlot('ghost')).rejects.toThrow(NotFoundException)
})
})
// -------------------------------------------------------------------------
// publishDaySlots — admin schedule management
// -------------------------------------------------------------------------
describe('publishDaySlots', () => {
const date = '2026-04-07'
it('materializes the default template when the day has no rows yet', async () => {
// First findMany (existing check) → empty; second findMany (re-read) → materialized rows.
const materialized = [
makeSlot({ id: 'slot-A', startTime: '08:00', endTime: '09:00', source: TimeSlotSource.TEMPLATE }),
makeSlot({ id: 'slot-B', startTime: '09:30', endTime: '10:30', source: TimeSlotSource.TEMPLATE }),
]
mockPrisma.timeSlot.findMany
.mockResolvedValueOnce([]) // initial check
.mockResolvedValueOnce(materialized) // after materialize
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 2 })
mockPrisma.timeSlot.findMany.mockResolvedValueOnce(materialized) // final state
await service.publishDaySlots({ date, slots: [] })
expect(mockPrisma.timeSlot.createMany).toHaveBeenCalledTimes(1)
const createCall = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: Array<{ source: string; status: string; date: Date }>
skipDuplicates: boolean
}
expect(createCall.skipDuplicates).toBe(true)
for (const row of createCall.data) {
expect(row.source).toBe(TimeSlotSource.TEMPLATE)
expect(row.status).toBe(TimeSlotStatus.OPEN)
}
})
it('CLOSEs (never deletes) orphaned rows when admin publishes empty list', async () => {
const existing = [
makeSlot({ id: 'slot-1', bookedCount: 0 }),
makeSlot({ id: 'slot-2', bookedCount: 0, startTime: '09:30', endTime: '10:30' }),
]
// Initial check + final-state read both return the (now-CLOSED) rows.
mockPrisma.timeSlot.findMany
.mockResolvedValueOnce(existing)
.mockResolvedValueOnce(existing)
mockPrisma.timeSlot.update.mockResolvedValue({})
mockPrisma.timeSlot.delete = jest.fn() // safety: ensure delete is never used here
await service.publishDaySlots({ date, slots: [] })
expect(mockPrisma.timeSlot.delete).not.toHaveBeenCalled()
// Each orphan is updated to CLOSED (no bookedCount discrimination).
const updateCalls = mockPrisma.timeSlot.update.mock.calls
expect(updateCalls.length).toBe(2)
for (const call of updateCalls) {
const data = (call[0] as { data: { status: string } }).data
expect(data.status).toBe(TimeSlotStatus.CLOSED)
}
})
it('CLOSEs (never deletes) orphaned rows even when they have bookings', async () => {
const existing = [
makeSlot({ id: 'slot-1', bookedCount: 3 }), // has bookings
]
mockPrisma.timeSlot.findMany
.mockResolvedValueOnce(existing)
.mockResolvedValueOnce(existing)
mockPrisma.timeSlot.update.mockResolvedValue({})
mockPrisma.timeSlot.delete = jest.fn()
await service.publishDaySlots({ date, slots: [] })
expect(mockPrisma.timeSlot.delete).not.toHaveBeenCalled()
const updateCall = mockPrisma.timeSlot.update.mock.calls[0]
const data = (updateCall[0] as { data: { status: string } }).data
expect(data.status).toBe(TimeSlotStatus.CLOSED)
})
it('updates existing slot referenced by existingSlotId and keeps its capacity >= bookedCount', async () => {
const existing = [
makeSlot({ id: 'slot-1', bookedCount: 3, capacity: 5 }),
]
mockPrisma.timeSlot.findMany
.mockResolvedValueOnce(existing)
.mockResolvedValueOnce(existing)
const updated = makeSlot({ id: 'slot-1', bookedCount: 3, capacity: 4 })
mockPrisma.timeSlot.update.mockResolvedValueOnce(updated)
await service.publishDaySlots({
date,
slots: [{ existingSlotId: 'slot-1', startTime: '09:00', endTime: '10:00', capacity: 2 }],
})
const updateData = (mockPrisma.timeSlot.update.mock.calls[0][0] as {
where: { id: string }
data: { capacity: number; status?: string }
})
expect(updateData.where.id).toBe('slot-1')
// capacity clamped up to bookedCount (3), not the requested 2.
expect(updateData.data.capacity).toBe(3)
// status is NOT touched on update — existing CLOSED rows stay CLOSED
// so that re-publishing the day cannot silently undo "rest day" intent.
expect(updateData.data.status).toBeUndefined()
})
it('preserves CLOSED status when admin re-publishes the day unchanged', async () => {
// Scenario: admin cleared the day, then reloaded the page. The page
// re-sends all CLOSED rows via existingSlotId. They must remain CLOSED.
const existing = [
makeSlot({ id: 'slot-1', status: TimeSlotStatus.CLOSED, bookedCount: 0 }),
]
mockPrisma.timeSlot.findMany
.mockResolvedValueOnce(existing)
.mockResolvedValueOnce(existing)
mockPrisma.timeSlot.update.mockResolvedValueOnce(existing[0])
await service.publishDaySlots({
date,
slots: [{ existingSlotId: 'slot-1', startTime: '09:00', endTime: '10:00', capacity: 1 }],
})
const updateData = (mockPrisma.timeSlot.update.mock.calls[0][0] as {
data: { status?: string }
})
expect(updateData.data.status).toBeUndefined()
})
it('creates new slots for entries without an existingSlotId', async () => {
const existing: typeof makeSlot extends (...a: any) => infer R ? R : never = [] as never
mockPrisma.timeSlot.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 0 })
mockPrisma.timeSlot.create.mockResolvedValueOnce(
makeSlot({ id: 'slot-new', source: TimeSlotSource.MANUAL, startTime: '14:00', endTime: '15:00' }),
)
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([
makeSlot({ id: 'slot-new', source: TimeSlotSource.MANUAL, startTime: '14:00', endTime: '15:00' }),
])
await service.publishDaySlots({
date,
slots: [{ startTime: '14:00', endTime: '15:00', capacity: 4 }],
})
expect(mockPrisma.timeSlot.create).toHaveBeenCalledTimes(1)
const createData = (mockPrisma.timeSlot.create.mock.calls[0][0] as {
data: { source: string; status: string; capacity: number }
}).data
expect(createData.source).toBe(TimeSlotSource.MANUAL)
expect(createData.status).toBe(TimeSlotStatus.OPEN)
expect(createData.capacity).toBe(4)
})
it('returns the final state of the day ordered by startTime', async () => {
const finalState = [
makeSlot({ id: 'a', startTime: '09:00', endTime: '10:00' }),
makeSlot({ id: 'b', startTime: '10:30', endTime: '11:30' }),
]
mockPrisma.timeSlot.findMany
.mockResolvedValueOnce([]) // initial check
.mockResolvedValueOnce([]) // after materialize
.mockResolvedValueOnce(finalState) // final state
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 0 })
const result = await service.publishDaySlots({ date, slots: [] })
expect(result).toHaveLength(2)
expect(result[0].startTime).toBe('09:00')
expect(result[1].startTime).toBe('10:30')
})
})
})

View File

@@ -17,6 +17,18 @@ function toUtcMidnight(date: Date): Date {
return d
}
/** Add `days` whole days to a UTC date, returning a new Date. */
function addDays(date: Date, days: number): Date {
const d = new Date(date)
d.setUTCDate(d.getUTCDate() + days)
return d
}
/** Normalise a Date (UTC midnight) to its `YYYY-MM-DD` key for set membership. */
function toDateKey(date: Date): string {
return date.toISOString().slice(0, 10)
}
@Injectable()
export class SlotGeneratorService {
private readonly logger = new Logger(SlotGeneratorService.name)
@@ -26,7 +38,13 @@ export class SlotGeneratorService {
/**
* Generate time slots for the next `daysAhead` days based on the fixed
* default schedule (Mon-Sun: 08:00-09:00, then 09:30-21:30 hourly).
* Uses `createMany` with `skipDuplicates` so re-runs are safe.
*
* Behaviour:
* - Days that already have any TimeSlot row (any status, including CLOSED)
* are treated as "admin-touched" and skipped, so that an explicit "rest
* day" intent cannot be silently overwritten by the nightly cron.
* - The final `createMany` call still uses `skipDuplicates` so that
* re-runs against partially-existing dates remain safe.
*
* @returns Number of newly created slots
*/
@@ -37,6 +55,18 @@ export class SlotGeneratorService {
tomorrow.setDate(tomorrow.getDate() + 1)
tomorrow.setUTCHours(0, 0, 0, 0)
const rangeStart = toUtcMidnight(tomorrow)
const rangeEnd = toUtcMidnight(addDays(tomorrow, daysAhead - 1))
// Pre-fetch dates inside the window that already have at least one row.
// Any status (OPEN / FULL / CLOSED) counts as "admin touched this day".
const touchedRows = await this.prisma.timeSlot.findMany({
where: { date: { gte: rangeStart, lte: rangeEnd } },
select: { date: true },
distinct: ['date'],
})
const touchedKeys = new Set(touchedRows.map((r) => toDateKey(r.date)))
const slotsToCreate: Array<{
date: Date
startTime: string
@@ -46,12 +76,15 @@ export class SlotGeneratorService {
}> = []
for (let offset = 0; offset < daysAhead; offset++) {
const target = new Date(tomorrow)
target.setDate(target.getDate() + offset)
const target = toUtcMidnight(addDays(tomorrow, offset))
if (touchedKeys.has(toDateKey(target))) {
continue
}
for (const slot of defaultSlots) {
slotsToCreate.push({
date: toUtcMidnight(target),
date: target,
startTime: slot.startTime,
endTime: slot.endTime,
capacity: DEFAULT_SLOT_CAPACITY,
@@ -61,6 +94,9 @@ export class SlotGeneratorService {
}
if (slotsToCreate.length === 0) {
this.logger.log(
`Skipped ${touchedKeys.size} admin-touched date(s); nothing to generate`,
)
return 0
}
@@ -69,7 +105,9 @@ export class SlotGeneratorService {
skipDuplicates: true,
})
this.logger.log(`Generated ${result.count} new time slots`)
this.logger.log(
`Generated ${result.count} new time slots (skipped ${touchedKeys.size} admin-touched date(s))`,
)
return result.count
}
@@ -94,6 +132,34 @@ export class SlotGeneratorService {
return result.count
}
/**
* Delete past TimeSlot rows that are CLOSED and have no bookings attached.
*
* Without this, every admin "clear" leaves 13 CLOSED/TEMPLATE rows in the
* DB forever (no member ever queries past dates, but they pile up).
* Rows with `bookedCount > 0` or any Booking record are kept so we never
* break the Booking.timeSlotId foreign key.
*
* Runs nightly alongside cleanupExpiredSlots.
*
* @returns Number of slots deleted
*/
async prunePastClosedSlots(): Promise<number> {
const today = new Date()
today.setUTCHours(0, 0, 0, 0)
const result = await this.prisma.timeSlot.deleteMany({
where: {
status: TimeSlotStatus.CLOSED,
date: { lt: today },
bookings: { none: {} },
},
})
this.logger.log(`Pruned ${result.count} past closed time slots`)
return result.count
}
/**
* Expire memberships whose end date has passed or whose remaining sessions
* have been exhausted.

View File

@@ -148,13 +148,26 @@ export class TimeSlotService {
/**
* Return a schedule preview for a given date.
* If TimeSlot records already exist → return them (isPublished: true).
* Otherwise → derive from the fixed default schedule (isPublished: false).
*
* Visibility rules (driven by the online mini-program's existing client):
* - OPEN/FULL slots are returned with `isPublished: true`.
* - CLOSED slots are HIDDEN from preview entirely. The pre-fix admin flow
* used to DELETE these rows on "clear day", but a non-deleting flow
* leaves CLOSED rows behind — the existing mini-program UI does not
* understand the CLOSED status and would render them as "已发布",
* making it look like the clear did not take effect. Hiding them
* preserves the legacy "empty list = rest day" UX until the client
* is republished with CLOSED-aware rendering.
* - When the day is entirely CLOSED (a "rest day"), return an empty
* array rather than the default ghost template, so the client shows
* "当日暂无排课" instead of inviting another publish-loop cycle.
* - When the day has no rows at all (never generated), return the
* default template as ghost previews (`isPublished: false`).
*/
async getSchedulePreview(date: string): Promise<ScheduleSlotPreview[]> {
const parsedDate = new Date(date)
// 1. Check for existing TimeSlot records (all statuses)
// 1. Check for existing TimeSlot records (all statuses).
const existingSlots = await this.prisma.timeSlot.findMany({
where: {
date: { gte: this.toDateOfDay(parsedDate), lte: this.toEndOfDay(parsedDate) },
@@ -162,8 +175,14 @@ export class TimeSlotService {
orderBy: { startTime: 'asc' },
})
if (existingSlots.length > 0) {
return existingSlots.map((slot) => ({
// 2. Filter out CLOSED — they are admin-closed (typically "rest day")
// and must not surface to the legacy mini-program client.
const openSlots = existingSlots.filter(
(slot) => slot.status !== TimeSlotStatus.CLOSED,
)
if (openSlots.length > 0) {
return openSlots.map((slot) => ({
id: slot.id,
date: date,
startTime: slot.startTime,
@@ -177,6 +196,12 @@ export class TimeSlotService {
}))
}
// 3. All rows for the day are CLOSED → admin declared a rest day.
// Return [] so the client shows the "no slots" empty state.
if (existingSlots.length > 0) {
return []
}
// 2. No existing slots — use fixed default schedule
const defaultSlots = getDefaultTimeSlots()
@@ -195,19 +220,61 @@ export class TimeSlotService {
}
/**
* Publish (create/update/remove) time slots for a specific date.
* - Slots with existingSlotId → update
* - New slots → create
* - Existing DB slots not referenced → delete (or CLOSE if they have bookings)
* Publish (create/update/close) time slots for a specific date.
*
* Behavior:
* - If the day has no TimeSlot rows yet (e.g. admin opened a future date
* that the cron has not generated), materialize the default template
* first so that any subsequent close intent persists.
* - Slots referenced via existingSlotId → updated in place; existing
* status (OPEN / CLOSED) is preserved. Reopening a CLOSED slot is
* intentionally NOT possible through this endpoint — the front-end
* re-sends unchanged rows on every publish, so an explicit reopen
* would silently undo "rest day" intent. Use the dedicated reopen
* endpoint (TODO) or delete + re-add via the UI instead.
* - Slots with no existingSlotId → created as MANUAL/OPEN.
* - Existing rows that the request drops are set to CLOSED rather than
* deleted, so that the admin's intent ("this is a rest day") survives
* the nightly slot-generation cron (skipDuplicates / touched-date skip).
* - Materialize + orphan-close interaction: when the day has no rows yet,
* every default template slot is written first as OPEN/TEMPLATE, then
* any slot not referenced by the request is closed. So if an admin
* edits a ghost preview and publishes only a subset, the unused default
* slots end up CLOSED/TEMPLATE rather than OPEN/TEMPLATE.
*
* @returns Final TimeSlot rows for the day, ordered by startTime.
*/
async publishDaySlots(dto: PublishDaySlotsDto) {
const parsedDate = new Date(dto.date + 'T00:00:00Z')
return this.prisma.$transaction(async (tx) => {
// 1. Get existing slots for this date
const existing = await tx.timeSlot.findMany({
where: { date: { gte: this.toDateOfDay(parsedDate), lte: this.toEndOfDay(parsedDate) } },
})
const dayRange = {
date: { gte: this.toDateOfDay(parsedDate), lte: this.toEndOfDay(parsedDate) },
}
// 1. Look up the existing rows for the day.
let existing = await tx.timeSlot.findMany({ where: dayRange })
// 2. Materialize the default template if the admin is touching a day
// that has never been generated. This records their intent (the row
// now exists), and any empty publish will leave the day in a
// CLOSED-everything state that the nightly cron will not undo.
if (existing.length === 0) {
const defaultSlots = getDefaultTimeSlots()
await tx.timeSlot.createMany({
data: defaultSlots.map((slot) => ({
date: this.toDateOfDay(parsedDate),
startTime: slot.startTime,
endTime: slot.endTime,
capacity: DEFAULT_SLOT_CAPACITY,
source: TimeSlotSource.TEMPLATE,
status: TimeSlotStatus.OPEN,
})),
skipDuplicates: true,
})
existing = await tx.timeSlot.findMany({ where: dayRange })
}
const existingMap = new Map(existing.map((s) => [s.id, s]))
const keptIds = new Set<string>()
@@ -222,10 +289,10 @@ export class TimeSlotService {
source: string
}> = []
// 2. Process each slot in the request
// 3. Process each slot in the request.
for (const item of dto.slots) {
if (item.existingSlotId && existingMap.has(item.existingSlotId)) {
// Update existing slot
// Update existing slot. Never reduce capacity below bookedCount.
const existingSlot = existingMap.get(item.existingSlotId)!
const safeCapacity = Math.max(item.capacity, existingSlot.bookedCount)
@@ -235,12 +302,15 @@ export class TimeSlotService {
startTime: item.startTime,
endTime: item.endTime,
capacity: safeCapacity,
// Existing status is preserved: CLOSED rows stay CLOSED, so an
// admin's "rest day" intent cannot be undone by a no-op
// re-publish. Reopening must go through a dedicated endpoint.
},
})
keptIds.add(item.existingSlotId)
results.push(updated)
} else {
// Create new slot
// Create a new slot.
const created = await tx.timeSlot.create({
data: {
date: parsedDate,
@@ -255,22 +325,27 @@ export class TimeSlotService {
}
}
// 3. Handle orphaned existing slots (not in request)
// 4. Close orphaned existing rows. We never delete — keeping the row
// preserves the admin's "rest day" intent across cron runs.
for (const slot of existing) {
if (!keptIds.has(slot.id)) {
if (slot.bookedCount > 0) {
// Has bookings → close instead of delete
if (slot.status !== TimeSlotStatus.CLOSED) {
await tx.timeSlot.update({
where: { id: slot.id },
data: { status: TimeSlotStatus.CLOSED },
})
} else {
await tx.timeSlot.delete({ where: { id: slot.id } })
}
}
}
return results.map((slot) => ({
// 5. Return the final state of the day so the admin UI can refresh
// without a second round-trip.
const finalState = await tx.timeSlot.findMany({
where: dayRange,
orderBy: { startTime: 'asc' },
})
return finalState.map((slot) => ({
id: slot.id,
date: slot.date.toISOString().split('T')[0],
startTime: slot.startTime,

View File

@@ -62,3 +62,171 @@ export enum SubscriptionMessageScene {
CLASS_REMINDER = 'CLASS_REMINDER',
ADMIN_BOOKING_CREATED = 'ADMIN_BOOKING_CREATED',
}
// ===== Body portrait =====
export enum BodyPortraitSource {
XHS = 'xhs',
WECHAT = 'wechat',
DIANPING = 'dianping',
MEMBER_SHARE = 'member_share',
ORGANIC = 'organic',
OTHER = 'other',
}
export enum BodyPortraitAssessmentStatus {
DRAFT = 'DRAFT',
COMPLETED = 'COMPLETED',
CLAIMED = 'CLAIMED',
EXPIRED = 'EXPIRED',
}
export enum BodyRegion {
NECK = 'neck',
SHOULDER = 'shoulder',
UPPER_BACK = 'upperBack',
LOW_BACK = 'lowBack',
PELVIS = 'pelvis',
HIP = 'hip',
KNEE = 'knee',
LEG = 'leg',
}
export enum PortraitGoal {
POSTURE = 'posture',
NECK_RELIEF = 'neckRelief',
BACK_COMFORT = 'backComfort',
CORE = 'core',
SHAPING = 'shaping',
HIP_LEG = 'hipLeg',
STABILITY = 'stability',
OTHER = 'other',
}
export enum SittingHours {
LT4 = 'lt4',
H4_TO_6 = 'h4to6',
H6_TO_8 = 'h6to8',
GT8 = 'gt8',
}
export enum ExerciseFreq {
NONE = 'none',
W1_TO_2 = 'w1to2',
W3_TO_4 = 'w3to4',
W5_PLUS = 'w5plus',
}
export enum WorkPosture {
COMPUTER = 'computer',
PHONE = 'phone',
STANDING = 'standing',
WALKING = 'walking',
HOLDING_CHILD = 'holdingChild',
OTHER = 'other',
}
export enum AfterSitting {
NECK_TIGHT = 'neckTight',
LOW_BACK_ACHE = 'lowBackAche',
BACK_STIFF = 'backStiff',
HIP_TIGHT = 'hipTight',
NONE = 'none',
}
export enum StandingNotice {
HEAD_FORWARD = 'headForward',
ROUNDED_SHOULDERS = 'roundedShoulders',
UNEVEN_SHOULDERS = 'unevenShoulders',
BELLY_FORWARD = 'bellyForward',
LOCKED_KNEES = 'lockedKnees',
UNNOTICED = 'unnoticed',
}
export enum SafetyFlag {
PERSISTENT_PAIN = 'persistentPain',
RECENT_INJURY = 'recentInjury',
NUMBNESS = 'numbness',
DOCTOR_LIMIT = 'doctorLimit',
MEDICAL_RECOVERY = 'medicalRecovery',
NONE = 'none',
}
export enum BodyDimension {
CERVICAL_SHOULDER = 'cervicalShoulder',
SPINAL_MOBILITY = 'spinalMobility',
CORE_CONTROL = 'coreControl',
HIP_PELVIS = 'hipPelvis',
LOWER_LIMB = 'lowerLimb',
}
export enum ConcernLevel {
LOW = 'low',
MILD = 'mild',
MODERATE = 'moderate',
HIGH = 'high',
PRIORITY = 'priority',
}
export enum PortraitType {
SEDENTARY_NECK_TENSION = 'sedentaryNeckTension',
SEDENTARY_CORE_WEAK = 'sedentaryCoreWeak',
NECK_COMPENSATION = 'neckCompensation',
HIP_TIGHT = 'hipTight',
UPPER_LOWER_CROSS = 'upperLowerCross',
LOWER_LIMB_UNSTABLE = 'lowerLimbUnstable',
MIXED_FATIGUE = 'mixedFatigue',
LOW_OVERALL_CONCERN = 'lowOverallConcern',
}
export enum PortraitMatchQuality {
FULL = 'full',
PARTIAL = 'partial',
}
export enum GrowthLeadStage {
VISIT = 'VISIT',
STARTED = 'STARTED',
COMPLETED = 'COMPLETED',
CLAIMED = 'CLAIMED',
PHONE_BOUND = 'PHONE_BOUND',
TRIAL_PURCHASED = 'TRIAL_PURCHASED',
TRIAL_BOOKED = 'TRIAL_BOOKED',
TRIAL_ATTENDED = 'TRIAL_ATTENDED',
PLAN_PURCHASED = 'PLAN_PURCHASED',
TRAINING = 'TRAINING',
RENEWAL_DUE = 'RENEWAL_DUE',
RENEWED = 'RENEWED',
}
export enum GrowthEventName {
ASSESSMENT_STARTED = 'assessment_started',
ASSESSMENT_COMPLETED = 'assessment_completed',
ASSESSMENT_CLAIMED = 'assessment_claimed',
REPORT_VIEWED = 'report_viewed',
ADVICE_VIEWED = 'advice_viewed',
PHONE_BOUND = 'phone_bound',
TRIAL_CLICKED = 'trial_clicked',
TRIAL_PURCHASED = 'trial_purchased',
TRIAL_BOOKED = 'trial_booked',
TRIAL_ATTENDED = 'trial_attended',
PLAN_PURCHASED = 'plan_purchased',
REPORT_SHARED = 'report_shared',
}
export enum ProfessionalAssessmentKind {
INITIAL = 'INITIAL',
FOLLOW_UP = 'FOLLOW_UP',
}
export enum TrainingPlanStatus {
DRAFT = 'DRAFT',
ACTIVE = 'ACTIVE',
COMPLETED = 'COMPLETED',
}
export enum ProgressPhotoAngle {
FRONT = 'FRONT',
SIDE = 'SIDE',
BACK = 'BACK',
DETAIL = 'DETAIL',
}

View File

@@ -9,6 +9,25 @@ export {
OrderStatus,
InviteReferralStatus,
SubscriptionMessageScene,
BodyPortraitSource,
BodyPortraitAssessmentStatus,
BodyRegion,
PortraitGoal,
SittingHours,
ExerciseFreq,
WorkPosture,
AfterSitting,
StandingNotice,
SafetyFlag,
BodyDimension,
ConcernLevel,
PortraitType,
PortraitMatchQuality,
GrowthLeadStage,
GrowthEventName,
ProfessionalAssessmentKind,
TrainingPlanStatus,
ProgressPhotoAngle,
} from './enums'
// Constants
@@ -116,3 +135,4 @@ export type {
} from './types/index'
export * from './types/member-care'
export * from './types/body-portrait'

View File

@@ -0,0 +1,427 @@
import {
AfterSitting,
BodyDimension,
BodyPortraitAssessmentStatus,
BodyPortraitSource,
BodyRegion,
ConcernLevel,
ExerciseFreq,
GrowthEventName,
GrowthLeadStage,
PortraitGoal,
PortraitMatchQuality,
PortraitType,
ProfessionalAssessmentKind,
ProgressPhotoAngle,
SafetyFlag,
SittingHours,
StandingNotice,
TrainingPlanStatus,
WorkPosture,
} from '../enums'
export const BODY_PORTRAIT_QUESTIONNAIRE_VERSION = 'portrait-q-v1'
export const BODY_PORTRAIT_RULES_VERSION = 'portrait-r-v1'
export const BODY_PORTRAIT_OFFLINE_PROTOCOL = 'offline-v1'
export const BODY_PORTRAIT_DISCLAIMER =
'本评估不是医疗诊断或体态疾病检测,仅用于运动训练参考。若你正处于明显疼痛、伤病或医生限制运动的阶段,请先由医生或相应专业人士确认运动条件。'
export const BODY_REGION_LABELS: Record<BodyRegion, string> = {
[BodyRegion.NECK]: '头颈',
[BodyRegion.SHOULDER]: '肩',
[BodyRegion.UPPER_BACK]: '上背',
[BodyRegion.LOW_BACK]: '腰',
[BodyRegion.PELVIS]: '骨盆',
[BodyRegion.HIP]: '髋',
[BodyRegion.KNEE]: '膝',
[BodyRegion.LEG]: '腿',
}
export const PORTRAIT_GOAL_LABELS: Record<PortraitGoal, string> = {
[PortraitGoal.POSTURE]: '看起来更挺拔',
[PortraitGoal.NECK_RELIEF]: '肩颈没那么累',
[PortraitGoal.BACK_COMFORT]: '久坐更舒服',
[PortraitGoal.CORE]: '腹部更有力量',
[PortraitGoal.SHAPING]: '身体线条更好',
[PortraitGoal.HIP_LEG]: '腿型/髋部状态改善',
[PortraitGoal.STABILITY]: '动作更稳定',
[PortraitGoal.OTHER]: '我有自己的目标',
}
export const SITTING_HOURS_LABELS: Record<SittingHours, string> = {
[SittingHours.LT4]: '不到 4 小时',
[SittingHours.H4_TO_6]: '46 小时',
[SittingHours.H6_TO_8]: '68 小时',
[SittingHours.GT8]: '超过 8 小时',
}
export const EXERCISE_FREQ_LABELS: Record<ExerciseFreq, string> = {
[ExerciseFreq.NONE]: '几乎不运动',
[ExerciseFreq.W1_TO_2]: '12 次/周',
[ExerciseFreq.W3_TO_4]: '34 次/周',
[ExerciseFreq.W5_PLUS]: '5 次以上',
}
export const WORK_POSTURE_LABELS: Record<WorkPosture, string> = {
[WorkPosture.COMPUTER]: '电脑办公',
[WorkPosture.PHONE]: '低头手机',
[WorkPosture.STANDING]: '久站',
[WorkPosture.WALKING]: '频繁走动',
[WorkPosture.HOLDING_CHILD]: '抱娃',
[WorkPosture.OTHER]: '其他',
}
export const AFTER_SITTING_LABELS: Record<AfterSitting, string> = {
[AfterSitting.NECK_TIGHT]: '肩颈紧',
[AfterSitting.LOW_BACK_ACHE]: '腰酸',
[AfterSitting.BACK_STIFF]: '背部僵硬',
[AfterSitting.HIP_TIGHT]: '臀腿紧',
[AfterSitting.NONE]: '没明显感觉',
}
export const STANDING_NOTICE_LABELS: Record<StandingNotice, string> = {
[StandingNotice.HEAD_FORWARD]: '头容易往前',
[StandingNotice.ROUNDED_SHOULDERS]: '肩膀向内',
[StandingNotice.UNEVEN_SHOULDERS]: '左右肩不一样高',
[StandingNotice.BELLY_FORWARD]: '肚子容易往前顶',
[StandingNotice.LOCKED_KNEES]: '膝盖容易锁死',
[StandingNotice.UNNOTICED]: '没有注意过',
}
export const SAFETY_FLAG_LABELS: Record<SafetyFlag, string> = {
[SafetyFlag.PERSISTENT_PAIN]: '持续明显疼痛',
[SafetyFlag.RECENT_INJURY]: '最近受伤',
[SafetyFlag.NUMBNESS]: '手脚明显麻木',
[SafetyFlag.DOCTOR_LIMIT]: '医生要求限制运动',
[SafetyFlag.MEDICAL_RECOVERY]: '正处于需要专业医疗评估的恢复阶段',
[SafetyFlag.NONE]: '以上都没有',
}
export const BODY_DIMENSION_LABELS: Record<BodyDimension, string> = {
[BodyDimension.CERVICAL_SHOULDER]: '肩颈状态',
[BodyDimension.SPINAL_MOBILITY]: '脊柱活动',
[BodyDimension.CORE_CONTROL]: '核心控制',
[BodyDimension.HIP_PELVIS]: '髋骨盆状态',
[BodyDimension.LOWER_LIMB]: '下肢稳定',
}
export const CONCERN_LEVEL_LABELS: Record<ConcernLevel, string> = {
[ConcernLevel.LOW]: '低关注',
[ConcernLevel.MILD]: '轻度关注',
[ConcernLevel.MODERATE]: '中度关注',
[ConcernLevel.HIGH]: '高关注',
[ConcernLevel.PRIORITY]: '重点关注',
}
export const PORTRAIT_TYPE_LABELS: Record<PortraitType, string> = {
[PortraitType.SEDENTARY_NECK_TENSION]: '久坐肩颈紧张型',
[PortraitType.SEDENTARY_CORE_WEAK]: '久坐核心弱化型',
[PortraitType.NECK_COMPENSATION]: '肩颈代偿型',
[PortraitType.HIP_TIGHT]: '髋部紧张型',
[PortraitType.UPPER_LOWER_CROSS]: '上下交叉混合型',
[PortraitType.LOWER_LIMB_UNSTABLE]: '下肢稳定不足型',
[PortraitType.MIXED_FATIGUE]: '全身疲劳累积型',
[PortraitType.LOW_OVERALL_CONCERN]: '整体关注度较低型',
}
export const GROWTH_LEAD_STAGE_LABELS: Record<GrowthLeadStage, string> = {
[GrowthLeadStage.VISIT]: '访问',
[GrowthLeadStage.STARTED]: '开始测评',
[GrowthLeadStage.COMPLETED]: '完成报告',
[GrowthLeadStage.CLAIMED]: '已认领',
[GrowthLeadStage.PHONE_BOUND]: '已留联系方式',
[GrowthLeadStage.TRIAL_PURCHASED]: '已购体验',
[GrowthLeadStage.TRIAL_BOOKED]: '已预约',
[GrowthLeadStage.TRIAL_ATTENDED]: '已到店',
[GrowthLeadStage.PLAN_PURCHASED]: '已购买计划',
[GrowthLeadStage.TRAINING]: '训练中',
[GrowthLeadStage.RENEWAL_DUE]: '待续费',
[GrowthLeadStage.RENEWED]: '已续费',
}
export interface BodyPortraitAnswers {
readonly concerns: readonly BodyRegion[]
readonly goal: PortraitGoal | null
readonly sittingHours: SittingHours | null
readonly exerciseFreq: ExerciseFreq | null
readonly workPosture: WorkPosture | null
readonly endOfDayFatigue: readonly BodyRegion[]
readonly afterSitting: readonly AfterSitting[]
readonly standingNotice: readonly StandingNotice[]
readonly safety: readonly SafetyFlag[]
}
export interface BodyPortraitScores {
readonly cervicalShoulder: number
readonly spinalMobility: number
readonly coreControl: number
readonly hipPelvis: number
readonly lowerLimb: number
}
export interface BodyPortraitLevels {
readonly cervicalShoulder: ConcernLevel
readonly spinalMobility: ConcernLevel
readonly coreControl: ConcernLevel
readonly hipPelvis: ConcernLevel
readonly lowerLimb: ConcernLevel
}
export interface BodyPortraitEvidence {
readonly dimension: BodyDimension
readonly title: string
readonly answers: readonly string[]
readonly explanation: string
}
export interface BodyPortraitAdviceItem {
readonly title: string
readonly detail: string
readonly stopIf: string
}
export interface BodyPortraitAdvice {
readonly id: string
readonly items: readonly BodyPortraitAdviceItem[]
}
export interface BodyPortraitChain {
readonly steps: readonly string[]
readonly takeaway: string
}
export interface BodyPortraitSafetyResult {
readonly flagged: boolean
readonly flags: readonly SafetyFlag[]
readonly message: string | null
}
export interface BodyPortraitReport {
readonly questionnaireVersion: string
readonly rulesVersion: string
readonly scores: BodyPortraitScores
readonly levels: BodyPortraitLevels
readonly primaryType: PortraitType
readonly secondaryType: PortraitType | null
readonly headline: string
readonly summary: string
readonly evidence: readonly BodyPortraitEvidence[]
readonly chain: BodyPortraitChain
readonly advice: BodyPortraitAdvice | null
readonly matchQuality: PortraitMatchQuality
readonly safety: BodyPortraitSafetyResult
readonly firstAssessmentHints: readonly string[]
readonly goalLabel: string | null
}
export interface BodyPortraitTeaser {
readonly assessmentId: string
readonly headline: string
readonly primaryType: PortraitType
readonly primaryTypeLabel: string
readonly topDimension: BodyDimension | null
readonly topDimensionLabel: string | null
readonly matchQuality: PortraitMatchQuality
readonly safetyFlagged: boolean
readonly disclaimer: string
}
export interface BodyPortraitVisitResponse {
readonly visitId: string
readonly visitToken: string
readonly source: BodyPortraitSource
}
export interface BodyPortraitSessionResponse {
readonly assessmentId: string
readonly accessToken: string
readonly visitToken: string
readonly questionnaireVersion: string
readonly status: BodyPortraitAssessmentStatus
readonly answers: BodyPortraitAnswers
readonly teaser: BodyPortraitTeaser | null
readonly report: BodyPortraitReport | null
readonly claimed: boolean
}
export interface BodyPortraitStats {
readonly completedCount: number
}
export interface CreateBodyPortraitVisitDto {
readonly source?: BodyPortraitSource | string
readonly campaignId?: string
readonly referralCode?: string
readonly landingPath?: string
}
export interface SaveBodyPortraitAnswersDto {
readonly accessToken: string
readonly answers: Partial<BodyPortraitAnswers>
}
export interface BodyPortraitAccessDto {
readonly accessToken: string
}
export interface TrackGrowthEventDto {
readonly name: GrowthEventName
readonly accessToken?: string
readonly idempotencyKey?: string
}
export interface GrowthEventRecord {
readonly id: string
readonly name: GrowthEventName
readonly occurredAt: string
}
export interface GrowthLeadSummary {
readonly id: string
readonly userId: string
readonly nickname: string
readonly phone: string | null
readonly stage: GrowthLeadStage
readonly source: BodyPortraitSource | null
readonly campaignId: string | null
readonly primaryType: PortraitType | null
readonly topDimension: BodyDimension | null
readonly safetyFlagged: boolean
readonly lastActiveAt: string
readonly completedAt: string | null
readonly followUpDraft: string
}
export interface GrowthLeadDetail extends GrowthLeadSummary {
readonly goalLabel: string | null
readonly scores: BodyPortraitScores | null
readonly levels: BodyPortraitLevels | null
readonly evidence: readonly BodyPortraitEvidence[]
readonly answers: BodyPortraitAnswers | null
readonly report: BodyPortraitReport | null
readonly events: readonly GrowthEventRecord[]
readonly coachNote: string
readonly firstAssessmentHints: readonly string[]
}
export interface GrowthTodayTask {
readonly kind: 'completed_unbooked' | 'trial_unpurchased' | 'reassessment_due'
readonly leadId: string
readonly userId: string
readonly nickname: string
readonly title: string
readonly detail: string
readonly happenedAt: string
readonly followUpDraft: string
}
export interface GrowthFunnelRow {
readonly source: BodyPortraitSource
readonly started: number
readonly completed: number
readonly claimed: number
readonly booked: number
readonly attended: number
readonly purchased: number
}
export interface GrowthTodayDashboard {
readonly tasks: readonly GrowthTodayTask[]
readonly funnel: readonly GrowthFunnelRow[]
}
export interface ProfessionalObservationScores {
readonly headPosition: number
readonly shoulderPosition: number
readonly thoracicExtension: number
readonly shoulderFlexion: number
readonly breathing: number
readonly pelvis: number
readonly coreControl: number
readonly hipMobility: number
readonly singleLeg: number
}
export interface ProfessionalAssessmentSessionRecord {
readonly id: string
readonly userId: string
readonly kind: ProfessionalAssessmentKind
readonly protocolVersion: string
readonly recordedAt: string
readonly bookingId: string | null
readonly originAssessmentId: string | null
readonly observations: ProfessionalObservationScores
readonly subjectiveTension: number | null
readonly coachSummary: string
readonly trainingFocus: string
readonly phaseGoal: string
readonly photoAngle: ProgressPhotoAngle | null
}
export interface CreateProfessionalAssessmentDto {
readonly kind: ProfessionalAssessmentKind
readonly recordedAt: string
readonly bookingId?: string
readonly originAssessmentId?: string
readonly observations: ProfessionalObservationScores
readonly subjectiveTension?: number | null
readonly coachSummary: string
readonly trainingFocus: string
readonly phaseGoal: string
readonly photoAngle?: ProgressPhotoAngle | null
}
export interface TrainingPlanPhaseRecord {
readonly id: string
readonly name: string
readonly lessonStart: number
readonly lessonEnd: number
readonly focus: readonly string[]
readonly summary: string
}
export interface TrainingPlanRecord {
readonly id: string
readonly userId: string
readonly title: string
readonly status: TrainingPlanStatus
readonly weeks: number
readonly sessionId: string | null
readonly phases: readonly TrainingPlanPhaseRecord[]
}
export interface CreateTrainingPlanDto {
readonly sessionId?: string
readonly title?: string
readonly weeks?: number
}
export interface ReassessmentTodoRecord {
readonly id: string
readonly userId: string
readonly planId: string
readonly lessonCheckpoint: number
readonly dueAt: string
readonly completedAt: string | null
}
export interface GrowthComparisonRow {
readonly label: string
readonly first: string
readonly latest: string
}
export interface GrowthShareCardRecord {
readonly id: string
readonly shareCode: string
readonly title: string
readonly caption: string
readonly completedCount: number
readonly weeks: number
readonly rows: readonly GrowthComparisonRow[]
readonly includePhotos: boolean
}
export interface CreateGrowthShareCardDto {
readonly includePhotos?: boolean
}

View File

@@ -10,6 +10,7 @@ export interface Booking {
readonly confirmedAt: string | null
readonly completedAt: string | null
readonly operatorId: string | null
readonly originAssessmentId?: string | null
readonly createdAt: string
readonly updatedAt: string
}
@@ -69,6 +70,7 @@ export interface BookingStatusHistory {
export interface CreateBookingDto {
readonly timeSlotId: string
readonly membershipId: string
readonly originAssessmentId?: string
}
export interface AdminArrangeBookingDto {

View File

@@ -53,3 +53,4 @@ export type {
} from './invite'
export type { TeachingAnalytics, TeachingAnalyticsRecord, TeachingAnalyticsSummary } from './teaching-analytics'
export * from './body-portrait'

View File

@@ -16,7 +16,7 @@ export interface MemberNoteRecord {
booking: { timeSlot: { date: string; startTime: string } } | null
}
export interface ProgressPhotoRecord {
id: string; caption: string; recordedAt: string; consentedAt: string | null; revokedAt: string | null
id: string; caption: string; recordedAt: string; consentedAt: string | null; revokedAt: string | null; angle?: string | null; sessionId?: string | null
}
export interface ProgressArchive {
nickname: string; completedCount: number; milestones: number[]; metrics: BodyMetricRecord[]; notes: MemberNoteRecord[]; photos: ProgressPhotoRecord[]