feat: 新增个人身体画像评估与馆主经营助手

把 3 分钟身体状态评估做成独立获客链路,匿名测评后登录认领完整报告,并接入体验预约与今日待办。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
richarjiang
2026-09-11 22:09:19 +08:00
parent b8c0dd6781
commit ab6602e41d
56 changed files with 4640 additions and 25 deletions

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,117 @@
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,
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)
})
})
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)
})
})

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,268 @@
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: draft,
})
}
}
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,209 @@
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 name = input.nickname || '你'
if (!input.report) {
return `看到你刚刚做了身体状态评估。线上只能做基础判断,如果方便的话,可以来工作室做一次实际的活动度和动作评估,我可以再帮你具体看看。`
}
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 `看到你刚刚做了身体状态评估,你目前比较明显的是${dimension}相关的关注点,画像更接近「${type}」。${lifestyle ? `如果平时${lifestyle}比较多,这种情况其实很常见。` : ''}线上只能做基础判断,如果方便的话,可以来工作室做一次实际的活动度和动作评估,我可以再帮你具体看看。`.replace(`${name}`, `${name}`)
}
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,280 @@
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) {
await this.prisma.reassessmentTodo.updateMany({
where: { userId, completedAt: null },
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 completedCount = await this.prisma.booking.count({ where: { userId, status: 'COMPLETED' } })
const plan = await this.prisma.trainingPlan.findFirst({ where: { userId }, orderBy: { createdAt: 'desc' } })
const rows = first && latest ? this.compare(first, latest) : []
const created = await this.prisma.growthShareCard.create({
data: {
userId,
planId: plan?.id,
shareCode: createShareCode(),
title: `我的第 ${completedCount || 1} 节普拉提`,
caption: first && latest && first.subjectiveTension != null && latest.subjectiveTension != null
? `${plan?.weeks || 12} 周前肩颈紧张 ${first.subjectiveTension}/10现在 ${latest.subjectiveTension}/10。坚持有时候真的看得见。`
: '坚持有时候真的看得见。',
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 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('日期无效')
if (date.getTime() > Date.now()) 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,21 @@
import { HttpException, HttpStatus } from '@nestjs/common'
interface Bucket {
count: number
resetAt: number
}
const buckets = new Map<string, Bucket>()
export function assertRateLimit(key: string, limit: number, windowMs = 60 * 60 * 1000) {
const now = Date.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,297 @@
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.expiresAt < new Date() && assessment.status === BodyPortraitAssessmentStatus.DRAFT) {
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)
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

@@ -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,18 @@ export class BookingService {
throw new BadRequestException('Membership has expired')
}
let originAssessmentId: string | undefined
if (dto.originAssessmentId) {
const owned = await tx.bodyPortraitAssessment.findFirst({
where: { id: dto.originAssessmentId, userId },
select: { id: true },
})
if (!owned) {
throw new ForbiddenException('画像报告不属于当前用户')
}
originAssessmentId = owned.id
}
// 5. Create booking or revive a previously cancelled booking.
const newBooking = existing?.status === BookingStatus.CANCELLED
? await tx.booking.update({
@@ -137,6 +152,7 @@ export class BookingService {
confirmedAt: null,
completedAt: null,
operatorId: null,
originAssessmentId: originAssessmentId || existing.originAssessmentId,
},
})
: await tx.booking.create({
@@ -146,6 +162,7 @@ export class BookingService {
membershipId: dto.membershipId,
membershipTimesDeducted: false,
status: BookingStatus.PENDING_CONFIRMATION,
originAssessmentId,
},
})
@@ -170,6 +187,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 +507,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}`)