feat: 支持会员卡合同在线签署

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
richarjiang
2026-10-28 12:24:22 +08:00
parent c04a9db627
commit ecea65f596
46 changed files with 4313 additions and 232 deletions

View File

@@ -0,0 +1,89 @@
-- AlterTable
ALTER TABLE `orders` ADD COLUMN `contract_accepted_at` DATETIME(3) NULL;
-- CreateTable
CREATE TABLE `contract_parties` (
`id` VARCHAR(191) NOT NULL,
`business_name` VARCHAR(100) NOT NULL DEFAULT '',
`brand_name` VARCHAR(60) NOT NULL DEFAULT '',
`credit_code` VARCHAR(18) NOT NULL DEFAULT '',
`operator_name` VARCHAR(30) NOT NULL DEFAULT '',
`phone` VARCHAR(20) NOT NULL DEFAULT '',
`address` VARCHAR(200) NOT NULL DEFAULT '',
`premises_type` VARCHAR(10) NOT NULL DEFAULT 'LEASED',
`lease_end_date` DATE NULL,
`standard_lesson_price` INTEGER NOT NULL DEFAULT 0,
`signature_image` MEDIUMBLOB NULL,
`signature_sha256` VARCHAR(64) NULL,
`signature_updated_at` DATETIME(3) NULL,
`updated_at` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `membership_contracts` (
`id` VARCHAR(191) NOT NULL,
`contract_no` VARCHAR(32) NOT NULL,
`user_id` VARCHAR(191) NOT NULL,
`membership_id` VARCHAR(191) NOT NULL,
`order_id` VARCHAR(191) NULL,
`source` ENUM('PURCHASE', 'LEGACY') NOT NULL,
`status` ENUM('PENDING_SIGN', 'SIGNED', 'VOIDED') NOT NULL DEFAULT 'PENDING_SIGN',
`require_before_booking` BOOLEAN NOT NULL DEFAULT false,
`template_version` VARCHAR(32) NOT NULL,
`terms` JSON NOT NULL,
`party_name` VARCHAR(30) NULL,
`party_phone` VARCHAR(20) NULL,
`document` JSON NULL,
`content_sha256` VARCHAR(64) NULL,
`signature_image` MEDIUMBLOB NULL,
`signature_sha256` VARCHAR(64) NULL,
`party_b_signature_image` MEDIUMBLOB NULL,
`party_b_name` VARCHAR(100) NULL,
`signed_at` DATETIME(3) NULL,
`signer_ip` VARCHAR(64) NULL,
`signer_user_agent` VARCHAR(300) NULL,
`signer_device` VARCHAR(200) NULL,
`voided_at` DATETIME(3) NULL,
`voided_by` VARCHAR(191) NULL,
`void_reason` VARCHAR(200) NULL,
`created_by` VARCHAR(191) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `membership_contracts_contract_no_key`(`contract_no`),
UNIQUE INDEX `membership_contracts_order_id_key`(`order_id`),
INDEX `membership_contracts_user_id_status_idx`(`user_id`, `status`),
INDEX `membership_contracts_membership_id_status_idx`(`membership_id`, `status`),
INDEX `membership_contracts_status_created_at_idx`(`status`, `created_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `contract_events` (
`id` VARCHAR(191) NOT NULL,
`contract_id` VARCHAR(191) NOT NULL,
`action` VARCHAR(20) NOT NULL,
`actor_id` VARCHAR(191) NULL,
`ip` VARCHAR(64) NULL,
`user_agent` VARCHAR(300) NULL,
`detail` JSON NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `contract_events_contract_id_created_at_idx`(`contract_id`, `created_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `membership_contracts` ADD CONSTRAINT `membership_contracts_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `membership_contracts` ADD CONSTRAINT `membership_contracts_membership_id_fkey` FOREIGN KEY (`membership_id`) REFERENCES `memberships`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `membership_contracts` ADD CONSTRAINT `membership_contracts_order_id_fkey` FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `contract_events` ADD CONSTRAINT `contract_events_contract_id_fkey` FOREIGN KEY (`contract_id`) REFERENCES `membership_contracts`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -58,6 +58,17 @@ enum InviteReferralStatus {
QUALIFIED
}
enum MembershipContractStatus {
PENDING_SIGN
SIGNED
VOIDED
}
enum MembershipContractSource {
PURCHASE
LEGACY
}
// ===== Models =====
model User {
@@ -95,6 +106,7 @@ model User {
trainingPlans TrainingPlan[]
reassessmentTodos ReassessmentTodo[]
growthShareCards GrowthShareCard[]
membershipContracts MembershipContract[]
@@map("users")
}
@@ -163,6 +175,7 @@ model Membership {
bookings Booking[]
orders Order[]
inviteRewardGrants InviteRewardGrant[]
contracts MembershipContract[]
@@index([userId])
@@index([userId, cardTypeId])
@@ -273,6 +286,7 @@ model Order {
status OrderStatus @default(PENDING)
wxTransactionId String? @map("wx_transaction_id")
paidAt DateTime? @map("paid_at")
contractAcceptedAt DateTime? @map("contract_accepted_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -280,6 +294,7 @@ model Order {
cardType CardType @relation(fields: [cardTypeId], references: [id])
membership Membership? @relation(fields: [membershipId], references: [id])
inviteReferrals InviteReferral[]
contract MembershipContract?
@@index([userId])
@@index([status])
@@ -287,6 +302,84 @@ model Order {
@@map("orders")
}
// Single-row party B profile; signed contracts keep their own snapshot.
model ContractParty {
id String @id @default(uuid())
businessName String @default("") @map("business_name") @db.VarChar(100)
brandName String @default("") @map("brand_name") @db.VarChar(60)
creditCode String @default("") @map("credit_code") @db.VarChar(18)
operatorName String @default("") @map("operator_name") @db.VarChar(30)
phone String @default("") @db.VarChar(20)
address String @default("") @db.VarChar(200)
premisesType String @default("LEASED") @map("premises_type") @db.VarChar(10)
leaseEndDate DateTime? @map("lease_end_date") @db.Date
standardLessonPrice Int @default(0) @map("standard_lesson_price")
signatureImage Bytes? @map("signature_image") @db.MediumBlob
signatureSha256 String? @map("signature_sha256") @db.VarChar(64)
signatureUpdatedAt DateTime? @map("signature_updated_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("contract_parties")
}
// terms is frozen at issuance; document, hashes and signatures are frozen at signing.
model MembershipContract {
id String @id @default(uuid())
contractNo String @unique @map("contract_no") @db.VarChar(32)
userId String @map("user_id")
membershipId String @map("membership_id")
orderId String? @unique @map("order_id")
source MembershipContractSource
status MembershipContractStatus @default(PENDING_SIGN)
requireBeforeBooking Boolean @default(false) @map("require_before_booking")
templateVersion String @map("template_version") @db.VarChar(32)
terms Json
partyName String? @map("party_name") @db.VarChar(30)
partyPhone String? @map("party_phone") @db.VarChar(20)
document Json?
contentSha256 String? @map("content_sha256") @db.VarChar(64)
signatureImage Bytes? @map("signature_image") @db.MediumBlob
signatureSha256 String? @map("signature_sha256") @db.VarChar(64)
partyBSignatureImage Bytes? @map("party_b_signature_image") @db.MediumBlob
partyBName String? @map("party_b_name") @db.VarChar(100)
signedAt DateTime? @map("signed_at")
signerIp String? @map("signer_ip") @db.VarChar(64)
signerUserAgent String? @map("signer_user_agent") @db.VarChar(300)
signerDevice String? @map("signer_device") @db.VarChar(200)
voidedAt DateTime? @map("voided_at")
voidedBy String? @map("voided_by")
voidReason String? @map("void_reason") @db.VarChar(200)
createdBy String? @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
membership Membership @relation(fields: [membershipId], references: [id])
order Order? @relation(fields: [orderId], references: [id])
events ContractEvent[]
@@index([userId, status])
@@index([membershipId, status])
@@index([status, createdAt])
@@map("membership_contracts")
}
model ContractEvent {
id String @id @default(uuid())
contractId String @map("contract_id")
action String @db.VarChar(20)
actorId String? @map("actor_id")
ip String? @db.VarChar(64)
userAgent String? @map("user_agent") @db.VarChar(300)
detail Json?
createdAt DateTime @default(now()) @map("created_at")
contract MembershipContract @relation(fields: [contractId], references: [id])
@@index([contractId, createdAt])
@@map("contract_events")
}
model InviteReferral {
id String @id @default(uuid())
inviterId String @map("inviter_id")