diff --git a/docs/posture-assessment.md b/docs/posture-assessment.md
new file mode 100644
index 0000000..211eb5c
--- /dev/null
+++ b/docs/posture-assessment.md
@@ -0,0 +1,63 @@
+# 会员体测与体态检查表
+
+会员档案 → 成长档案 → 体测 → 新建记录 / 查看 / 编辑。会员自己的成长档案可查看完整记录;新增、修改和删除沿用馆主权限。
+
+## 纸质内容对应
+
+检查项目按用户提供的《体态分析指南 检查表》照片逐项录入。共同定义位于 `packages/shared/src/posture-assessment.ts`,前端与服务端共用,避免选项漂移。
+
+| 分区 | 内容 |
+| --- | --- |
+| 铅垂线 | 身体各部分与铅垂线相符;头部、胸部和骨盆排列对齐 |
+| 侧视图(两侧均需检查) | 踝关节、膝部、髋关节、骨盆、腰椎、下胸椎、上胸椎、颈椎、头部 |
+| 正面视图(两侧均需检查) | 足部、膝部、骨盆、肋骨架、肩部、头部;保留「如有必要,可参照后视图加以确认」提示 |
+| 后视图(两侧均需检查) | 足部、股骨、骨盆、肩胛骨、肱骨 |
+| 循序检查脊柱 | 从侧面观察是否有平直部位及位置;从背后观察并触碰检查是否有失衡部位及位置 |
+
+保留纸上每一行的右/左栏;无左右栏的项目按整体记录。左右始终指会员自身。不同解剖平面的观察可以并存,同一侧中立位与异常、同一平面的相反观察互斥。铅垂线和脊柱问题区分「是」「否」「未检查」,没有自动勾选正常项。脊柱选项从「是」改为「否」或未检查时会清空相应位置。
+
+## 数据与兼容性
+
+- 沿用 `body_metrics`、现有记录 ID、日期、体重、体脂率、腰围、臀围、柔韧度和备注,不删除或重建历史记录。
+- 仅新增可空的 `height DOUBLE NULL` 和 `assessment JSON NULL`。历史行无需回填,体态内容为空时显示原始测量,并允许打开详情、编辑和补充检查项。
+- `assessment` 使用 `version: 1`、检查行 ID、左右标记、铅垂线和脊柱观察保存。未选项目不推定为正常。
+- 所有数值字段及检查项目均可选。至少需要一项测量、检查结果或非空备注,防止误存完全空白的记录。
+- 保留 `POST /admin/members/:userId/progress/metrics`;旧客户端无需提交新增字段,仍可保存原有数据。
+- 新增 `GET .../metrics/:id`(馆主与会员各自的 progress 路径)和 `PUT /admin/members/:userId/progress/metrics/:id`。读取和修改均限定记录所属会员,会员端仅有读取权限。
+- PUT 为整份记录保存:未填写的数值写入 NULL、取消的检查项清空,修改原记录而非新增一条。服务端校验日期、数值范围、检查行、左右标记及互斥约束。
+
+## 发布顺序
+
+1. 使用现有发布流程备份数据库,并在服务端执行 `pnpm --filter @mp-pilates/server exec prisma migrate deploy`,应用 `20261018070000_posture_assessment`。这是仅新增可空列的迁移,原服务仍能使用原字段。
+2. 生成 Prisma Client、构建 shared/server 后发布服务端。
+3. 构建并发布小程序。旧版小程序在过渡期仍可通过原 POST 接口保存测量,新版可查看和编辑所有历史记录。
+
+没有新环境变量。本地开发不会自动连接生产数据库或执行生产迁移。
+
+## 验证重点
+
+已有会员的旧测量原值展示、旧版请求无需新字段、新增仅体态检查、空数值清除、编辑后不新增副本、左右侧独立、多平面并存、相反结果互斥、记录归属校验、未保存离开提醒、保存失败保留表单,以及返回档案后刷新。
+
+## 生产发布记录(2026-10-18)
+
+按用户授权,将体测功能与此前遗漏的商城「取消取货码、管理员直接完成交付」服务端改动一并发布。商城增加了旧版小程序响应兼容层,避免旧页面缺少取货码而无法提交交付。
+
+- 服务器:沿用 `/usr/local/web/mp-pilates-server`、`/usr/local/web/mp-pilates-shared` 和 PM2 `mp-pilates-server`,Node 22.16.0、Prisma 5.22.0;保留现有环境配置、支付证书和依赖。
+- 成功应用 `20260925111500_drop_product_order_pickup_code` 与 `20261018070000_posture_assessment`,生产数据库的 13 个迁移均已完成。
+- 采用短暂停服的同步切换,更新 server/shared 构建、schema 和原生 Prisma Client 后重启;PM2 正常在线,没有异常重启,内网和公网健康检查均为 200。
+- 对迁移前后记录按 ID 排序并校验完整字段摘要:3 条历史体测原值完全一致;3 条商品订单除被移除的取货码外,其余原值完全一致。历史体测新增字段为 NULL,且数据库列允许 NULL。
+- 13 项线上接口检查通过:体测档案和详情读取、会员归属隔离、商城订单列表的旧版兼容码、管理汇总、匿名访问限制、会员管理权限限制和非法分页校验。
+- 发布前完整服务端测试为 31 个套件、480 项全部通过;shared/server 构建与小程序类型检查通过。没有创建真实测试订单、扣款或修改历史体测来做验证。
+- 发布包 SHA-256:`ad993fe92dd06b43eb4f33963dd3a72642debb5e9c501880c953e05643d1dd4c`。
+
+备份和验证报告仅保存在服务器受限目录 `/usr/local/web/releases/posture-20261018-1792305938060/`:
+
+- `database-before.sql.gz`:完整逻辑备份,gzip 完整性检查通过。
+- `previous-build.tgz`、`prisma-client-before/`:旧运行文件、共享包、schema/迁移和生成的 Client。
+- `pickup-codes-at-switch.json`:切换时的旧取货码,供必要时精确恢复旧商城版本使用。
+- `data-baseline-at-switch.json`、`verification.json`:切换前的数据摘要及发布后的验证报告,不包含访问令牌。
+- `release.tgz`、`stage/`:本次发布包及解压后的暂存文件。
+
+回滚旧商城版本时,需要同时恢复旧 `pickup_code` 列及对应值、旧运行文件和 Prisma Client,并核对迁移元数据,不能仅重启旧构建。体测新增的可空列可保留。完整数据库恢复用于灾难恢复,正常回滚不得覆盖上线后产生的新数据。
+
+本次仅发布服务端。新的体测界面与商城发货界面仍需随小程序新版发布;已发布的小程序通过服务端兼容层继续使用。
diff --git a/docs/studio-shop.md b/docs/studio-shop.md
index 10d8d2c..c69f87a 100644
--- a/docs/studio-shop.md
+++ b/docs/studio-shop.md
@@ -40,6 +40,12 @@
状态:`PENDING → PAID → COMPLETED`;`PENDING → CANCELLED`。商品可下架但不删除,保证历史订单有效。
+### 已发布小程序的过渡兼容(2026-10-18)
+
+数据库删除 `pickup_code` 后,商城订单响应暂时保留可选的 `pickupCode` 字段,由订单 ID 派生为稳定的 12 位大写十六进制展示值。旧版小程序仍可展示、输入并提交这个值;新版小程序不使用它。它不落库,也不作为交付凭证或鉴权依据。完成订单仍必须通过馆主权限及 PAID 状态检查,新旧请求体均可接受。待旧版小程序退出使用后可移除此响应兼容层。
+
+此兼容层及删列迁移已于 2026-10-18 与体测服务一并上线。旧订单除取货码外的原始字段摘要保持一致,线上订单列表、兼容字段和权限验证通过;详见 [本次生产发布记录](posture-assessment.md#生产发布记录2026-10-18)。
+
## 接口
公开:`GET /shop/products`、`GET /shop/products/:id`。
diff --git a/packages/app/src/components/BodyAssessmentRecords.vue b/packages/app/src/components/BodyAssessmentRecords.vue
new file mode 100644
index 0000000..00ca103
--- /dev/null
+++ b/packages/app/src/components/BodyAssessmentRecords.vue
@@ -0,0 +1,37 @@
+
+
+ POSTURE JOURNAL体态与身体评估
+ 从每一个细节,慢慢了解身体。铅垂线 · 侧视 · 正面 · 后视 · 脊柱
+ +第一份身体观察,从这里开始可只记录体态检查,身高、体重等均为选填。
+
+ {{ record.recordedAt.slice(8, 10) }}{{ record.recordedAt.slice(0, 7).replace('-', ' / ') }}{{ record.assessment ? '体态分析指南检查表' : '身体刻度记录' }}查看 ›
+ {{ postureAnswerCount(record.assessment) }} 项检查已记录{{ completedViews(record) }} / 5 个检查分区
+ 历史体测记录原始数据已保留
+ {{ item.label }} {{ record[item.key] }} {{ item.unit }}
+
+
+
+
+
+
+
+
+
diff --git a/packages/app/src/components/CustomNavBar.vue b/packages/app/src/components/CustomNavBar.vue
index f79c247..5473495 100644
--- a/packages/app/src/components/CustomNavBar.vue
+++ b/packages/app/src/components/CustomNavBar.vue
@@ -23,12 +23,13 @@
diff --git a/packages/app/src/components/PostureChecklist.vue b/packages/app/src/components/PostureChecklist.vue
new file mode 100644
index 0000000..58b6a48
--- /dev/null
+++ b/packages/app/src/components/PostureChecklist.vue
@@ -0,0 +1,33 @@
+
+
+
+ {{ String(index + 1).padStart(2, '0') }}{{ group.title }}{{ groupCount(group) ? groupCount(group) + ' 项已记录' : '未检查' }}
+
+ {{ option.label }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/app/src/pages.json b/packages/app/src/pages.json
index 9414e9f..74ffeca 100644
--- a/packages/app/src/pages.json
+++ b/packages/app/src/pages.json
@@ -75,6 +75,12 @@
"navigationStyle": "custom"
}
},
+ {
+ "path": "pages/profile/body-assessment",
+ "style": {
+ "navigationStyle": "custom"
+ }
+ },
{
"path": "pages/profile/progress",
"style": {
diff --git a/packages/app/src/pages/profile/body-assessment.vue b/packages/app/src/pages/profile/body-assessment.vue
new file mode 100644
index 0000000..cd7c7b6
--- /dev/null
+++ b/packages/app/src/pages/profile/body-assessment.vue
@@ -0,0 +1,196 @@
+
+
+
+ 正在翻开检查表…
+ {{ loadError }}
+
+
+ POSTURE ASSESSMENT{{ editable ? '填写中 · 各项选填' : '评估记录' }}
+ 体态分析指南检查表
+ 会员姓名{{ nickname || '会员' }}评估日期{{ recordDate.replace(/-/g, ' / ') }} ⌄{{ recordDate.replace(/-/g, ' / ') }}
+ {{ answerCount }} 项检查已记录{{ editable ? '无需填满,可随时保存' : '未检查项目保留为空' }}
+
+
+
+
+
+
+
+
+ {{ steps[activeStep].english }}{{ steps[activeStep].title }}{{ String(activeStep + 1).padStart(2, '0') }} / 06
+
+
+ 身高、体重等均为选填。只记录本次实际测量的数据。
+ {{ field.label }}选填{{ measurements[field.key] || '—' }}{{ field.unit }}
+ 坐位体前屈:未触及脚尖可填写负值。
+ 评估备注 选填{{ remark || '暂无备注' }}{{ remark.length }} / 200
+
+
+
+ 依照纸质检查表,观察身体各部分的排列。未检查可留空。
+ 0{{ index + 1 }}{{ question.label }}{{ assessment.plumb[question.key] ? '' : '未检查' }}
+
+
+
+ 两侧均需检查{{ editable ? '按实际观察勾选;「右」「左」均指会员自身。再次轻触可取消。' : '「右」「左」均指会员自身;未勾选不代表已确认正常。' }}
+
+ * 如有必要,可参照后视图加以确认。
+
+
+
+ 依次从侧面和背后观察,记录检查结果与所在位置。
+ 0{{ index + 1 }}{{ question.view }}{{ question.label }}{{ assessment.spine[question.key] ? '' : '未检查' }}在什么位置?选填{{ assessment.spine[question.location] || '未记录位置' }}
+ 每一份观察,都是下一次练习的起点。{{ editable ? '检查表可以分次完成。保存后,可随时回到档案继续编辑。' : '这份记录已收录于会员成长档案。' }}
+
+ {{ saveError }}
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/server/prisma/migrations/20261018070000_posture_assessment/migration.sql b/packages/server/prisma/migrations/20261018070000_posture_assessment/migration.sql
new file mode 100644
index 0000000..920d11c
--- /dev/null
+++ b/packages/server/prisma/migrations/20261018070000_posture_assessment/migration.sql
@@ -0,0 +1,3 @@
+ALTER TABLE `body_metrics`
+ ADD COLUMN `height` DOUBLE NULL,
+ ADD COLUMN `assessment` JSON NULL;
diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma
index 19018db..1934d78 100644
--- a/packages/server/prisma/schema.prisma
+++ b/packages/server/prisma/schema.prisma
@@ -384,11 +384,13 @@ model BodyMetric {
id String @id @default(uuid())
userId String @map("user_id")
recordedAt DateTime @db.Date @map("recorded_at")
+ height Float?
weight Float?
bodyFat Float? @map("body_fat")
waist Float?
hip Float?
flexibility Float?
+ assessment Json?
remark String @default("") @db.VarChar(200)
operatorId String @map("operator_id")
createdAt DateTime @default(now()) @map("created_at")
diff --git a/packages/server/src/shop/__tests__/shop-compatibility.controller.spec.ts b/packages/server/src/shop/__tests__/shop-compatibility.controller.spec.ts
new file mode 100644
index 0000000..16d852a
--- /dev/null
+++ b/packages/server/src/shop/__tests__/shop-compatibility.controller.spec.ts
@@ -0,0 +1,44 @@
+import { INestApplication, UnauthorizedException, ValidationPipe } from '@nestjs/common'
+import { Test } from '@nestjs/testing'
+import type { ExecutionContext } from '@nestjs/common'
+import type { AddressInfo } from 'node:net'
+import { JwtAuthGuard } from '../../auth/jwt-auth.guard'
+import { RolesGuard } from '../../auth/roles.guard'
+import { ShopAdminController, ShopController } from '../shop.controller'
+import { ShopService } from '../shop.service'
+import { ShopOrderCompatibilityInterceptor } from '../shop-order-compatibility.interceptor'
+import { ApiResponseInterceptor } from '../../common/interceptors/api-response.interceptor'
+
+describe('Shop rollout HTTP compatibility', () => {
+ let app: INestApplication
+ const order = { id: 'order-1', orderNo: 'SP001', status: 'PAID' }
+ const service = { orders: jest.fn().mockResolvedValue({ items: [order], total: 1 }), complete: jest.fn().mockResolvedValue({ ...order, status: 'COMPLETED' }) }
+ async function request(method: string, path: string, role?: string, body?: unknown) {
+ const address = app.getHttpServer().address() as AddressInfo
+ return fetch(`http://127.0.0.1:${address.port}${path}`, { method, headers: { 'Content-Type': 'application/json', ...(role ? { 'x-test-role': role } : {}) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })
+ }
+ beforeAll(async () => {
+ const module = await Test.createTestingModule({ controllers: [ShopController, ShopAdminController], providers: [{ provide: ShopService, useValue: service }, RolesGuard, ShopOrderCompatibilityInterceptor] })
+ .overrideGuard(JwtAuthGuard).useValue({ canActivate(context: ExecutionContext) { const req = context.switchToHttp().getRequest(); if (!req.headers['x-test-role']) throw new UnauthorizedException(); req.user = { sub: 'test-operator', role: req.headers['x-test-role'] }; return true } }).compile()
+ app = module.createNestApplication()
+ app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
+ app.useGlobalInterceptors(new ApiResponseInterceptor())
+ await app.listen(0, '127.0.0.1')
+ })
+ afterAll(async () => { await app.close() })
+ it.each(['/shop/orders', '/admin/shop/orders'])('supplies the old display field inside the API envelope: %s', async path => {
+ const response = await request('GET', path, 'ADMIN')
+ expect(response.status).toBe(200)
+ expect((await response.json()).data.items[0].pickupCode).toMatch(/^[A-F0-9]{12}$/)
+ })
+ it.each([{}, { pickupCode: 'ABCDEF123456' }])('accepts new and already-published administrator request bodies: %j', async body => {
+ const response = await request('POST', '/admin/shop/orders/order-1/complete', 'ADMIN', body)
+ expect(response.status).toBe(201)
+ expect((await response.json()).data.status).toBe('COMPLETED')
+ expect(service.complete).toHaveBeenCalledWith('order-1', 'test-operator')
+ })
+ it.each([undefined, 'MEMBER'])('retains administrator authorization for fulfillment: %s', async role => {
+ const response = await request('POST', '/admin/shop/orders/order-1/complete', role, {})
+ expect(response.status).toBe(role ? 403 : 401)
+ })
+})
diff --git a/packages/server/src/shop/__tests__/shop-order-compatibility.interceptor.spec.ts b/packages/server/src/shop/__tests__/shop-order-compatibility.interceptor.spec.ts
new file mode 100644
index 0000000..697a5d9
--- /dev/null
+++ b/packages/server/src/shop/__tests__/shop-order-compatibility.interceptor.spec.ts
@@ -0,0 +1,25 @@
+import { CallHandler, ExecutionContext } from '@nestjs/common'
+import { firstValueFrom, of } from 'rxjs'
+import { ShopOrderCompatibilityInterceptor } from '../shop-order-compatibility.interceptor'
+
+describe('Published shop client compatibility', () => {
+ const interceptor = new ShopOrderCompatibilityInterceptor()
+ const serialize = (value: unknown) => firstValueFrom(interceptor.intercept({} as ExecutionContext, { handle: () => of(value) } as CallHandler))
+ it('returns a stable 12-character code for existing and new orders without changing the stored order', async () => {
+ const order = { id: 'order-1', orderNo: 'SP001', status: 'PAID', amount: 100 }
+ const result = await serialize(order) as Record
+ expect(result.pickupCode).toMatch(/^[A-F0-9]{12}$/)
+ expect(await serialize(order)).toEqual(result)
+ expect(order).not.toHaveProperty('pickupCode')
+ expect(result).toMatchObject(order)
+ expect((await serialize({ ...order, id: 'order-2' }) as Record).pickupCode).not.toBe(result.pickupCode)
+ })
+ it('preserves pagination while supplying codes to customer and administrator lists', async () => {
+ const result = await serialize({ items: [{ id: 'order-1', orderNo: 'SP001' }], total: 1, page: 2, limit: 20 }) as { items: { pickupCode: string }[] }
+ expect(result).toMatchObject({ total: 1, page: 2, limit: 20 })
+ expect(result.items[0].pickupCode).toMatch(/^[A-F0-9]{12}$/)
+ })
+ it.each([null, undefined, { id: 'product', title: '商品' }, { prepayId: 'wx123', paySign: 'signature' }, { orders: 2, amount: 300 }])('leaves non-order responses unchanged: %j', async value => {
+ expect(await serialize(value)).toBe(value)
+ })
+})
diff --git a/packages/server/src/shop/shop-order-compatibility.interceptor.ts b/packages/server/src/shop/shop-order-compatibility.interceptor.ts
new file mode 100644
index 0000000..c1bf41c
--- /dev/null
+++ b/packages/server/src/shop/shop-order-compatibility.interceptor.ts
@@ -0,0 +1,24 @@
+import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'
+import { createHash } from 'crypto'
+import { map, Observable } from 'rxjs'
+
+/** Keep already-published clients usable until their pickup-code UI is retired. */
+function withLegacyPickupCode(value: unknown): unknown {
+ if (!value || typeof value !== 'object') return value
+ if (Array.isArray(value)) return value.map(withLegacyPickupCode)
+ const record = value as Record
+ if (typeof record.id === 'string' && typeof record.orderNo === 'string') {
+ // Display compatibility only: fulfillment is authorized by admin role and PAID status.
+ // Derive a stable value without retaining the removed database column.
+ return { ...record, pickupCode: createHash('sha256').update('legacy-pickup:' + record.id).digest('hex').slice(0, 12).toUpperCase() }
+ }
+ if (Array.isArray(record.items)) return { ...record, items: record.items.map(withLegacyPickupCode) }
+ return value
+}
+
+@Injectable()
+export class ShopOrderCompatibilityInterceptor implements NestInterceptor {
+ intercept(_context: ExecutionContext, next: CallHandler): Observable {
+ return next.handle().pipe(map(withLegacyPickupCode))
+ }
+}
diff --git a/packages/server/src/shop/shop.controller.ts b/packages/server/src/shop/shop.controller.ts
index 457319f..7c68536 100644
--- a/packages/server/src/shop/shop.controller.ts
+++ b/packages/server/src/shop/shop.controller.ts
@@ -10,6 +10,7 @@ import {
Put,
Query,
UseGuards,
+ UseInterceptors,
} from '@nestjs/common'
import { UserRole } from '@mp-pilates/shared'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
@@ -18,8 +19,10 @@ import { Roles } from '../auth/roles.decorator'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { ShopService } from './shop.service'
import { BuyProductDto, ProductDto, ShopQueryDto, StockDto } from './dto/shop.dto'
+import { ShopOrderCompatibilityInterceptor } from './shop-order-compatibility.interceptor'
@Controller('shop')
+@UseInterceptors(ShopOrderCompatibilityInterceptor)
export class ShopController {
constructor(private readonly shop: ShopService) {}
@Post('wx-notify')
@@ -67,6 +70,7 @@ export class ShopController {
}
@Controller('admin/shop')
+@UseInterceptors(ShopOrderCompatibilityInterceptor)
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
export class ShopAdminController {
diff --git a/packages/server/src/shop/shop.module.ts b/packages/server/src/shop/shop.module.ts
index 804994d..320ba49 100644
--- a/packages/server/src/shop/shop.module.ts
+++ b/packages/server/src/shop/shop.module.ts
@@ -3,10 +3,11 @@ import { PrismaModule } from '../prisma/prisma.module'
import { WechatPayService } from '../payment/wechat-pay.service'
import { ShopController, ShopAdminController } from './shop.controller'
import { ShopService } from './shop.service'
+import { ShopOrderCompatibilityInterceptor } from './shop-order-compatibility.interceptor'
@Module({
imports: [PrismaModule],
controllers: [ShopController, ShopAdminController],
- providers: [ShopService, WechatPayService],
+ providers: [ShopService, WechatPayService, ShopOrderCompatibilityInterceptor],
exports: [ShopService],
})
export class ShopModule {}
diff --git a/packages/server/src/user/__tests__/member-progress.service.spec.ts b/packages/server/src/user/__tests__/member-progress.service.spec.ts
index 332035b..0e904a8 100644
--- a/packages/server/src/user/__tests__/member-progress.service.spec.ts
+++ b/packages/server/src/user/__tests__/member-progress.service.spec.ts
@@ -10,8 +10,9 @@ import { GUARDS_METADATA } from '@nestjs/common/constants'
import { MemberProgressController, AdminMemberProgressController } from '../member-progress.controller'
import { JwtAuthGuard } from '../../auth/jwt-auth.guard'
import { RolesGuard } from '../../auth/roles.guard'
+import { emptyPostureAssessment, POSTURE_SECTIONS, togglePostureMark, isPostureAssessment } from '@mp-pilates/shared'
describe('Member progress permissions and measurements', () => {
- const db = { user: { findUnique: jest.fn() }, booking: { count: jest.fn(), findFirst: jest.fn() }, lessonSupplement: { aggregate: jest.fn() }, bodyMetric: { findMany: jest.fn(), create: jest.fn(), deleteMany: jest.fn() }, memberNote: { findMany: jest.fn(), create: jest.fn(), deleteMany: jest.fn() }, progressPhoto: { findMany: jest.fn(), findFirst: jest.fn(), updateMany: jest.fn(), update: jest.fn(), create: jest.fn(), delete: jest.fn() } }
+ const db = { user: { findUnique: jest.fn() }, booking: { count: jest.fn(), findFirst: jest.fn() }, lessonSupplement: { aggregate: jest.fn() }, bodyMetric: { findMany: jest.fn(), findFirst: jest.fn(), create: jest.fn(), updateMany: jest.fn(), deleteMany: jest.fn() }, memberNote: { findMany: jest.fn(), create: jest.fn(), deleteMany: jest.fn() }, progressPhoto: { findMany: jest.fn(), findFirst: jest.fn(), updateMany: jest.fn(), update: jest.fn(), create: jest.fn(), delete: jest.fn() } }
const storage = { signedUrl: jest.fn(), credential: jest.fn(), verify: jest.fn(), removeObject: jest.fn() }
let service: MemberProgressService
beforeEach(() => { jest.resetAllMocks(); service = new MemberProgressService(db as unknown as PrismaService, storage as unknown as ProgressPhotoStorageService); db.user.findUnique.mockResolvedValue({ nickname: '学员' }); db.booking.count.mockResolvedValue(8); db.lessonSupplement.aggregate.mockResolvedValue({ _sum: { quantity: 3 } }); db.bodyMetric.findMany.mockResolvedValue([]); db.memberNote.findMany.mockResolvedValue([]); db.progressPhoto.findMany.mockResolvedValue([]) })
@@ -30,11 +31,71 @@ describe('Member progress permissions and measurements', () => {
await expect(service.addMetric('member', 'admin', { recordedAt: '2025-02-30', weight: 50 })).rejects.toThrow('日期')
await service.addMetric('member', 'admin', { recordedAt: '2025-01-01', flexibility: -3 })
expect(db.bodyMetric.create.mock.calls[0][0].data.flexibility).toBe(-3)
- expect(db.bodyMetric.create.mock.calls[0][0].data.weight).toBeUndefined()
+ expect(db.bodyMetric.create.mock.calls[0][0].data.weight).toBeNull()
})
it('rejects non-finite, out of range and nonnumeric measurements', async () => {
for (const weight of [0, -10, 501, Infinity, '50']) expect((await validate(Object.assign(new BodyMetricDto(), { recordedAt: '2025-01-01', weight }))).length).toBeGreaterThan(0)
})
+ it('continues to display historical measurements with no posture assessment', async () => {
+ const historical = { id: 'legacy', recordedAt: '2025-01-01', weight: 50, bodyFat: null, flexibility: -3, remark: '首次测量', height: null, assessment: null }
+ db.bodyMetric.findMany.mockResolvedValue([historical])
+ db.bodyMetric.findFirst.mockResolvedValue(historical)
+ expect((await service.archive('member')).metrics).toEqual([historical])
+ expect(await service.metric('member', 'legacy')).toEqual({ nickname: '学员', record: historical })
+ expect(db.bodyMetric.findFirst).toHaveBeenCalledWith({ where: { id: 'legacy', userId: 'member' } })
+ })
+ it('accepts legacy clients without new fields, posture-only records and notes-only records', async () => {
+ await service.addMetric('member', 'admin', { recordedAt: '2025-01-01', weight: 50 })
+ expect(db.bodyMetric.create.mock.calls[0][0].data).toMatchObject({ weight: 50, height: null, assessment: emptyPostureAssessment() })
+ const assessment = emptyPostureAssessment()
+ assessment.plumb.alignment = 'no'
+ await service.addMetric('member', 'admin', { recordedAt: '2025-01-01', assessment })
+ expect(db.bodyMetric.create.mock.calls[1][0].data).toMatchObject({ weight: null, assessment })
+ await service.addMetric('member', 'admin', { recordedAt: '2025-01-01', remark: '观察肩部排列' })
+ expect(db.bodyMetric.create.mock.calls[2][0].data.remark).toBe('观察肩部排列')
+ })
+ it('allows empty optional measurements, including explicit null when clearing an existing value', async () => {
+ expect(await validate(Object.assign(new BodyMetricDto(), { recordedAt: '2025-01-01', height: null, weight: null }))).toHaveLength(0)
+ for (const height of [0, 251, Infinity, '170']) expect((await validate(Object.assign(new BodyMetricDto(), { recordedAt: '2025-01-01', height }))).length).toBeGreaterThan(0)
+ })
+ it('updates the existing member record in place and clears removed measurements and findings', async () => {
+ const assessment = emptyPostureAssessment()
+ assessment.spine.flat = 'yes'; assessment.spine.flatLocation = '下胸椎'
+ db.bodyMetric.updateMany.mockResolvedValue({ count: 1 })
+ db.bodyMetric.findFirst.mockResolvedValue({ id: 'existing', assessment })
+ await service.updateMetric('member', 'existing', 'admin', { recordedAt: '2025-01-02', weight: null, assessment })
+ expect(db.bodyMetric.updateMany).toHaveBeenCalledWith({ where: { id: 'existing', userId: 'member' }, data: expect.objectContaining({ weight: null, height: null, assessment, operatorId: 'admin', recordedAt: new Date('2025-01-02T00:00:00.000Z') }) })
+ expect(db.bodyMetric.create).not.toHaveBeenCalled()
+ })
+ it('cannot read or edit another member record and rejects invalid edits before writing', async () => {
+ db.bodyMetric.findFirst.mockResolvedValue(null)
+ db.bodyMetric.updateMany.mockResolvedValue({ count: 0 })
+ await expect(service.metric('other', 'existing')).rejects.toThrow('不存在')
+ await expect(service.updateMetric('other', 'existing', 'admin', { recordedAt: '2025-01-01', weight: 50 })).rejects.toThrow('不存在')
+ expect(db.bodyMetric.updateMany.mock.calls[0][0].where).toEqual({ id: 'existing', userId: 'other' })
+ db.bodyMetric.updateMany.mockClear()
+ await expect(service.updateMetric('member', 'existing', 'admin', { recordedAt: '2025-02-30', weight: 50 })).rejects.toThrow('日期')
+ await expect(service.updateMetric('member', 'existing', 'admin', { recordedAt: '2025-01-01' })).rejects.toThrow('至少填写')
+ expect(db.bodyMetric.updateMany).not.toHaveBeenCalled()
+ })
+ it('rejects malformed, unknown, wrong-side and contradictory posture payloads', async () => {
+ const badPayloads = [
+ [], { ...emptyPostureAssessment(), version: 2 },
+ { ...emptyPostureAssessment(), answers: { unknown: ['right'] } },
+ { ...emptyPostureAssessment(), answers: { 'side.ankle.neutral': ['checked'] } },
+ { ...emptyPostureAssessment(), answers: { 'side.ankle.neutral': ['right', 'right'] } },
+ { ...emptyPostureAssessment(), answers: { 'side.ankle.neutral': ['right'], 'side.ankle.plantar': ['right'] } },
+ { ...emptyPostureAssessment(), plumb: { alignment: true, stacking: null } },
+ { ...emptyPostureAssessment(), spine: { flat: 'no', flatLocation: 'stale location', imbalance: null, imbalanceLocation: '' } },
+ { ...emptyPostureAssessment(), spine: { flat: 'yes', flatLocation: 'x'.repeat(201), imbalance: null, imbalanceLocation: '' } },
+ ]
+ for (const assessment of badPayloads) {
+ const dto = Object.assign(new BodyMetricDto(), { recordedAt: '2025-01-01', assessment })
+ expect((await validate(dto)).length).toBeGreaterThan(0)
+ await expect(service.addMetric('member', 'admin', dto)).rejects.toThrow('体态检查内容无效')
+ }
+ expect(db.bodyMetric.create).not.toHaveBeenCalled()
+ })
it('only annotates the target member completed booking', async () => {
db.booking.findFirst.mockResolvedValue(null)
await expect(service.addNote('member', 'admin', { content: 'note', shared: false, bookingId: 'foreign' })).rejects.toThrow('只能批注')
@@ -86,6 +147,42 @@ describe('Member progress permissions and measurements', () => {
})
})
+describe('Posture checklist selection behavior', () => {
+ const allGroups = POSTURE_SECTIONS.flatMap(section => section.groups)
+ it('keeps left and right independent while replacing contradictory findings on the same side', () => {
+ const group = allGroups.find(group => group.id === 'side.ankle')!
+ const [neutral, plantar, dorsal] = group.options
+ let answers = togglePostureMark({}, group, neutral, 'left')
+ answers = togglePostureMark(answers, group, plantar, 'right')
+ expect(answers).toEqual({ [neutral.id]: ['left'], [plantar.id]: ['right'] })
+ answers = togglePostureMark(answers, group, dorsal, 'right')
+ expect(answers).toEqual({ [neutral.id]: ['left'], [dorsal.id]: ['right'] })
+ answers = togglePostureMark(answers, group, dorsal, 'right')
+ expect(answers).toEqual({ [neutral.id]: ['left'] })
+ })
+ it('allows scapula findings in different planes and excludes neutral only on the affected side', () => {
+ const group = allGroups.find(group => group.id === 'back.scapula')!
+ const option = (id: string) => group.options.find(option => option.id === group.id + '.' + id)!
+ let answers = togglePostureMark({}, group, option('neutral'), 'left')
+ answers = togglePostureMark(answers, group, option('elevated'), 'right')
+ answers = togglePostureMark(answers, group, option('upward'), 'right')
+ answers = togglePostureMark(answers, group, option('winging'), 'right')
+ expect(Object.keys(answers)).toHaveLength(4)
+ expect(isPostureAssessment({ ...emptyPostureAssessment(), answers })).toBe(true)
+ answers = togglePostureMark(answers, group, option('neutral'), 'right')
+ expect(answers).toEqual({ [option('neutral').id]: ['left', 'right'] })
+ })
+ it('clears bilateral abnormalities when whole-body neutral is selected', () => {
+ const group = allGroups.find(group => group.id === 'front.head')!
+ const [clockwise, , neutral, tilt] = group.options
+ let answers = togglePostureMark({}, group, clockwise, 'checked')
+ answers = togglePostureMark(answers, group, tilt, 'right')
+ expect(isPostureAssessment({ ...emptyPostureAssessment(), answers })).toBe(true)
+ answers = togglePostureMark(answers, group, neutral, 'checked')
+ expect(answers).toEqual({ [neutral.id]: ['checked'] })
+ })
+})
+
describe('Member progress authorization', () => {
it('requires authentication on member and admin progress controllers', () => {
expect(Reflect.getMetadata(GUARDS_METADATA, MemberProgressController)).toContain(JwtAuthGuard)
@@ -99,4 +196,12 @@ describe('Member progress authorization', () => {
expect(guard.canActivate(context('MEMBER'))).toBe(false)
expect(guard.canActivate(context('ADMIN'))).toBe(true)
})
+ it('restricts record editing to admins and exposes no member write route', () => {
+ const handler = AdminMemberProgressController.prototype.updateMetric
+ const guard = new RolesGuard(new Reflector())
+ const context = (role: string) => ({ getHandler: () => handler, getClass: () => AdminMemberProgressController, switchToHttp: () => ({ getRequest: () => ({ user: { role } }) }) }) as unknown as ExecutionContext
+ expect(guard.canActivate(context('MEMBER'))).toBe(false)
+ expect(guard.canActivate(context('ADMIN'))).toBe(true)
+ expect('updateMetric' in MemberProgressController.prototype).toBe(false)
+ })
})
diff --git a/packages/server/src/user/dto/member-progress.dto.ts b/packages/server/src/user/dto/member-progress.dto.ts
index 1ca3dd7..7704363 100644
--- a/packages/server/src/user/dto/member-progress.dto.ts
+++ b/packages/server/src/user/dto/member-progress.dto.ts
@@ -1,11 +1,14 @@
-import { IsBoolean, IsDateString, IsNumber, IsOptional, IsString, Max, MaxLength, Min, MinLength, Matches } from 'class-validator'
+import { IsBoolean, IsDateString, IsNumber, IsOptional, IsString, Max, MaxLength, Min, MinLength, Matches, ValidateBy } from 'class-validator'
+import { isPostureAssessment, PostureAssessment } from '@mp-pilates/shared'
export class BodyMetricDto {
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/) recordedAt!: string
- @IsOptional() @IsNumber() @Min(1) @Max(500) weight?: number
- @IsOptional() @IsNumber() @Min(1) @Max(75) bodyFat?: number
- @IsOptional() @IsNumber() @Min(10) @Max(300) waist?: number
- @IsOptional() @IsNumber() @Min(10) @Max(300) hip?: number
- @IsOptional() @IsNumber() @Min(-50) @Max(100) flexibility?: number
+ @IsOptional() @IsNumber() @Min(30) @Max(250) height?: number | null
+ @IsOptional() @IsNumber() @Min(1) @Max(500) weight?: number | null
+ @IsOptional() @IsNumber() @Min(1) @Max(75) bodyFat?: number | null
+ @IsOptional() @IsNumber() @Min(10) @Max(300) waist?: number | null
+ @IsOptional() @IsNumber() @Min(10) @Max(300) hip?: number | null
+ @IsOptional() @IsNumber() @Min(-50) @Max(100) flexibility?: number | null
+ @IsOptional() @ValidateBy({ name: 'postureAssessment', validator: { validate: isPostureAssessment, defaultMessage: () => '体态检查内容无效,请检查选项及左右侧标记' } }) assessment?: PostureAssessment | null
@IsOptional() @IsString() @MaxLength(200) remark?: string
}
export class MemberNoteDto {
diff --git a/packages/server/src/user/member-progress.controller.ts b/packages/server/src/user/member-progress.controller.ts
index 5e50b42..fe9a809 100644
--- a/packages/server/src/user/member-progress.controller.ts
+++ b/packages/server/src/user/member-progress.controller.ts
@@ -1,4 +1,4 @@
-import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common'
+import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common'
import { UserRole } from '@mp-pilates/shared'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { RolesGuard } from '../auth/roles.guard'
@@ -10,6 +10,7 @@ import { BodyMetricDto, MemberNoteDto, PhotoConsentDto, ProgressPhotoDto } from
export class MemberProgressController {
constructor(private readonly service: MemberProgressService) {}
@Get() archive(@CurrentUser('sub') userId: string) { return this.service.archive(userId) }
+ @Get('metrics/:id') metric(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.metric(userId, id) }
@Post('photos/:id/consent') consent(@CurrentUser('sub') userId: string, @Param('id') id: string, @Body() dto: PhotoConsentDto) { return this.service.consent(userId, id, dto.consent) }
@Get('photos/:id/url') url(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.photoUrl(userId, id) }
@Delete('photos/:id') removePhoto(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.remove(userId, 'photos', id) }
@@ -19,6 +20,8 @@ export class AdminMemberProgressController {
constructor(private readonly service: MemberProgressService) {}
@Get() archive(@Param('userId') userId: string) { return this.service.archive(userId, true) }
@Post('metrics') metric(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: BodyMetricDto) { return this.service.addMetric(userId, operatorId, dto) }
+ @Get('metrics/:id') metricDetail(@Param('userId') userId: string, @Param('id') id: string) { return this.service.metric(userId, id) }
+ @Put('metrics/:id') updateMetric(@Param('userId') userId: string, @Param('id') id: string, @CurrentUser('sub') operatorId: string, @Body() dto: BodyMetricDto) { return this.service.updateMetric(userId, id, operatorId, dto) }
@Post('notes') note(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: MemberNoteDto) { return this.service.addNote(userId, operatorId, dto) }
@Delete(':kind/:id') remove(@Param('userId') userId: string, @Param('kind') kind: string, @Param('id') id: string) { return this.service.remove(userId, kind, id) }
@Post('photos/upload') upload(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: ProgressPhotoDto) { return this.service.upload(userId, operatorId, dto) }
diff --git a/packages/server/src/user/member-progress.service.ts b/packages/server/src/user/member-progress.service.ts
index 85fc0f7..deff5cf 100644
--- a/packages/server/src/user/member-progress.service.ts
+++ b/packages/server/src/user/member-progress.service.ts
@@ -2,6 +2,8 @@ import { BadRequestException, ForbiddenException, Injectable, NotFoundException
import { PrismaService } from '../prisma/prisma.service'
import { BodyMetricDto, MemberNoteDto, ProgressPhotoDto } from './dto/member-progress.dto'
import { ProgressPhotoStorageService } from './progress-photo-storage.service'
+import { BODY_MEASUREMENT_FIELDS, emptyPostureAssessment, isPostureAssessment, postureAnswerCount } from '@mp-pilates/shared'
+import { Prisma } from '@prisma/client'
@Injectable()
export class MemberProgressService {
constructor(private readonly prisma: PrismaService, private readonly storage: ProgressPhotoStorageService) {}
@@ -29,8 +31,26 @@ export class MemberProgressService {
}
async addMetric(userId: string, operatorId: string, dto: BodyMetricDto) {
await this.member(userId)
- if ([dto.weight, dto.bodyFat, dto.waist, dto.hip, dto.flexibility].every(v => v == null)) throw new BadRequestException('请至少填写一项体测数据')
- return this.prisma.bodyMetric.create({ data: { ...dto, userId, operatorId, recordedAt: this.date(dto.recordedAt) } })
+ return this.prisma.bodyMetric.create({ data: { ...this.metricData(dto), userId, operatorId } })
+ }
+ async metric(userId: string, id: string) {
+ const member = await this.member(userId)
+ const record = await this.prisma.bodyMetric.findFirst({ where: { id, userId } })
+ if (!record) throw new NotFoundException('体测记录不存在')
+ return { nickname: member.nickname, record }
+ }
+ async updateMetric(userId: string, id: string, operatorId: string, dto: BodyMetricDto) {
+ const data = this.metricData(dto)
+ const result = await this.prisma.bodyMetric.updateMany({ where: { id, userId }, data: { ...data, operatorId } })
+ if (!result.count) throw new NotFoundException('体测记录不存在')
+ return this.prisma.bodyMetric.findFirst({ where: { id, userId } })
+ }
+ private metricData(dto: BodyMetricDto) {
+ const assessment = dto.assessment ?? emptyPostureAssessment()
+ if (!isPostureAssessment(assessment)) throw new BadRequestException('体态检查内容无效')
+ const measurements = Object.fromEntries(BODY_MEASUREMENT_FIELDS.map(field => [field.key, dto[field.key] ?? null]))
+ if (BODY_MEASUREMENT_FIELDS.every(field => dto[field.key] == null) && !postureAnswerCount(assessment) && !dto.remark?.trim()) throw new BadRequestException('请至少填写一项体测数据、体态检查或评估备注')
+ return { ...measurements, recordedAt: this.date(dto.recordedAt), remark: dto.remark?.trim() || '', assessment: assessment as unknown as Prisma.InputJsonValue }
}
async addNote(userId: string, operatorId: string, dto: MemberNoteDto) {
await this.member(userId)
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 5abb097..920b492 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -137,6 +137,7 @@ export type {
} from './types/index'
export * from './types/member-care'
+export * from './posture-assessment'
export * from './types/body-portrait'
export * from './types/shop'
diff --git a/packages/shared/src/posture-assessment.ts b/packages/shared/src/posture-assessment.ts
new file mode 100644
index 0000000..de4c6a3
--- /dev/null
+++ b/packages/shared/src/posture-assessment.ts
@@ -0,0 +1,125 @@
+/** Transcribed from the member's photographed「体态分析指南 检查表」. */
+export type PostureMark = 'checked' | 'right' | 'left'
+export type AssessmentAnswer = 'yes' | 'no' | null
+export interface PostureAssessment {
+ version: 1
+ answers: Record
+ plumb: { alignment: AssessmentAnswer; stacking: AssessmentAnswer }
+ spine: { flat: AssessmentAnswer; flatLocation: string; imbalance: AssessmentAnswer; imbalanceLocation: string }
+}
+export interface PostureOption {
+ id: string
+ label: string
+ bilateral: boolean
+ neutral: boolean
+ axis: string
+}
+export interface PostureGroup { id: string; title: string; options: PostureOption[] }
+export interface PostureSection { id: string; title: string; subtitle: string; groups: PostureGroup[] }
+
+// The last two flags retain the paper's right/left columns and anatomical planes.
+type OptionInput = [id: string, label: string, bilateral?: boolean, neutral?: boolean, axis?: string]
+function group(id: string, title: string, rows: OptionInput[]): PostureGroup {
+ return { id, title, options: rows.map(([key, label, bilateral = false, neutral = false, axis = 'position']) => ({ id: `${id}.${key}`, label, bilateral, neutral, axis })) }
+}
+const bilateralPosition: OptionInput[] = [['neutral', '中立位', true, true], ['flexion', '屈曲', true], ['extension', '伸展', true]]
+
+export const POSTURE_PLUMB_QUESTIONS = [
+ { key: 'alignment' as const, label: '身体各部分是否与铅垂线相符(头部、上身、肩部、骨盆和膝部)' },
+ { key: 'stacking' as const, label: '头部、胸部和骨盆对于彼此是否为排列对齐?' },
+]
+export const POSTURE_SECTIONS: PostureSection[] = [
+ { id: 'side', title: '侧视图', subtitle: '两侧均需检查', groups: [
+ group('side.ankle', '踝关节', [['neutral', '中立位', true, true], ['plantar', '跖屈', true], ['dorsal', '背屈', true]]),
+ group('side.knee', '膝部', [['neutral', '中立位', true, true], ['hyperextension', '过度伸展', true], ['flexion', '屈曲', true]]),
+ group('side.hip', '髋关节', bilateralPosition),
+ group('side.pelvis', '骨盆', [['neutral', '中立位', true, true], ['anterior', '骨盆前倾', true], ['posterior', '骨盆后倾', true]]),
+ group('side.lumbar', '腰椎', [['neutral', '中立位', false, true], ['flat', '平直前凸曲度减小'], ['extension', '过度伸展前凸曲度增大']]),
+ group('side.lowerThoracic', '下胸椎', [['neutral', '中立位', false, true], ['flat', '平直后凸曲度减小'], ['flexion', '过度屈曲后凸曲度增大']]),
+ group('side.upperThoracic', '上胸椎', [['neutral', '中立位', false, true], ['flat', '平直后凸曲度减小'], ['flexion', '过度屈曲后凸曲度增大']]),
+ group('side.cervical', '颈椎', [['neutral', '中立位', false, true], ['flat', '平直前凸曲度减小'], ['extension', '过度伸展前凸曲度增大']]),
+ group('side.head', '头部', [['neutral', '中立位', false, true], ['forward', '前伸'], ['retracted', '后缩']]),
+ ] },
+ { id: 'front', title: '正面视图', subtitle: '两侧均需检查', groups: [
+ group('front.foot', '足部', [['neutral', '中立位', true, true], ['supination', '内翻/旋后', true], ['pronation', '外翻/旋前', true]]),
+ group('front.knee', '膝部', [['neutral', '中立位', false, true], ['valgus', 'X型腿膝外翻'], ['varus', 'O型腿膝内翻']]),
+ group('front.pelvis', '骨盆', [['level', '水平', true, true], ['elevated', '上提', true, false, 'height'], ['clockwise', '顺时针偏转', false, false, 'rotation'], ['counterclockwise', '逆时针偏转', false, false, 'rotation']]),
+ group('front.ribcage', '肋骨架', [['neutral', '中立位', false, true], ['elevated', '上提', true, false, 'height'], ['shift', '平移', true, false, 'shift'], ['clockwise', '顺时针偏转', false, false, 'rotation'], ['counterclockwise', '逆时针偏转', false, false, 'rotation']]),
+ group('front.shoulder', '肩部', [['level', '水平', false, true], ['elevated', '上提', true], ['depressed', '下沉', true]]),
+ group('front.head', '头部', [['clockwise', '顺时针偏转', false, false, 'rotation'], ['counterclockwise', '逆时针偏转', false, false, 'rotation'], ['neutral', '中立位', false, true], ['tilt', '倾斜', true, false, 'tilt'], ['shift', '平移', true, false, 'shift']]),
+ ] },
+ { id: 'back', title: '后视图', subtitle: '两侧均需检查', groups: [
+ group('back.foot', '足部', [['neutral', '中立位', true, true], ['supination', '内翻/旋后', true], ['pronation', '外翻/旋前', true]]),
+ group('back.femur', '股骨', [['neutral', '中立位', true, true], ['internal', '内旋', true], ['external', '外旋', true]]),
+ group('back.pelvis', '骨盆', [['level', '水平', false, true], ['elevated', '上提', true, false, 'height'], ['clockwise', '顺时针偏转', false, false, 'rotation'], ['counterclockwise', '逆时针偏转', false, false, 'rotation']]),
+ group('back.scapula', '肩胛骨', [['neutral', '中立位', true, true], ['protracted', '前引', true, false, 'translation'], ['retracted', '后缩', true, false, 'translation'], ['elevated', '上提', true, false, 'height'], ['depressed', '下沉', true, false, 'height'], ['upward', '上旋', true, false, 'rotation'], ['downward', '下旋', true, false, 'rotation'], ['winging', '外翻', true, false, 'winging'], ['anterior', '前倾', true, false, 'tilt']]),
+ group('back.humerus', '肱骨', [['neutral', '中立位', true, true], ['internal', '内旋', true]]),
+ ] },
+]
+
+export const BODY_MEASUREMENT_FIELDS = [
+ { key: 'height', label: '身高', unit: 'cm', min: 30, max: 250 },
+ { key: 'weight', label: '体重', unit: 'kg', min: 1, max: 500 },
+ { key: 'bodyFat', label: '体脂率', unit: '%', min: 1, max: 75 },
+ { key: 'waist', label: '腰围', unit: 'cm', min: 10, max: 300 },
+ { key: 'hip', label: '臀围', unit: 'cm', min: 10, max: 300 },
+ { key: 'flexibility', label: '坐位体前屈', unit: 'cm', min: -50, max: 100 },
+] as const
+export type BodyMeasurementKey = typeof BODY_MEASUREMENT_FIELDS[number]['key']
+
+export function emptyPostureAssessment(): PostureAssessment {
+ return { version: 1, answers: {}, plumb: { alignment: null, stacking: null }, spine: { flat: null, flatLocation: '', imbalance: null, imbalanceLocation: '' } }
+}
+
+/** Select per side; neutral and opposing findings in the same plane exclude each other. */
+export function togglePostureMark(answers: PostureAssessment['answers'], group: PostureGroup, option: PostureOption, mark: PostureMark): PostureAssessment['answers'] {
+ const next = Object.fromEntries(Object.entries(answers).map(([id, marks]) => [id, [...marks]]))
+ if (next[option.id]?.includes(mark)) {
+ next[option.id] = next[option.id].filter(value => value !== mark)
+ } else {
+ for (const other of group.options) {
+ if (other.id === option.id) continue
+ const conflicts = option.neutral || other.neutral || option.axis === other.axis
+ if (!conflicts) continue
+ // Whole-body rows affect both sides; otherwise preserve the opposite side.
+ next[other.id] = (next[other.id] || []).filter(value => option.bilateral && other.bilateral && value !== mark)
+ }
+ next[option.id] = [...(next[option.id] || []), mark]
+ }
+ return Object.fromEntries(Object.entries(next).filter(([, marks]) => marks.length))
+}
+
+const isObject = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value)
+const exactKeys = (value: Record, keys: string[]) => Object.keys(value).length === keys.length && keys.every(key => Object.prototype.hasOwnProperty.call(value, key))
+const isAnswer = (value: unknown) => value === null || value === 'yes' || value === 'no'
+export function isPostureAssessment(value: unknown): value is PostureAssessment {
+ if (!isObject(value) || !exactKeys(value, ['version', 'answers', 'plumb', 'spine']) || value.version !== 1 || !isObject(value.answers) || !isObject(value.plumb) || !isObject(value.spine)) return false
+ const { answers, plumb, spine } = value
+ if (!exactKeys(plumb, ['alignment', 'stacking']) || !Object.values(plumb).every(isAnswer)) return false
+ if (!exactKeys(spine, ['flat', 'flatLocation', 'imbalance', 'imbalanceLocation']) || !isAnswer(spine.flat) || !isAnswer(spine.imbalance)) return false
+ if (![spine.flatLocation, spine.imbalanceLocation].every(text => typeof text === 'string' && text.length <= 200)) return false
+ if ((spine.flat !== 'yes' && spine.flatLocation !== '') || (spine.imbalance !== 'yes' && spine.imbalanceLocation !== '')) return false
+ const groups = POSTURE_SECTIONS.flatMap(section => section.groups)
+ const options = groups.flatMap(group => group.options)
+ for (const [key, marks] of Object.entries(answers)) {
+ const option = options.find(option => option.id === key)
+ if (!option || !Array.isArray(marks) || !marks.length || new Set(marks).size !== marks.length || !marks.every(mark => option.bilateral ? mark === 'right' || mark === 'left' : mark === 'checked')) return false
+ }
+ // Reject contradictory submissions even when the client is bypassed.
+ for (const group of groups) {
+ const selected = group.options.filter(option => Array.isArray(answers[option.id]))
+ for (let i = 0; i < selected.length; i++) for (let j = i + 1; j < selected.length; j++) {
+ const a = selected[i], b = selected[j]
+ if (!(a.neutral || b.neutral || a.axis === b.axis)) continue
+ if (!a.bilateral || !b.bilateral || (answers[a.id] as string[]).some(mark => (answers[b.id] as string[]).includes(mark))) return false
+ }
+ }
+ return true
+}
+
+export function postureAnswerCount(assessment: PostureAssessment | null | undefined): number {
+ if (!assessment) return 0
+ return Object.values(assessment.answers).reduce((sum, marks) => sum + marks.length, 0)
+ + Object.values(assessment.plumb).filter(Boolean).length
+ + [assessment.spine.flat, assessment.spine.imbalance].filter(Boolean).length
+}
diff --git a/packages/shared/src/types/member-care.ts b/packages/shared/src/types/member-care.ts
index 507643a..5ec209f 100644
--- a/packages/shared/src/types/member-care.ts
+++ b/packages/shared/src/types/member-care.ts
@@ -9,7 +9,8 @@ export interface ReviewSummary {
month: string; count: number; average: number | null; nps: number | null; npsCount: number
}
export interface BodyMetricRecord {
- id: string; recordedAt: string; weight: number | null; bodyFat: number | null; waist: number | null; hip: number | null; flexibility: number | null; remark: string
+ id: string; recordedAt: string; height?: number | null; weight: number | null; bodyFat: number | null; waist: number | null; hip: number | null; flexibility: number | null; remark: string
+ assessment?: import('../posture-assessment').PostureAssessment | null
}
export interface MemberNoteRecord {
id: string; content: string; shared: boolean; createdAt: string; bookingId: string | null
diff --git a/packages/shared/src/types/shop.ts b/packages/shared/src/types/shop.ts
index df5d594..1a218e9 100644
--- a/packages/shared/src/types/shop.ts
+++ b/packages/shared/src/types/shop.ts
@@ -24,6 +24,8 @@ export interface ShopOrder {
contactName: string
contactPhone: string
status: ShopOrderStatus
+ /** @deprecated Response-only bridge for published clients; not used for authorization. */
+ pickupCode?: string
expiresAt: string
createdAt: string
paidAt: string | null