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")
}