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

@@ -14,6 +14,7 @@ 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'
import { ContractModule } from './contract/contract.module'
@Module({
imports: [
@@ -34,6 +35,7 @@ import { BodyPortraitModule } from './body-portrait/body-portrait.module'
AdminModule,
InviteModule,
BodyPortraitModule,
ContractModule,
],
controllers: [AppController],
})

View File

@@ -161,6 +161,9 @@ function buildTxMock(overrides: Record<string, unknown> = {}) {
user: {
findUnique: jest.fn(),
},
membershipContract: {
findFirst: jest.fn().mockResolvedValue(null),
},
bookingStatusHistory: {
create: jest.fn(),
},
@@ -492,6 +495,21 @@ describe('BookingService', () => {
describe('createBooking', () => {
const dto = { timeSlotId: MOCK_SLOT_ID, membershipId: MOCK_MEMBERSHIP_ID }
it('blocks booking until the purchase contract is signed', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.membershipContract.findFirst.mockResolvedValue({ id: 'contract-1' })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.createBooking(MOCK_USER_ID, dto)).rejects.toThrow('请先签署会员卡合同')
expect(tx.membershipContract.findFirst).toHaveBeenCalledWith(expect.objectContaining({
where: { membershipId: mockActiveMembership.id, status: 'PENDING_SIGN', requireBeforeBooking: true },
}))
expect(tx.booking.create).not.toHaveBeenCalled()
})
it('creates booking in pending confirmation status', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)

View File

@@ -10,6 +10,8 @@ import { Booking, Membership, TimeSlot, BookingStatusHistory } from '@prisma/cli
import {
BookingStatus,
CardTypeCategory,
CONTRACT_UNSIGNED_MESSAGE,
MembershipContractStatus,
MembershipStatus,
TimeSlotStatus,
type TeachingScheduleSlot,
@@ -131,6 +133,11 @@ export class BookingService {
throw new BadRequestException('Membership has expired')
}
if (membership.cardType.type === CardTypeCategory.DURATION && !canUseMembershipForSlot(membership, timeSlot)) throw new BadRequestException('课程不在该月卡周期内,或该周期已无可用课时')
const unsignedContract = await tx.membershipContract.findFirst({
where: { membershipId: membership.id, status: MembershipContractStatus.PENDING_SIGN, requireBeforeBooking: true },
select: { id: true },
})
if (unsignedContract) throw new ForbiddenException(CONTRACT_UNSIGNED_MESSAGE)
let originAssessmentId: string | undefined
if (dto.originAssessmentId) {

View File

@@ -0,0 +1,135 @@
import { MembershipContractSource } from '@mp-pilates/shared'
import {
buildContractDocument,
formatChinaDateTime,
formatYuan,
hashContractDocument,
toChineseAmount,
type ContractRenderInput,
type ContractTerms,
} from '../contract-template'
const baseTerms: ContractTerms = {
kind: 'MULTI_TIMES',
cardTypeId: 'card-10',
cardName: '私教10次卡',
lessonCount: 10,
durationDays: 1095,
listPrice: 388000,
amount: 388000,
discountNote: null,
orderNos: ['1730000000000abc'],
paidAt: '2026-10-28T03:37:00.000Z',
acceptedAt: '2026-10-28T03:35:00.000Z',
startDate: '2026-10-28T03:37:00.000Z',
expireDate: '2029-10-27T03:37:00.000Z',
scheduled: false,
renewal: false,
availableTimesAfter: null,
legacyRemainingTimes: null,
legacyAsOf: null,
}
function render(overrides: Partial<ContractRenderInput> = {}, terms: Partial<ContractTerms> = {}) {
return buildContractDocument({
contractNo: 'MP20261028000001',
source: MembershipContractSource.PURCHASE,
terms: { ...baseTerms, ...terms },
partyA: { realName: '张三', phone: '13800000000', openid: 'o2Gv_0FiEg3C7dGwK0ib7-75wlE4' },
partyB: {
businessName: '深圳市宝安区某某普拉提工作室',
brandName: 'MP Pilates',
creditCode: '92440300MA5XXXXX1X',
operatorName: '李四',
phone: '19500000000',
address: '深圳市宝安区某路 1 号',
premisesType: 'LEASED',
leaseEndDate: '2028-06-30T00:00:00.000Z',
standardLessonPrice: 45000,
},
cancelHoursLimit: 2,
...overrides,
})
}
function allText(document: ReturnType<typeof render>) {
return [
...document.cardRows.map(row => `${row.label}:${row.value}`),
...document.sections.flatMap(section => section.clauses.map(clause => clause.text)),
].join('\n')
}
describe('contract formatting', () => {
it('formats amounts in yuan and Chinese capitals', () => {
expect(formatYuan(388000)).toBe('3,880.00')
expect(toChineseAmount(388000)).toBe('人民币叁仟捌佰捌拾元整')
expect(toChineseAmount(750000)).toBe('人民币柒仟伍佰元整')
expect(toChineseAmount(1000100)).toBe('人民币壹万零壹元整')
expect(toChineseAmount(10000000)).toBe('人民币壹拾万元整')
expect(toChineseAmount(12345)).toBe('人民币壹佰贰拾叁元肆角伍分')
})
it('formats timestamps in China time', () => {
expect(formatChinaDateTime('2026-10-28T03:37:00.000Z')).toBe('2026年10月28日 11:37')
expect(formatChinaDateTime('2026-10-28T17:10:00.000Z')).toBe('2026年10月29日 01:10')
})
})
describe('buildContractDocument', () => {
it('renders a multi-times contract with standard-price refunds and fixed clause numbers', () => {
const document = render()
const refund = document.sections.find(section => section.title.includes('退费'))!
expect(refund.clauses[0].text).toMatch(/^1\. 七日无理由退款/)
expect(refund.clauses[1].text).toContain('单节标准课价(¥450.00)')
expect(refund.clauses[2].text).toMatch(/^3\. 乙方原因退费/)
expect(refund.clauses.slice(0, 3).every(clause => clause.emphasis)).toBe(true)
const text = allText(document)
expect(text).toContain('卡种类别:多次卡')
expect(text).toContain('人民币叁仟捌佰捌拾元整')
expect(text).toContain('取消或未到场上课的,该节课时视为已使用')
expect(text).toContain('课程开始前 2 小时以前取消')
expect(text).not.toContain('最终解释权')
expect(document.partyB.find(row => row.label === '场所性质')?.value).toBe('租赁(租期至 2028年06月30日)')
expect(document.partyA.find(row => row.label === '微信账号标识')?.value).toBe('o2Gv****wlE4')
})
it('explains renewal merges for times cards', () => {
const text = allText(render({}, { renewal: true, availableTimesAfter: 14 }))
expect(text).toContain('续购后合计可用 14 节')
expect(text).toContain('优先计入较早购买的课时')
})
it('renders monthly cards per period and prorates unlimited monthly refunds by days', () => {
const limited = allText(render({}, { kind: 'MONTHLY', cardName: '月卡', lessonCount: 10, durationDays: 30, scheduled: true }))
expect(limited).toContain('卡种类别:月卡')
expect(limited).toContain('每期课时:10 节')
expect(limited).toContain('未使用的课时不结转至下一期')
expect(limited).toContain('自上一期届满时起生效')
expect(limited).toContain('月卡按期计算')
const unlimited = allText(render({}, { kind: 'MONTHLY', lessonCount: null, durationDays: 30 }))
expect(unlimited).toContain('实付金额 × 剩余有效天数 ÷ 总有效天数')
expect(unlimited).not.toContain('乙方确认预约时扣除 1 节课时')
})
it('marks missing party information as pending and renders legacy terms', () => {
const document = render(
{
source: MembershipContractSource.LEGACY,
partyA: { realName: null, phone: null, openid: 'openid-123456789' },
},
{ legacyRemainingTimes: 6, legacyAsOf: '2026-10-28T03:37:00.000Z', orderNos: [] },
)
expect(document.title).toContain('存量会员补签')
expect(document.partyA[0].value).toBe('(待填写)')
const text = allText(document)
expect(text).toContain('剩余课时:6 节(截至 2026年10月28日)')
expect(text).toContain('支付方式:以乙方收款记录为准')
expect(text).toContain('双方确认此前的付款、预约、上课及课时扣减记录真实有效')
})
it('hashes deterministically and detects content changes', () => {
expect(hashContractDocument(render())).toBe(hashContractDocument(render()))
expect(hashContractDocument(render())).not.toBe(hashContractDocument(render({ cancelHoursLimit: 4 })))
})
})

View File

@@ -0,0 +1,315 @@
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'
import { Decimal } from '@prisma/client/runtime/library'
import { MembershipContractSource, MembershipContractStatus } from '@mp-pilates/shared'
import { ContractService, decodeSignaturePng } from '../contract.service'
import { buildContractDocument, hashContractDocument } from '../contract-template'
const PNG = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(800, 1)])
const PNG_DATA_URI = `data:image/png;base64,${PNG.toString('base64')}`
const party = {
id: 'party-1',
businessName: '某某普拉提工作室',
brandName: '',
creditCode: '92440300MA5XXXXX1X',
operatorName: '李四',
phone: '19500000000',
address: '深圳市宝安区某路 1 号',
premisesType: 'LEASED',
leaseEndDate: null,
standardLessonPrice: 45000,
signatureImage: PNG,
signatureSha256: 'party-sha',
signatureUpdatedAt: new Date(),
updatedAt: new Date(),
}
const timesCard = {
id: 'card-10',
name: '私教10次卡',
type: 'TIMES',
totalTimes: 10,
durationDays: 1095,
price: new Decimal(388000),
originalPrice: null,
description: null,
coverUrl: null,
isActive: true,
sortOrder: 0,
createdAt: new Date(),
updatedAt: new Date(),
}
function buildPrisma() {
const prisma = {
$queryRaw: jest.fn(),
$transaction: jest.fn(),
membershipContract: {
findUnique: jest.fn(),
findFirst: jest.fn(),
findMany: jest.fn(),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'contract-new', createdAt: new Date(), signedAt: null, voidReason: null, status: 'PENDING_SIGN', ...data })),
update: jest.fn(),
updateMany: jest.fn(),
count: jest.fn(),
},
contractEvent: { create: jest.fn() },
contractParty: { findFirst: jest.fn().mockResolvedValue(party), create: jest.fn() },
membership: { findMany: jest.fn(), findUnique: jest.fn() },
order: { findMany: jest.fn() },
cardType: { findFirst: jest.fn(), findUnique: jest.fn() },
user: { findUnique: jest.fn() },
inviteReferral: { findUnique: jest.fn() },
}
prisma.$transaction.mockImplementation((fn: (tx: typeof prisma) => unknown) => fn(prisma))
return prisma
}
function buildService(prisma = buildPrisma()) {
const studio = { getInfo: jest.fn().mockResolvedValue({ cancelHoursLimit: 2 }) }
const pdf = { render: jest.fn().mockResolvedValue(Buffer.from('%PDF')) }
return { service: new ContractService(prisma as never, studio as never, pdf as never), prisma, pdf }
}
const order = {
id: 'order-1',
userId: 'user-1',
cardTypeId: 'card-10',
membershipId: null,
orderNo: '1730000000000abc',
inviteInviterId: null,
purchasedCategory: 'TIMES',
amount: new Decimal(388000),
status: 'PENDING',
wxTransactionId: null,
paidAt: null,
contractAcceptedAt: new Date('2026-10-28T03:35:00.000Z'),
createdAt: new Date('2026-10-28T03:35:00.000Z'),
updatedAt: new Date(),
}
describe('ContractService.issueForPaidOrder', () => {
const paidAt = new Date('2026-10-28T03:37:00.000Z')
it('does not issue contracts for single-lesson or trial cards', async () => {
const { service, prisma } = buildService()
const result = await service.issueForPaidOrder(prisma as never, {
order: order as never,
cardType: { ...timesCard, totalTimes: 1 } as never,
membership: {} as never,
paidAt,
})
expect(result).toBeNull()
expect(prisma.membershipContract.create).not.toHaveBeenCalled()
})
it('issues a booking-blocking contract with frozen purchase terms', async () => {
const { service, prisma } = buildService()
prisma.membershipContract.findUnique.mockResolvedValue(null)
const membership = {
id: 'membership-1',
createdAt: new Date('2026-10-28T03:37:00.100Z'),
startDate: paidAt,
expireDate: new Date('2029-10-27T03:37:00.000Z'),
remainingTimes: 10,
}
await service.issueForPaidOrder(prisma as never, { order: order as never, cardType: timesCard as never, membership: membership as never, paidAt })
const data = prisma.membershipContract.create.mock.calls[0][0].data
expect(data).toMatchObject({
userId: 'user-1',
membershipId: 'membership-1',
orderId: 'order-1',
source: MembershipContractSource.PURCHASE,
requireBeforeBooking: true,
})
expect(data.contractNo).toMatch(/^MP\d{14}$/)
expect(data.terms).toMatchObject({ kind: 'MULTI_TIMES', lessonCount: 10, amount: 388000, renewal: false, acceptedAt: '2026-10-28T03:35:00.000Z' })
expect(prisma.contractEvent.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: 'CREATED' }) }))
})
it('records renewals that merge into an earlier times card', async () => {
const { service, prisma } = buildService()
prisma.membershipContract.findUnique.mockResolvedValue(null)
const membership = {
id: 'membership-old',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
startDate: new Date('2026-01-01T00:00:00.000Z'),
expireDate: new Date('2029-10-27T03:37:00.000Z'),
remainingTimes: 14,
}
await service.issueForPaidOrder(prisma as never, { order: order as never, cardType: timesCard as never, membership: membership as never, paidAt })
expect(prisma.membershipContract.create.mock.calls[0][0].data.terms).toMatchObject({
renewal: true,
availableTimesAfter: 14,
startDate: paidAt.toISOString(),
})
})
it('treats a reset expired times card as a fresh purchase starting at payment', async () => {
const { service, prisma } = buildService()
prisma.membershipContract.findUnique.mockResolvedValue(null)
const membership = {
id: 'membership-old',
createdAt: new Date('2025-01-01T00:00:00.000Z'),
startDate: new Date('2025-01-01T00:00:00.000Z'),
expireDate: new Date('2029-10-27T03:37:00.000Z'),
remainingTimes: 10,
}
await service.issueForPaidOrder(prisma as never, { order: order as never, cardType: timesCard as never, membership: membership as never, paidAt })
expect(prisma.membershipContract.create.mock.calls[0][0].data.terms).toMatchObject({
renewal: false,
availableTimesAfter: null,
startDate: paidAt.toISOString(),
})
})
it('is idempotent per order', async () => {
const { service, prisma } = buildService()
prisma.membershipContract.findUnique.mockResolvedValue({ id: 'existing' })
const result = await service.issueForPaidOrder(prisma as never, { order: order as never, cardType: timesCard as never, membership: {} as never, paidAt })
expect(result).toEqual({ id: 'existing' })
expect(prisma.membershipContract.create).not.toHaveBeenCalled()
})
})
describe('ContractService.syncLegacyContracts', () => {
const future = new Date(Date.now() + 30 * 86400000)
const baseMembership = {
userId: 'user-1',
cardTypeId: 'card-10',
cardType: timesCard,
user: { id: 'user-1', nickname: '学员', phone: null },
status: 'ACTIVE',
remainingTimes: 6,
totalTimes: 10,
startDate: new Date('2026-01-01T00:00:00.000Z'),
expireDate: future,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
}
it('creates legacy contracts from uncovered paid orders and lists cards without orders', async () => {
const { service, prisma } = buildService()
prisma.membership.findMany.mockResolvedValue([
{ ...baseMembership, id: 'with-orders', orders: [{ id: 'o1', orderNo: 'NO1', amount: new Decimal(388000), paidAt: new Date('2026-01-01') }], contracts: [] },
{ ...baseMembership, id: 'no-orders', orders: [], contracts: [] },
{ ...baseMembership, id: 'covered', orders: [{ id: 'o2', amount: new Decimal(1) }], contracts: [{ orderId: 'o2', source: 'PURCHASE' }] },
{ ...baseMembership, id: 'single', cardType: { ...timesCard, totalTimes: 1 }, orders: [], contracts: [] },
])
prisma.membership.findUnique.mockResolvedValue({ ...baseMembership, id: 'with-orders' })
prisma.membershipContract.findFirst.mockResolvedValue(null)
const result = await service.syncLegacyContracts('user-1')
expect(result.created).toBe(1)
expect(result.needsAmount.map(item => item.membershipId)).toEqual(['no-orders'])
const data = prisma.membershipContract.create.mock.calls[0][0].data
expect(data).toMatchObject({ membershipId: 'with-orders', source: MembershipContractSource.LEGACY, requireBeforeBooking: false })
expect(data.terms).toMatchObject({ amount: 388000, orderNos: ['NO1'], legacyRemainingTimes: 6 })
})
it('does not duplicate an existing legacy contract under concurrent syncs', async () => {
const { service, prisma } = buildService()
prisma.membership.findMany.mockResolvedValue([
{ ...baseMembership, id: 'with-orders', orders: [{ id: 'o1', orderNo: 'NO1', amount: new Decimal(388000), paidAt: null }], contracts: [] },
])
prisma.membership.findUnique.mockResolvedValue({ ...baseMembership, id: 'with-orders' })
prisma.membershipContract.findFirst.mockResolvedValue({ id: 'raced' })
const result = await service.syncLegacyContracts('user-1')
expect(result.created).toBe(0)
expect(prisma.membershipContract.create).not.toHaveBeenCalled()
})
})
describe('ContractService.sign', () => {
const terms = {
kind: 'MULTI_TIMES', cardTypeId: 'card-10', cardName: '私教10次卡', lessonCount: 10, durationDays: 1095,
listPrice: 388000, amount: 388000, discountNote: null, orderNos: ['NO1'], paidAt: '2026-10-28T03:37:00.000Z',
acceptedAt: null, startDate: '2026-10-28T03:37:00.000Z', expireDate: '2029-10-27T03:37:00.000Z',
scheduled: false, renewal: false, availableTimesAfter: null, legacyRemainingTimes: null, legacyAsOf: null,
}
const contract = {
id: 'contract-1',
contractNo: 'MP20261028000001',
userId: 'user-1',
membershipId: 'membership-1',
orderId: 'order-1',
source: 'PURCHASE',
status: 'PENDING_SIGN',
requireBeforeBooking: true,
terms,
partyName: '张三',
partyPhone: '13800000000',
createdAt: new Date(),
signedAt: null,
voidReason: null,
user: { id: 'user-1', nickname: '学员', phone: '13800000000', openid: 'openid-123456789' },
}
const expectedHash = hashContractDocument(buildContractDocument({
contractNo: contract.contractNo,
source: MembershipContractSource.PURCHASE,
terms: terms as never,
partyA: { realName: '张三', phone: '13800000000', openid: 'openid-123456789' },
partyB: { ...party, premisesType: 'LEASED', leaseEndDate: null },
cancelHoursLimit: 2,
}))
const dto = { contentSha256: expectedHash, signatureImage: PNG_DATA_URI, agreedTerms: true, confirmedHealth: true }
const meta = { ip: '1.2.3.4', userAgent: 'wechat' }
it('hides other members\' contracts', async () => {
const { service, prisma } = buildService()
prisma.membershipContract.findUnique.mockResolvedValue({ ...contract, userId: 'someone-else' })
await expect(service.sign('user-1', 'contract-1', dto, meta)).rejects.toBeInstanceOf(NotFoundException)
})
it('rejects signing when the reviewed content has changed', async () => {
const { service, prisma } = buildService()
prisma.membershipContract.findUnique.mockResolvedValue(contract)
await expect(service.sign('user-1', 'contract-1', { ...dto, contentSha256: 'a'.repeat(64) }, meta)).rejects.toBeInstanceOf(ConflictException)
})
it('rejects signing until party B profile is complete', async () => {
const { service, prisma } = buildService()
prisma.contractParty.findFirst.mockResolvedValue({ ...party, signatureImage: null })
prisma.membershipContract.findUnique.mockResolvedValue(contract)
await expect(service.sign('user-1', 'contract-1', dto, meta)).rejects.toBeInstanceOf(BadRequestException)
})
it('freezes the document, hashes and both signatures atomically', async () => {
const { service, prisma } = buildService()
prisma.membershipContract.findUnique.mockResolvedValue(contract)
prisma.membershipContract.updateMany.mockResolvedValue({ count: 1 })
await service.sign('user-1', 'contract-1', dto, meta)
const update = prisma.membershipContract.updateMany.mock.calls[0][0]
expect(update.where).toEqual({ id: 'contract-1', status: MembershipContractStatus.PENDING_SIGN })
expect(update.data).toMatchObject({
status: MembershipContractStatus.SIGNED,
contentSha256: expectedHash,
signerIp: '1.2.3.4',
partyBSignatureImage: PNG,
})
expect(update.data.signatureSha256).toMatch(/^[a-f0-9]{64}$/)
expect(prisma.contractEvent.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: 'SIGNED' }) }))
})
it('reports a concurrent signature as a conflict', async () => {
const { service, prisma } = buildService()
prisma.membershipContract.findUnique.mockResolvedValue(contract)
prisma.membershipContract.updateMany.mockResolvedValue({ count: 0 })
await expect(service.sign('user-1', 'contract-1', dto, meta)).rejects.toBeInstanceOf(ConflictException)
})
it('only exports signed contracts', async () => {
const { service, prisma, pdf } = buildService()
prisma.membershipContract.findUnique.mockResolvedValue(contract)
await expect(service.exportPdf('contract-1', { id: 'user-1', isAdmin: false }, meta)).rejects.toBeInstanceOf(BadRequestException)
expect(pdf.render).not.toHaveBeenCalled()
})
})
describe('decodeSignaturePng', () => {
it('accepts PNG data URIs and rejects other payloads', () => {
expect(decodeSignaturePng(PNG_DATA_URI).equals(PNG)).toBe(true)
expect(() => decodeSignaturePng(Buffer.alloc(900).toString('base64'))).toThrow(BadRequestException)
expect(() => decodeSignaturePng(PNG.subarray(0, 100).toString('base64'))).toThrow(BadRequestException)
})
})

View File

@@ -0,0 +1,155 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { existsSync } from 'fs'
import { join } from 'path'
import PDFDocument from 'pdfkit'
import type { ContractDocument, ContractRow } from '@mp-pilates/shared'
import { formatChinaDateTime } from './contract-template'
export interface ContractPdfInput {
document: ContractDocument
contentSha256: string
signerName: string
signedAt: string
memberSignature: Buffer
partyBSignature: Buffer | null
partyBName: string
}
const PAGE_MARGIN = 56
const TEXT_COLOR = '#2f2a26'
const MUTED_COLOR = '#7a6f66'
const LINE_COLOR = '#d8cfc6'
@Injectable()
export class ContractPdfService {
constructor(private readonly config: ConfigService) {}
private resolveFont(): string {
const candidates = [
this.config.get<string>('CONTRACT_PDF_FONT_PATH')?.trim(),
join(__dirname, '..', '..', 'assets', 'fonts', 'NotoSansSC-Regular.otf'),
].filter((item): item is string => !!item)
const found = candidates.find(path => existsSync(path))
if (!found) throw new ServiceUnavailableException('合同字体文件缺失,暂时无法导出')
return found
}
async render(input: ContractPdfInput): Promise<Buffer> {
const doc = new PDFDocument({
size: 'A4',
margins: { top: PAGE_MARGIN, bottom: PAGE_MARGIN + 18, left: PAGE_MARGIN, right: PAGE_MARGIN },
bufferPages: true,
info: {
Title: `${input.document.title} ${input.document.contractNo}`,
Subject: `SHA-256 ${input.contentSha256}`,
CreationDate: new Date(input.signedAt),
},
})
const chunks: Buffer[] = []
doc.on('data', (chunk: Buffer) => chunks.push(chunk))
const finished = new Promise<Buffer>((resolve, reject) => {
doc.on('end', () => resolve(Buffer.concat(chunks)))
doc.on('error', reject)
})
doc.registerFont('cn', this.resolveFont())
doc.font('cn').fillColor(TEXT_COLOR)
const width = doc.page.width - PAGE_MARGIN * 2
const { document } = input
doc.fontSize(18).text(document.title, { align: 'center' })
doc.moveDown(0.3)
doc.fontSize(9).fillColor(MUTED_COLOR).text(`合同编号:${document.contractNo} 模板版本:${document.templateVersion}`, { align: 'center' })
doc.moveDown(0.8)
doc.fontSize(9).fillColor(MUTED_COLOR).text(document.notice, { lineGap: 2 })
doc.moveDown(0.8)
this.heading(doc, '甲方(会员)')
this.rows(doc, document.partyA, width)
this.heading(doc, '乙方(经营者)')
this.rows(doc, document.partyB, width)
this.heading(doc, '会员卡信息')
this.rows(doc, document.cardRows, width)
for (const section of document.sections) {
this.heading(doc, section.title)
for (const clause of section.clauses) {
doc.fontSize(10).fillColor(TEXT_COLOR)
if (clause.emphasis) {
doc.lineWidth(0.22).strokeColor(TEXT_COLOR).text(clause.text, { lineGap: 3, fill: true, stroke: true })
} else {
doc.text(clause.text, { lineGap: 3 })
}
doc.moveDown(0.25)
}
}
doc.moveDown(0.6)
doc.fontSize(10).lineWidth(0.22).strokeColor(TEXT_COLOR).text(document.commitment, { lineGap: 3, fill: true, stroke: true })
this.signatures(doc, input, width)
const range = doc.bufferedPageRange()
for (let index = 0; index < range.count; index++) {
doc.switchToPage(range.start + index)
const bottom = doc.page.height - PAGE_MARGIN + 4
doc.page.margins.bottom = 0
doc.fontSize(7.5).fillColor(MUTED_COLOR).text(
`合同编号 ${document.contractNo} | 第 ${index + 1} 页 / 共 ${range.count} 页 | 内容摘要 SHA-256:${input.contentSha256}`,
PAGE_MARGIN,
bottom,
{ width, align: 'center', lineBreak: false },
)
}
doc.end()
return finished
}
private heading(doc: PDFKit.PDFDocument, title: string) {
doc.moveDown(0.6)
doc.fontSize(12).fillColor(TEXT_COLOR).lineWidth(0.4).strokeColor(TEXT_COLOR).text(title, { fill: true, stroke: true })
doc.moveDown(0.35)
}
private rows(doc: PDFKit.PDFDocument, rows: readonly ContractRow[], width: number) {
const labelWidth = 96
const gap = 10
for (const row of rows) {
doc.fontSize(10)
const valueHeight = doc.heightOfString(row.value, { width: width - labelWidth - gap, lineGap: 2 })
if (doc.y + valueHeight > doc.page.height - doc.page.margins.bottom) doc.addPage()
const top = doc.y
doc.fillColor(MUTED_COLOR).text(row.label, PAGE_MARGIN, top, { width: labelWidth })
doc.fillColor(TEXT_COLOR).text(row.value, PAGE_MARGIN + labelWidth + gap, top, { width: width - labelWidth - gap, lineGap: 2 })
doc.x = PAGE_MARGIN
doc.y = Math.max(doc.y, top + valueHeight) + 4
doc.moveTo(PAGE_MARGIN, doc.y - 2).lineTo(PAGE_MARGIN + width, doc.y - 2).lineWidth(0.3).strokeColor(LINE_COLOR).stroke()
}
}
private signatures(doc: PDFKit.PDFDocument, input: ContractPdfInput, width: number) {
const blockHeight = 150
if (doc.y + blockHeight > doc.page.height - doc.page.margins.bottom) doc.addPage()
doc.moveDown(1.2)
const top = doc.y
const column = width / 2
const signedAt = formatChinaDateTime(input.signedAt)
doc.fontSize(10).fillColor(TEXT_COLOR)
doc.text('甲方(签名):', PAGE_MARGIN, top)
doc.image(input.memberSignature, PAGE_MARGIN, top + 18, { fit: [column - 24, 70] })
doc.fontSize(9).fillColor(MUTED_COLOR)
doc.text(`姓名:${input.signerName}`, PAGE_MARGIN, top + 94)
doc.text(`签署时间:${signedAt}`, PAGE_MARGIN, top + 110)
const right = PAGE_MARGIN + column
doc.fontSize(10).fillColor(TEXT_COLOR)
doc.text('乙方(签章):', right, top)
if (input.partyBSignature) doc.image(input.partyBSignature, right, top + 18, { fit: [column - 24, 70] })
doc.fontSize(9).fillColor(MUTED_COLOR)
doc.text(input.partyBName, right, top + 94, { width: column })
doc.text(`签署时间:${signedAt}`, right, top + 110)
doc.x = PAGE_MARGIN
doc.y = top + blockHeight
}
}

View File

@@ -0,0 +1,405 @@
import { createHash } from 'crypto'
import {
MembershipContractSource,
type ContractClause,
type ContractDocument,
type ContractKind,
type ContractPremisesType,
type ContractRow,
type ContractSection,
} from '@mp-pilates/shared'
export const CONTRACT_TEMPLATE_VERSION = '2026-10-v1'
const CHINA_OFFSET_MS = 8 * 3600 * 1000
const PENDING_TEXT = '(待填写)'
/** 签发时冻结的交易事实,后续续费或改卡不影响已签发合同。 */
export interface ContractTerms {
kind: ContractKind
cardTypeId: string
cardName: string
/** 本合同对应的课时数;null 为不限次。 */
lessonCount: number | null
durationDays: number
listPrice: number
amount: number
discountNote: string | null
orderNos: string[]
paidAt: string | null
acceptedAt: string | null
startDate: string | null
expireDate: string | null
scheduled: boolean
renewal: boolean
availableTimesAfter: number | null
legacyRemainingTimes: number | null
legacyAsOf: string | null
}
export interface ContractPartyAInput {
realName: string | null
phone: string | null
openid: string
}
export interface ContractPartyBInput {
businessName: string
brandName: string
creditCode: string
operatorName: string
phone: string
address: string
premisesType: ContractPremisesType
leaseEndDate: string | null
standardLessonPrice: number
}
export interface ContractRenderInput {
contractNo: string
source: MembershipContractSource
terms: ContractTerms
partyA: ContractPartyAInput
partyB: ContractPartyBInput
cancelHoursLimit: number
}
export function formatYuan(fen: number): string {
const sign = fen < 0 ? '-' : ''
const [integer, decimal] = (Math.abs(fen) / 100).toFixed(2).split('.')
return `${sign}${integer.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}.${decimal}`
}
export function toChineseAmount(fen: number): string {
const digits = '零壹贰叁肆伍陆柒捌玖'
const units = ['', '拾', '佰', '仟']
const sections = ['', '万', '亿']
const yuan = Math.floor(fen / 100)
const jiao = Math.floor((fen % 100) / 10)
const cent = fen % 10
let integer = ''
if (yuan === 0) {
integer = '零'
} else {
const text = String(yuan)
let zeroPending = false
for (let index = 0; index < text.length; index++) {
const digit = Number(text[index])
const position = text.length - 1 - index
if (digit === 0) {
zeroPending = true
} else {
if (zeroPending && integer) integer += '零'
zeroPending = false
integer += digits[digit] + units[position % 4]
}
if (position % 4 === 0 && position > 0) {
const sectionValue = Number(text.slice(Math.max(0, index - 3), index + 1))
if (sectionValue !== 0) integer += sections[position / 4]
}
}
}
if (jiao === 0 && cent === 0) return `人民币${integer}元整`
const jiaoText = jiao ? `${digits[jiao]}角` : cent ? '零' : ''
const centText = cent ? `${digits[cent]}分` : ''
return `人民币${integer}元${jiaoText}${centText}`
}
function chinaParts(iso: string) {
const date = new Date(new Date(iso).getTime() + CHINA_OFFSET_MS)
return {
year: date.getUTCFullYear(),
month: String(date.getUTCMonth() + 1).padStart(2, '0'),
day: String(date.getUTCDate()).padStart(2, '0'),
hour: String(date.getUTCHours()).padStart(2, '0'),
minute: String(date.getUTCMinutes()).padStart(2, '0'),
}
}
export function formatChinaDate(iso: string): string {
const { year, month, day } = chinaParts(iso)
return `${year}年${month}月${day}日`
}
export function formatChinaDateTime(iso: string): string {
const { year, month, day, hour, minute } = chinaParts(iso)
return `${year}年${month}月${day}日 ${hour}:${minute}`
}
/** 数据库 DATE 列以 UTC 日历值保存。 */
function formatCalendarDate(iso: string): string {
const date = new Date(iso)
return `${date.getUTCFullYear()}年${String(date.getUTCMonth() + 1).padStart(2, '0')}月${String(date.getUTCDate()).padStart(2, '0')}日`
}
export function maskOpenid(openid: string): string {
if (openid.length <= 8) return openid
return `${openid.slice(0, 4)}****${openid.slice(-4)}`
}
function orPending(value: string | null | undefined): string {
return value && value.trim() ? value.trim() : PENDING_TEXT
}
function lessonText(count: number | null): string {
return count === null ? '有效期内不限次数' : `${count} 节`
}
function clauses(items: Array<string | ContractClause | false | null | undefined>): ContractClause[] {
return items
.filter((item): item is string | ContractClause => !!item)
.map((item, index) => {
const clause = typeof item === 'string' ? { text: item } : item
return { ...clause, text: `${index + 1}. ${clause.text}` }
})
}
function bold(text: string): ContractClause {
return { text, emphasis: true }
}
function buildPartyARows(partyA: ContractPartyAInput): ContractRow[] {
return [
{ label: '姓名', value: orPending(partyA.realName) },
{ label: '手机号', value: orPending(partyA.phone) },
{ label: '微信账号标识', value: maskOpenid(partyA.openid) },
]
}
function buildPartyBRows(partyB: ContractPartyBInput): ContractRow[] {
const premises = partyB.premisesType === 'OWNED'
? '自有'
: partyB.leaseEndDate
? `租赁(租期至 ${formatCalendarDate(partyB.leaseEndDate)})`
: '租赁'
const rows: ContractRow[] = [
{ label: '经营者名称', value: `${orPending(partyB.businessName)}(个体工商户)` },
{ label: '统一社会信用代码', value: orPending(partyB.creditCode) },
{ label: '经营者', value: orPending(partyB.operatorName) },
]
if (partyB.brandName.trim()) rows.push({ label: '门店名称', value: partyB.brandName.trim() })
rows.push(
{ label: '联系电话', value: orPending(partyB.phone) },
{ label: '经营场所', value: orPending(partyB.address) },
{ label: '场所性质', value: premises },
)
return rows
}
function buildCardRows(input: ContractRenderInput): ContractRow[] {
const { terms, partyB, source } = input
const isMonthly = terms.kind === 'MONTHLY'
const rows: ContractRow[] = [
{ label: '卡种名称', value: terms.cardName },
{ label: '卡种类别', value: isMonthly ? '月卡' : '多次卡' },
{ label: '服务内容', value: '普拉提课程训练服务(课程时段、单节时长以乙方小程序发布的课程为准)' },
{ label: '服务地点', value: orPending(partyB.address) },
]
if (source === MembershipContractSource.LEGACY) {
rows.push(
{ label: '购买课时', value: terms.lessonCount === null ? '有效期内不限次数' : `${terms.lessonCount} 节(含续购累计)` },
{
label: '剩余课时',
value: terms.legacyRemainingTimes === null
? '有效期内不限次数'
: `${terms.legacyRemainingTimes} 节(截至 ${terms.legacyAsOf ? formatChinaDate(terms.legacyAsOf) : PENDING_TEXT})`,
},
{ label: '已付金额', value: `¥${formatYuan(terms.amount)}(${toChineseAmount(terms.amount)})` },
{ label: '支付方式', value: terms.orderNos.length ? '微信支付' : '以乙方收款记录为准' },
)
if (terms.orderNos.length) rows.push({ label: '订单编号', value: terms.orderNos.join('、') })
} else {
rows.push(
{ label: isMonthly ? '每期课时' : '课时数量', value: lessonText(terms.lessonCount) },
{ label: '有效天数', value: `${terms.durationDays} 天` },
{ label: '卡种售价', value: `¥${formatYuan(terms.listPrice)}` },
{
label: '实付金额',
value: `¥${formatYuan(terms.amount)}(${toChineseAmount(terms.amount)})${terms.discountNote ? `,${terms.discountNote}` : ''}`,
},
{ label: '支付方式', value: '微信支付(乙方小程序在线收款)' },
{ label: '订单编号', value: terms.orderNos[0] ?? '支付后生成' },
{ label: '支付时间', value: terms.paidAt ? formatChinaDateTime(terms.paidAt) : '以微信支付确认时间为准' },
)
}
rows.push(
{ label: '权益生效时间', value: terms.startDate ? formatChinaDateTime(terms.startDate) : '微信支付确认后立即生效' },
{
label: '权益截止时间',
value: terms.expireDate ? formatChinaDateTime(terms.expireDate) : `自生效时间起 ${terms.durationDays} 天`,
},
)
if (terms.renewal && terms.availableTimesAfter !== null) {
rows.push({
label: '续购说明',
value: `本次为续购,新增 ${terms.lessonCount ?? 0} 节课时并入原会员卡,续购后合计可用 ${terms.availableTimesAfter} 节,有效期至上述截止时间`,
})
}
if (terms.scheduled) {
rows.push({ label: '排期说明', value: '本期为提前续购的下期月卡,自上一期届满时起生效' })
}
rows.push({
label: '单节标准课价',
value: partyB.standardLessonPrice > 0
? `¥${formatYuan(partyB.standardLessonPrice)}(乙方公示的单次课价格,仅用于第六条第 2 款退费结算)`
: PENDING_TEXT,
})
return rows
}
function buildSections(input: ContractRenderInput): ContractSection[] {
const { terms, partyB, source, cancelHoursLimit } = input
const isMonthly = terms.kind === 'MONTHLY'
const unlimited = terms.lessonCount === null
const standardPrice = partyB.standardLessonPrice > 0 ? `¥${formatYuan(partyB.standardLessonPrice)}` : '单节标准课价'
const acceptedAt = terms.acceptedAt ? `已于 ${formatChinaDateTime(terms.acceptedAt)} ` : ''
return [
{
title: '第一条 合同成立与生效',
clauses: clauses([
`本合同由乙方通过微信小程序向甲方提供。甲方${acceptedAt}在支付前阅读并同意本合同条款,并在核对本合同全文后以手写电子签名方式签署;乙方签章已预先加载于本合同。本合同自甲方签署之日起生效。`,
source === MembershipContractSource.LEGACY
? '甲方在本合同签署前已向乙方购买会员卡,双方确认此前的付款、预约、上课及课时扣减记录真实有效;自本合同签署之日起,双方就剩余权益按本合同履行。'
: '甲方在本合同签署前已完成付款的,付款时间及会员卡权益生效时间以本合同「会员卡信息」所载为准;签署前已发生的预约、上课及课时扣减记录,双方确认有效并适用本合同。',
'本合同履行期限自会员卡权益生效之时起,至权益截止时间届满或课时全部使用完毕之时止(以先到者为准);依本合同办理退费或转让完毕的,本合同自办理完毕之日起终止。',
'甲方可随时在乙方小程序「个人中心 - 我的合同」查看、下载本合同及签署记录。',
]),
},
{
title: '第二条 会员卡权益与有效期',
clauses: clauses([
isMonthly
? `本卡为月卡,每期有效期 ${terms.durationDays} 天,每期课时为${unlimited ? '有效期内不限次数' : ` ${terms.lessonCount} 节`}。每期课时仅限当期使用,未使用的课时不结转至下一期。`
: `本卡为多次卡,共含课时 ${terms.lessonCount ?? 0} 节,自付款确认时起生效,有效期 ${terms.durationDays} 天,具体起止时间以「会员卡信息」所载为准。`,
isMonthly
? '上一期月卡尚未到期时购买的,新一期自上一期届满时起生效;当期课时用完后,甲方可在小程序中确认提前启用下一期,下一期自启用时起计算完整期限。各期生效与截止时间以小程序记录为准。'
: '在有效期内续购同一卡种的,新增课时并入原会员卡,有效期按续购时小程序公示的规则相应延长,并以续购时签署的合同为准;已过期会员卡的剩余课时不并入新购卡。',
bold('因甲方自身原因未在有效期内使用的课时,在有效期届满后失效,乙方不再提供相应服务,也不予退费;甲方在有效期届满前已按第七条申请延期或暂停的除外。'),
'乙方在小程序中实时展示剩余课时、有效期及预约、上课记录,甲方可随时查询。',
'本会员卡为记名卡,仅限甲方本人使用,甲方不得将会员卡借予他人使用。',
]),
},
{
title: '第三条 课程预约与使用',
clauses: clauses([
`甲方通过乙方微信小程序预约课程,预约经乙方确认后成立。${unlimited ? '' : '乙方确认预约时扣除 1 节课时。'}`,
bold(`已确认的预约,甲方在课程开始前 ${cancelHoursLimit} 小时以前取消的,已扣课时自动退回;在课程开始前 ${cancelHoursLimit} 小时内取消或未到场上课的,该节课时视为已使用。尚未经乙方确认的预约,取消不扣课时。`),
'甲方因突发疾病等紧急情况无法按时上课的,应及时联系乙方说明,乙方可视情况退回该节课时。',
'甲方迟到的,课程按原定时间结束,不予顺延。',
'因乙方原因(如授课人员临时无法授课、场地设施故障等)取消或调整课程的,乙方应尽快通知甲方并退回相应课时,由甲方另行预约。',
]),
},
{
title: '第四条 乙方的权利和义务',
clauses: clauses([
'乙方为依法登记的个体工商户,按本合同约定在经营场所为甲方提供普拉提课程服务,授课人员应接受过普拉提专业培训。',
'乙方应保证器械、设施符合安全使用要求并定期维护保养,在训练中给予必要的安全指导和保护。',
'乙方不得在合同履行期间单方提高价格、减少课时或降低服务标准。',
bold('乙方决定停业或迁移经营场所的,应至少提前 30 日在经营场所及小程序显著位置公告并通知甲方;甲方可选择继续履行或解除合同,选择解除的,按第六条第 3 款退款。'),
'乙方出现可能影响正常提供服务的重大经营风险时,应当停止收取预付款。',
'乙方仅为履行本合同收集、使用甲方的姓名、手机号及预约、上课记录等信息,并采取必要措施保护信息安全,除法律法规另有规定外不向第三方提供。身体评估、照片等信息由甲方另行授权。',
'因乙方过错造成甲方人身或财产损害的,乙方依法承担相应责任。',
'乙方发现甲方身体状况明显不适宜训练时,有权暂停当节训练并建议甲方就医,因此暂停的课程不扣除课时。',
]),
},
{
title: '第五条 甲方的权利和义务',
clauses: clauses([
'甲方应如实填写姓名及联系方式,信息变更时应及时告知乙方。',
'甲方应遵守场馆安全规定,按授课人员指导规范使用器械,训练前如实告知身体状况。',
'甲方应妥善保管随身物品,贵重物品请勿带入场馆;因乙方过错造成物品损失的,乙方依法承担相应责任。',
'出于训练安全考虑,甲方不得携带 12 周岁以下儿童进入训练区域。',
'甲方享有查询消费记录、剩余课时及有效期的权利,并可依本合同申请延期、暂停、转让和退费。',
]),
},
{
title: '第六条 退费',
clauses: clauses([
bold('七日无理由退款:甲方自付款之日起 7 日内(含第 7 日),且付款后未上课、未发生课时扣减的,可申请退还全部实付金额,乙方不收取任何费用。本款同样适用于续购订单。'),
bold(
unlimited
? '甲方原因退费:超过前款期限或已上课的,甲方可随时申请解除合同并退费。退款金额 = 实付金额 × 剩余有效天数 ÷ 总有效天数;尚未生效的期次全额退还。乙方不收取手续费、违约金或其他费用。'
: `甲方原因退费:超过前款期限或已上课的,甲方可随时申请解除合同并退费。退款金额 = 实付金额 − 已使用课时数 × 单节标准课价(${standardPrice});计算结果小于零时按零计,乙方不向甲方追收差额。乙方不收取手续费、违约金或其他费用。${
isMonthly
? '月卡按期计算,已使用课时数指本合同对应期次已扣减的课时,尚未生效的期次全额退还。'
: terms.renewal
? '续购课时并入原会员卡的,已使用课时按扣减先后顺序优先计入较早购买的课时。'
: ''
}`,
),
bold(
`乙方原因退费:因乙方停业、迁址给甲方造成明显不便、无法按约提供服务、降低服务标准,或未经甲方同意将合同义务转让给第三方等原因解除合同的,${unlimited ? '按剩余有效天数占总有效天数的比例退还实付金额' : '退款金额 = 实付金额 − 已使用课时数 × 本卡实付单价(实付金额 ÷ 课时总数)'},乙方并依法支付相应利息。`,
),
'甲方因怀孕、伤病等身体健康原因(需提供医疗机构证明)不宜继续训练的,可选择按第七条暂停,或按本条第 2 款解除合同。',
'退费申请可通过小程序或乙方联系电话提出。乙方应在双方确认退款金额后 7 个工作日内通过微信支付原路退回;退款完成后,对应会员卡剩余权益作废,本合同终止。',
'乙方赠送的课时或权益(如有)不计入已使用课时,也不折算退款,合同解除后一并失效。',
]),
},
{
title: '第七条 延期、暂停与转让',
clauses: clauses([
'甲方可在有效期内申请延期 1 次,延期不超过 30 天,乙方不收取费用。',
'甲方因怀孕、伤病等原因(需提供医疗机构证明)暂时不宜训练的,可申请暂停,暂停期间有效期相应顺延,暂停期限由双方根据医嘱协商确定。',
`甲方可将本卡剩余权益一次性整体转让给他人,转让自甲方通知乙方时对乙方生效,乙方不收取转让费用。受让人应在乙方小程序注册并确认本合同条款后预约课程,承继本卡剩余课时与有效期。${unlimited ? '不限次卡不得由多人分用。' : ''}`,
'延期、暂停及转让的结果以乙方在小程序中的记录为准。',
]),
},
{
title: '第八条 健康声明与运动安全',
clauses: clauses([
bold('甲方确认不存在以下不适宜普拉提运动的情形:严重心脑血管疾病、未控制的高血压或糖尿病、严重骨质疏松、急性损伤或手术恢复期、癫痫、传染性疾病、精神类疾病发作期,以及医生建议避免运动的其他情形。如存在上述情形,或处于孕期、产后恢复期,甲方应在上课前告知乙方并遵医嘱训练。'),
'甲方不得在饮酒后或身体明显不适时参加训练;训练中如有不适,应立即告知授课人员并停止训练。',
'乙方提供的是运动训练服务,不提供医疗诊断或治疗,不对训练效果作出疗效承诺。',
bold('甲方隐瞒或未如实告知健康状况,导致自身损害的,由甲方自行承担相应责任;乙方有过错的,依法承担相应责任。'),
]),
},
{
title: '第九条 电子签署与通知',
clauses: clauses([
'双方同意以电子形式订立本合同。甲方通过本人微信账号登录乙方小程序,核对身份信息后手写电子签名,与在纸质合同上签字具有同等法律效力。',
'乙方系统记录签署时间、合同内容摘要(SHA-256)、签名摘要及签署设备信息作为签署凭证;签署后合同内容不可修改。',
'乙方通过小程序消息或甲方预留手机号向甲方发送通知;甲方联系方式变更的,应及时更新。',
]),
},
{
title: '第十条 违约责任与争议解决',
clauses: clauses([
'任何一方违反本合同约定的,应依法承担违约责任。',
'履行本合同发生争议的,双方协商解决;协商不成的,甲方可向消费者协会或通过 12315 平台投诉申请调解,任何一方也可依法向有管辖权的人民法院提起诉讼。',
]),
},
{
title: '第十一条 其他',
clauses: clauses([
bold('乙方已就本合同中加粗显示的条款(包括有效期、取消预约、退费、健康声明等与甲方有重大利害关系的内容)向甲方作出提示,甲方可随时要求乙方予以说明。'),
'本合同未尽事宜,双方可另行书面约定;补充约定与本合同不一致的,以补充约定为准,但不得减损甲方依法享有的权利。',
'本合同电子文本由双方各自留存,具有同等效力。',
]),
},
]
}
export function buildContractDocument(input: ContractRenderInput): ContractDocument {
return {
title: input.source === MembershipContractSource.LEGACY ? '普拉提会员卡服务合同(存量会员补签)' : '普拉提会员卡服务合同',
contractNo: input.contractNo,
templateVersion: CONTRACT_TEMPLATE_VERSION,
notice: '签署提示:本合同依据《中华人民共和国民法典》《中华人民共和国消费者权益保护法》《消费者权益保护法实施条例》等规定订立。请您仔细阅读全部条款,加粗条款与您的权益密切相关,请重点阅读;如有疑问,可在签署前联系乙方说明。',
partyA: buildPartyARows(input.partyA),
partyB: buildPartyBRows(input.partyB),
cardRows: buildCardRows(input),
sections: buildSections(input),
commitment: '甲方承诺:本人确认所填写的姓名、联系方式真实有效,已如实告知身体健康状况,已仔细阅读并理解本合同全部条款(特别是加粗条款),自愿签署本合同。',
}
}
export function hashContractDocument(document: ContractDocument): string {
return createHash('sha256').update(JSON.stringify(document)).digest('hex')
}

View File

@@ -0,0 +1,126 @@
import { Body, Controller, Get, Param, Post, Put, Query, Req, Res, UseGuards } from '@nestjs/common'
import type { Request, Response } from 'express'
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 { ContractRequestMeta, ContractService, ContractViewer } from './contract.service'
import {
AdminContractQueryDto,
CreateLegacyContractDto,
SavePartyADto,
SignContractDto,
TemplatePreviewQueryDto,
UpdateContractPartyDto,
UpdatePartySignatureDto,
VoidContractDto,
} from './dto/contract.dto'
interface JwtUser {
sub: string
role: UserRole
}
function viewerOf(user: JwtUser): ContractViewer {
return { id: user.sub, isAdmin: user.role === UserRole.ADMIN }
}
function metaOf(request: Request): ContractRequestMeta {
const forwarded = request.headers['x-forwarded-for']
const ip = (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(',')[0]?.trim() || request.ip || null
const userAgent = request.headers['user-agent']?.slice(0, 300) ?? null
return { ip: ip ? ip.slice(0, 64) : null, userAgent }
}
@Controller('contracts')
@UseGuards(JwtAuthGuard)
export class ContractController {
constructor(private readonly contractService: ContractService) {}
@Get('mine')
listMine(@CurrentUser('sub') userId: string) {
return this.contractService.listMine(userId)
}
@Get('pending-summary')
pendingSummary(@CurrentUser('sub') userId: string) {
return this.contractService.getPendingSummary(userId)
}
@Get('template-preview')
templatePreview(@CurrentUser('sub') userId: string, @Query() query: TemplatePreviewQueryDto) {
return this.contractService.templatePreview(userId, query.cardTypeId)
}
@Get('by-order/:orderId')
findByOrder(@CurrentUser('sub') userId: string, @Param('orderId') orderId: string) {
return this.contractService.findByOrder(userId, orderId)
}
@Get(':id')
detail(@CurrentUser() user: JwtUser, @Param('id') id: string, @Req() request: Request) {
return this.contractService.getDetail(id, viewerOf(user), metaOf(request))
}
@Put(':id/party-a')
savePartyA(@CurrentUser('sub') userId: string, @Param('id') id: string, @Body() dto: SavePartyADto) {
return this.contractService.savePartyA(userId, id, dto)
}
@Post(':id/sign')
sign(@CurrentUser('sub') userId: string, @Param('id') id: string, @Body() dto: SignContractDto, @Req() request: Request) {
return this.contractService.sign(userId, id, dto, metaOf(request))
}
@Get(':id/pdf')
async pdf(@CurrentUser() user: JwtUser, @Param('id') id: string, @Req() request: Request, @Res() response: Response) {
const { buffer, fileName } = await this.contractService.exportPdf(id, viewerOf(user), metaOf(request))
response.setHeader('Content-Type', 'application/pdf')
response.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`)
response.setHeader('Cache-Control', 'no-store')
response.send(buffer)
}
}
@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
export class ContractAdminController {
constructor(private readonly contractService: ContractService) {}
@Get('contract-party')
getParty() {
return this.contractService.getPartyConfig()
}
@Put('contract-party')
updateParty(@Body() dto: UpdateContractPartyDto) {
return this.contractService.updatePartyConfig(dto)
}
@Put('contract-party/signature')
updatePartySignature(@Body() dto: UpdatePartySignatureDto) {
return this.contractService.updatePartySignature(dto.image)
}
@Get('contracts')
list(@Query() query: AdminContractQueryDto) {
return this.contractService.adminList(query)
}
@Post('contracts/legacy-sync')
syncLegacy() {
return this.contractService.syncLegacyContracts()
}
@Post('contracts/legacy')
createLegacy(@CurrentUser('sub') adminId: string, @Body() dto: CreateLegacyContractDto) {
return this.contractService.createLegacyByAdmin(adminId, dto)
}
@Post('contracts/:id/void')
voidContract(@CurrentUser('sub') adminId: string, @Param('id') id: string, @Body() dto: VoidContractDto) {
return this.contractService.voidContract(adminId, id, dto.reason)
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common'
import { PrismaModule } from '../prisma/prisma.module'
import { StudioModule } from '../studio/studio.module'
import { ContractAdminController, ContractController } from './contract.controller'
import { ContractPdfService } from './contract-pdf.service'
import { ContractService } from './contract.service'
@Module({
imports: [PrismaModule, StudioModule],
controllers: [ContractController, ContractAdminController],
providers: [ContractService, ContractPdfService],
exports: [ContractService],
})
export class ContractModule {}

View File

@@ -0,0 +1,701 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common'
import { CardType, ContractParty, Membership, MembershipContract, Order, Prisma } from '@prisma/client'
import { createHash, randomInt } from 'crypto'
import {
CardTypeCategory,
CONTRACT_SIGNATURE_MAX_BYTES,
MembershipContractSource,
MembershipContractStatus,
MembershipStatus,
OrderStatus,
getMembershipEffectiveState,
requiresMembershipContract,
type AdminMembershipContractRow,
type ContractDocument,
type ContractPartyConfig,
type ContractPendingSummary,
type LegacyContractCandidate,
type LegacyContractSyncResult,
type MembershipContractDetail,
type MembershipContractSummary,
type PaginatedData,
} from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { StudioService } from '../studio/studio.service'
import { ContractPdfService } from './contract-pdf.service'
import {
CONTRACT_TEMPLATE_VERSION,
buildContractDocument,
hashContractDocument,
type ContractPartyBInput,
type ContractTerms,
} from './contract-template'
import {
AdminContractQueryDto,
CreateLegacyContractDto,
SavePartyADto,
SignContractDto,
UpdateContractPartyDto,
} from './dto/contract.dto'
export interface ContractViewer {
id: string
isAdmin: boolean
}
export interface ContractRequestMeta {
ip: string | null
userAgent: string | null
}
type ContractWithUser = MembershipContract & {
user: { id: string; nickname: string; phone: string | null; openid: string }
}
type LegacyMembership = Membership & {
cardType: CardType
user: { id: string; nickname: string; phone: string | null }
orders: Order[]
contracts: Array<{ orderId: string | null; source: string }>
}
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
const SIGNATURE_MIN_BYTES = 500
const USER_SELECT = { id: true, nickname: true, phone: true, openid: true } as const
function sha256(input: Buffer | string): string {
return createHash('sha256').update(input).digest('hex')
}
function toDataUri(image: Uint8Array | Buffer | null): string | null {
return image ? `data:image/png;base64,${Buffer.from(image).toString('base64')}` : null
}
export function decodeSignaturePng(input: string): Buffer {
const base64 = input.replace(/^data:image\/png;base64,/, '')
const image = Buffer.from(base64, 'base64')
if (image.length < SIGNATURE_MIN_BYTES || image.length > CONTRACT_SIGNATURE_MAX_BYTES || !image.subarray(0, 8).equals(PNG_MAGIC)) {
throw new BadRequestException('签名图片无效,请重新签名')
}
return image
}
@Injectable()
export class ContractService {
private readonly logger = new Logger(ContractService.name)
constructor(
private readonly prisma: PrismaService,
private readonly studioService: StudioService,
private readonly pdfService: ContractPdfService,
) {}
// ─── Issuance ──────────────────────────────────────────────────────────────
async issueForPaidOrder(
tx: Prisma.TransactionClient,
params: { order: Order; cardType: CardType; membership: Membership; paidAt: Date },
): Promise<MembershipContract | null> {
const { order, cardType, membership, paidAt } = params
if (!requiresMembershipContract(cardType)) return null
const existing = await tx.membershipContract.findUnique({ where: { orderId: order.id } })
if (existing) return existing
// Times cards reuse the earlier record; expired balances are reset rather than merged.
const reusedRecord = cardType.type === CardTypeCategory.TIMES && membership.createdAt < order.createdAt
const renewal = reusedRecord && membership.remainingTimes !== cardType.totalTimes
const listPrice = Number(cardType.price)
const amount = Number(order.amount)
const terms: ContractTerms = {
kind: cardType.type === CardTypeCategory.DURATION ? 'MONTHLY' : 'MULTI_TIMES',
cardTypeId: cardType.id,
cardName: cardType.name,
lessonCount: cardType.totalTimes,
durationDays: cardType.durationDays,
listPrice,
amount,
discountNote: amount < listPrice ? (order.inviteInviterId ? '已享好友邀请 95 折' : '已享优惠') : null,
orderNos: [order.orderNo],
paidAt: paidAt.toISOString(),
acceptedAt: order.contractAcceptedAt?.toISOString() ?? null,
startDate: (reusedRecord ? paidAt : membership.startDate).toISOString(),
expireDate: membership.expireDate.toISOString(),
scheduled: membership.startDate > paidAt,
renewal,
availableTimesAfter: renewal ? membership.remainingTimes : null,
legacyRemainingTimes: null,
legacyAsOf: null,
}
return this.createContract(tx, {
userId: order.userId,
membershipId: membership.id,
orderId: order.id,
source: MembershipContractSource.PURCHASE,
requireBeforeBooking: true,
terms,
createdBy: null,
})
}
private async createContract(
tx: Prisma.TransactionClient,
data: {
userId: string
membershipId: string
orderId: string | null
source: MembershipContractSource
requireBeforeBooking: boolean
terms: ContractTerms
createdBy: string | null
},
): Promise<MembershipContract> {
const contract = await tx.membershipContract.create({
data: {
...data,
contractNo: await this.generateContractNo(tx),
templateVersion: CONTRACT_TEMPLATE_VERSION,
terms: data.terms as unknown as Prisma.InputJsonValue,
},
})
await tx.contractEvent.create({
data: { contractId: contract.id, action: 'CREATED', actorId: data.createdBy, detail: { source: data.source } },
})
return contract
}
private async generateContractNo(tx: Prisma.TransactionClient): Promise<string> {
const china = new Date(Date.now() + 8 * 3600 * 1000)
const date = `${china.getUTCFullYear()}${String(china.getUTCMonth() + 1).padStart(2, '0')}${String(china.getUTCDate()).padStart(2, '0')}`
for (let attempt = 0; attempt < 5; attempt++) {
const contractNo = `MP${date}${String(randomInt(0, 1_000_000)).padStart(6, '0')}`
const taken = await tx.membershipContract.findUnique({ where: { contractNo }, select: { id: true } })
if (!taken) return contractNo
}
throw new ConflictException('合同编号生成失败,请重试')
}
// ─── Legacy memberships ────────────────────────────────────────────────────
async syncLegacyContracts(userId?: string): Promise<LegacyContractSyncResult> {
const now = new Date()
const memberships: LegacyMembership[] = await this.prisma.membership.findMany({
where: {
...(userId ? { userId } : {}),
status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED] },
expireDate: { gt: now },
inviteRewardGrants: { none: {} },
},
include: {
cardType: true,
user: { select: { id: true, nickname: true, phone: true } },
orders: { where: { status: OrderStatus.PAID }, orderBy: { paidAt: 'asc' } },
contracts: { where: { status: { not: MembershipContractStatus.VOIDED } }, select: { orderId: true, source: true } },
},
orderBy: { createdAt: 'asc' },
})
let created = 0
const needsAmount: LegacyContractCandidate[] = []
for (const membership of memberships) {
if (!requiresMembershipContract(membership.cardType)) continue
const state = getMembershipEffectiveState(membership, now)
if (state.status !== MembershipStatus.ACTIVE && state.status !== MembershipStatus.SCHEDULED) continue
if (membership.contracts.some(contract => contract.source === MembershipContractSource.LEGACY)) continue
const covered = new Set(membership.contracts.map(contract => contract.orderId))
const uncovered = membership.orders.filter(order => !covered.has(order.id))
if (!uncovered.length) {
if (!membership.contracts.length) needsAmount.push(this.toLegacyCandidate(membership, state.remainingTimes))
continue
}
const amount = uncovered.reduce((sum, order) => sum + Number(order.amount), 0)
if (await this.createLegacyContract(membership.id, amount, uncovered, null)) created++
}
return { created, needsAmount }
}
async createLegacyByAdmin(adminId: string, dto: CreateLegacyContractDto): Promise<MembershipContractSummary> {
const membership = await this.prisma.membership.findUnique({ where: { id: dto.membershipId }, include: { cardType: true } })
if (!membership) throw new NotFoundException('会员卡不存在')
if (!requiresMembershipContract(membership.cardType)) throw new BadRequestException('该卡种无需签署合同')
const state = getMembershipEffectiveState(membership)
if (state.status !== MembershipStatus.ACTIVE && state.status !== MembershipStatus.SCHEDULED) {
throw new BadRequestException('会员卡已过期或已用完,无需补签')
}
const orders = await this.prisma.order.findMany({
where: { membershipId: membership.id, status: OrderStatus.PAID, contract: { is: null } },
orderBy: { paidAt: 'asc' },
})
const contract = await this.createLegacyContract(membership.id, dto.amount, orders, adminId)
if (!contract) throw new ConflictException('该会员卡已有补签合同')
return this.toSummary(contract)
}
private async createLegacyContract(
membershipId: string,
amount: number,
orders: Order[],
createdBy: string | null,
): Promise<MembershipContract | null> {
return this.prisma.$transaction(async tx => {
const membership = await tx.membership.findUnique({ where: { id: membershipId }, include: { cardType: true } })
if (!membership) return null
await tx.$queryRaw`SELECT id FROM users WHERE id = ${membership.userId} FOR UPDATE`
const duplicate = await tx.membershipContract.findFirst({
where: {
membershipId,
source: MembershipContractSource.LEGACY,
status: { not: MembershipContractStatus.VOIDED },
},
select: { id: true },
})
if (duplicate) return null
const now = new Date()
const state = getMembershipEffectiveState(membership, now)
const lastPaid = orders.reduce<Date | null>((latest, order) => (order.paidAt && (!latest || order.paidAt > latest) ? order.paidAt : latest), null)
const terms: ContractTerms = {
kind: membership.cardType.type === CardTypeCategory.DURATION ? 'MONTHLY' : 'MULTI_TIMES',
cardTypeId: membership.cardTypeId,
cardName: membership.cardType.name,
lessonCount: membership.totalTimes ?? membership.cardType.totalTimes,
durationDays: membership.cardType.durationDays,
listPrice: Number(membership.cardType.price),
amount,
discountNote: null,
orderNos: orders.map(order => order.orderNo),
paidAt: lastPaid?.toISOString() ?? null,
acceptedAt: null,
startDate: membership.startDate.toISOString(),
expireDate: membership.expireDate.toISOString(),
scheduled: membership.startDate > now,
renewal: false,
availableTimesAfter: null,
legacyRemainingTimes: state.remainingTimes,
legacyAsOf: now.toISOString(),
}
return this.createContract(tx, {
userId: membership.userId,
membershipId,
orderId: null,
source: MembershipContractSource.LEGACY,
requireBeforeBooking: false,
terms,
createdBy,
})
})
}
private toLegacyCandidate(membership: LegacyMembership, remainingTimes: number | null): LegacyContractCandidate {
return {
membershipId: membership.id,
user: { ...membership.user },
cardName: membership.cardType.name,
kind: membership.cardType.type === CardTypeCategory.DURATION ? 'MONTHLY' : 'MULTI_TIMES',
remainingTimes,
totalTimes: membership.totalTimes,
startDate: membership.startDate.toISOString(),
expireDate: membership.expireDate.toISOString(),
}
}
// ─── Member ────────────────────────────────────────────────────────────────
async getPendingSummary(userId: string): Promise<ContractPendingSummary> {
await this.syncLegacyQuietly(userId)
const pending = await this.prisma.membershipContract.findMany({
where: { userId, status: MembershipContractStatus.PENDING_SIGN },
select: { id: true, requireBeforeBooking: true },
orderBy: [{ requireBeforeBooking: 'desc' }, { createdAt: 'asc' }],
})
return {
pendingCount: pending.length,
blockingCount: pending.filter(item => item.requireBeforeBooking).length,
firstPendingId: pending[0]?.id ?? null,
}
}
async listMine(userId: string): Promise<MembershipContractSummary[]> {
await this.syncLegacyQuietly(userId)
const contracts = await this.prisma.membershipContract.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
})
const rank = (status: string) => (status === MembershipContractStatus.PENDING_SIGN ? 0 : status === MembershipContractStatus.SIGNED ? 1 : 2)
return contracts
.map(contract => this.toSummary(contract))
.sort((left, right) => rank(left.status) - rank(right.status))
}
async findByOrder(userId: string, orderId: string): Promise<MembershipContractSummary | null> {
const contract = await this.prisma.membershipContract.findFirst({ where: { orderId, userId } })
return contract ? this.toSummary(contract) : null
}
async getDetail(id: string, viewer: ContractViewer, meta?: ContractRequestMeta): Promise<MembershipContractDetail> {
const contract = await this.loadForViewer(id, viewer)
if (meta && contract.userId === viewer.id && contract.status === MembershipContractStatus.PENDING_SIGN) {
await this.prisma.contractEvent.create({
data: { contractId: id, action: 'VIEWED', actorId: viewer.id, ip: meta.ip, userAgent: meta.userAgent },
})
}
return this.toDetail(contract)
}
async savePartyA(userId: string, id: string, dto: SavePartyADto): Promise<MembershipContractDetail> {
const contract = await this.loadForViewer(id, { id: userId, isAdmin: false })
if (contract.userId !== userId) throw new NotFoundException('合同不存在')
if (contract.status !== MembershipContractStatus.PENDING_SIGN) throw new ConflictException('合同已签署或已作废')
const updated = await this.prisma.membershipContract.update({
where: { id },
data: { partyName: dto.realName, partyPhone: dto.phone },
include: { user: { select: USER_SELECT } },
})
return this.toDetail(updated)
}
async sign(userId: string, id: string, dto: SignContractDto, meta: ContractRequestMeta): Promise<MembershipContractDetail> {
const contract = await this.loadForViewer(id, { id: userId, isAdmin: false })
if (contract.userId !== userId) throw new NotFoundException('合同不存在')
if (contract.status !== MembershipContractStatus.PENDING_SIGN) throw new ConflictException('合同已签署或已作废')
if (!contract.partyName || !contract.partyPhone) throw new BadRequestException('请先填写姓名和手机号')
const { document, contentSha256, party, missingFields } = await this.renderPending(contract)
if (missingFields.length) throw new BadRequestException('场馆合同信息尚未配置完成,请联系馆主')
if (contentSha256 !== dto.contentSha256) throw new ConflictException('合同内容已更新,请重新阅读后签署')
const signature = decodeSignaturePng(dto.signatureImage)
const signatureSha256 = sha256(signature)
const signedAt = new Date()
await this.prisma.$transaction(async tx => {
const claimed = await tx.membershipContract.updateMany({
where: { id, status: MembershipContractStatus.PENDING_SIGN },
data: {
status: MembershipContractStatus.SIGNED,
document: document as unknown as Prisma.InputJsonValue,
contentSha256,
signatureImage: signature,
signatureSha256,
partyBSignatureImage: party.signatureImage,
partyBName: `${party.businessName}(经营者:${party.operatorName})`,
signedAt,
signerIp: meta.ip,
signerUserAgent: meta.userAgent,
signerDevice: dto.device ?? null,
},
})
if (!claimed.count) throw new ConflictException('合同已签署或已作废')
await tx.contractEvent.create({
data: {
contractId: id,
action: 'SIGNED',
actorId: userId,
ip: meta.ip,
userAgent: meta.userAgent,
detail: { contentSha256, signatureSha256, partyBSignatureSha256: party.signatureSha256, device: dto.device ?? null },
},
})
})
this.logger.log(`Contract signed: ${contract.contractNo}`)
return this.getDetail(id, { id: userId, isAdmin: false })
}
async exportPdf(id: string, viewer: ContractViewer, meta: ContractRequestMeta): Promise<{ buffer: Buffer; fileName: string }> {
const contract = await this.loadForViewer(id, viewer)
if (contract.status !== MembershipContractStatus.SIGNED || !contract.document || !contract.signatureImage || !contract.signedAt) {
throw new BadRequestException('合同签署完成后才能导出')
}
const buffer = await this.pdfService.render({
document: contract.document as unknown as ContractDocument,
contentSha256: contract.contentSha256 ?? '',
signerName: contract.partyName ?? '',
signedAt: contract.signedAt.toISOString(),
memberSignature: Buffer.from(contract.signatureImage),
partyBSignature: contract.partyBSignatureImage ? Buffer.from(contract.partyBSignatureImage) : null,
partyBName: contract.partyBName ?? '',
})
await this.prisma.contractEvent.create({
data: { contractId: id, action: 'EXPORTED', actorId: viewer.id, ip: meta.ip, userAgent: meta.userAgent },
})
return { buffer, fileName: `会员卡服务合同-${contract.contractNo}.pdf` }
}
/** 购买前预览:金额按当前优惠计算,日期以支付确认为准。 */
async templatePreview(userId: string, cardTypeId: string): Promise<{ document: ContractDocument }> {
const [cardType, user, referral] = await Promise.all([
this.prisma.cardType.findUnique({ where: { id: cardTypeId } }),
this.prisma.user.findUnique({ where: { id: userId }, select: { openid: true, phone: true } }),
this.prisma.inviteReferral.findUnique({ where: { inviteeId: userId } }),
])
if (!cardType || !cardType.isActive || !user) throw new NotFoundException('卡种不存在或已下架')
if (!requiresMembershipContract(cardType)) throw new BadRequestException('该卡种无需签署合同')
const listPrice = Number(cardType.price)
const amount = referral ? Math.round(listPrice * 95 / 100) : listPrice
const terms: ContractTerms = {
kind: cardType.type === CardTypeCategory.DURATION ? 'MONTHLY' : 'MULTI_TIMES',
cardTypeId,
cardName: cardType.name,
lessonCount: cardType.totalTimes,
durationDays: cardType.durationDays,
listPrice,
amount,
discountNote: referral ? '已享好友邀请 95 折' : null,
orderNos: [],
paidAt: null,
acceptedAt: null,
startDate: null,
expireDate: null,
scheduled: false,
renewal: false,
availableTimesAfter: null,
legacyRemainingTimes: null,
legacyAsOf: null,
}
const [party, studio] = await Promise.all([this.getPartyRecord(), this.studioService.getInfo()])
const document = buildContractDocument({
contractNo: '支付后生成',
source: MembershipContractSource.PURCHASE,
terms,
partyA: { realName: null, phone: user.phone, openid: user.openid },
partyB: await this.toPartyBInput(party),
cancelHoursLimit: studio.cancelHoursLimit,
})
return { document }
}
// ─── Admin ─────────────────────────────────────────────────────────────────
async getPartyConfig(): Promise<ContractPartyConfig> {
const party = await this.getPartyRecord()
const input = await this.toPartyBInput(party)
return {
businessName: party.businessName,
brandName: party.brandName,
creditCode: party.creditCode,
operatorName: party.operatorName,
phone: party.phone,
address: party.address,
premisesType: input.premisesType,
leaseEndDate: input.leaseEndDate,
standardLessonPrice: input.standardLessonPrice,
standardLessonPriceConfigured: party.standardLessonPrice > 0,
signatureImage: toDataUri(party.signatureImage),
signatureUpdatedAt: party.signatureUpdatedAt?.toISOString() ?? null,
missingFields: this.missingFields(party, input),
}
}
async updatePartyConfig(dto: UpdateContractPartyDto): Promise<ContractPartyConfig> {
const party = await this.getPartyRecord()
const { leaseEndDate, ...rest } = dto
await this.prisma.contractParty.update({
where: { id: party.id },
data: {
...rest,
...(leaseEndDate !== undefined ? { leaseEndDate: leaseEndDate ? new Date(leaseEndDate) : null } : {}),
},
})
return this.getPartyConfig()
}
async updatePartySignature(image: string): Promise<ContractPartyConfig> {
const party = await this.getPartyRecord()
const signature = decodeSignaturePng(image)
await this.prisma.contractParty.update({
where: { id: party.id },
data: { signatureImage: signature, signatureSha256: sha256(signature), signatureUpdatedAt: new Date() },
})
return this.getPartyConfig()
}
async adminList(query: AdminContractQueryDto): Promise<PaginatedData<AdminMembershipContractRow>> {
const page = query.page ?? 1
const limit = query.limit ?? 20
const search = query.search?.trim()
const where: Prisma.MembershipContractWhereInput = {
...(query.status ? { status: query.status } : {}),
...(search
? {
OR: [
{ contractNo: { contains: search } },
{ partyName: { contains: search } },
{ user: { nickname: { contains: search } } },
{ user: { phone: { contains: search } } },
],
}
: {}),
}
const [items, total] = await Promise.all([
this.prisma.membershipContract.findMany({
where,
include: { user: { select: { id: true, nickname: true, phone: true } } },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.membershipContract.count({ where }),
])
return {
items: items.map(item => ({ ...this.toSummary(item), user: { ...item.user }, partyName: item.partyName })),
total,
page,
limit,
}
}
async voidContract(adminId: string, id: string, reason: string): Promise<MembershipContractSummary> {
const contract = await this.prisma.membershipContract.findUnique({ where: { id } })
if (!contract) throw new NotFoundException('合同不存在')
if (contract.status === MembershipContractStatus.VOIDED) throw new ConflictException('合同已作废')
const updated = await this.prisma.$transaction(async tx => {
const result = await tx.membershipContract.update({
where: { id },
data: { status: MembershipContractStatus.VOIDED, voidedAt: new Date(), voidedBy: adminId, voidReason: reason },
})
await tx.contractEvent.create({
data: { contractId: id, action: 'VOIDED', actorId: adminId, detail: { reason, previousStatus: contract.status } },
})
return result
})
return this.toSummary(updated)
}
// ─── Helpers ───────────────────────────────────────────────────────────────
private async syncLegacyQuietly(userId: string) {
try {
await this.syncLegacyContracts(userId)
} catch (error) {
this.logger.warn(`Legacy contract sync failed for ${userId}: ${(error as Error).message}`)
}
}
private async loadForViewer(id: string, viewer: ContractViewer): Promise<ContractWithUser> {
const contract = await this.prisma.membershipContract.findUnique({
where: { id },
include: { user: { select: USER_SELECT } },
})
if (!contract || (!viewer.isAdmin && contract.userId !== viewer.id)) throw new NotFoundException('合同不存在')
return contract
}
private async getPartyRecord(): Promise<ContractParty> {
const existing = await this.prisma.contractParty.findFirst()
return existing ?? this.prisma.contractParty.create({ data: {} })
}
private async toPartyBInput(party: ContractParty): Promise<ContractPartyBInput> {
let standardLessonPrice = party.standardLessonPrice
if (standardLessonPrice <= 0) {
const single = await this.prisma.cardType.findFirst({
where: { isActive: true, type: CardTypeCategory.TIMES, totalTimes: 1 },
orderBy: { price: 'asc' },
})
standardLessonPrice = single ? Number(single.price) : 0
}
return {
businessName: party.businessName,
brandName: party.brandName,
creditCode: party.creditCode,
operatorName: party.operatorName,
phone: party.phone,
address: party.address,
premisesType: party.premisesType === 'OWNED' ? 'OWNED' : 'LEASED',
leaseEndDate: party.leaseEndDate?.toISOString() ?? null,
standardLessonPrice,
}
}
private missingFields(party: ContractParty, input: ContractPartyBInput): string[] {
const missing: string[] = []
if (!party.businessName.trim()) missing.push('经营者名称')
if (!party.creditCode.trim()) missing.push('统一社会信用代码')
if (!party.operatorName.trim()) missing.push('经营者姓名')
if (!party.phone.trim()) missing.push('联系电话')
if (!party.address.trim()) missing.push('经营场所')
if (input.standardLessonPrice <= 0) missing.push('单节标准课价')
if (!party.signatureImage) missing.push('经营者签名')
return missing
}
private async renderPending(contract: ContractWithUser) {
const [party, studio] = await Promise.all([this.getPartyRecord(), this.studioService.getInfo()])
const partyB = await this.toPartyBInput(party)
const document = buildContractDocument({
contractNo: contract.contractNo,
source: contract.source as MembershipContractSource,
terms: contract.terms as unknown as ContractTerms,
partyA: { realName: contract.partyName, phone: contract.partyPhone, openid: contract.user.openid },
partyB,
cancelHoursLimit: studio.cancelHoursLimit,
})
return { document, contentSha256: hashContractDocument(document), party, missingFields: this.missingFields(party, partyB) }
}
private toSummary(contract: MembershipContract): MembershipContractSummary {
const terms = contract.terms as unknown as ContractTerms
return {
id: contract.id,
contractNo: contract.contractNo,
status: contract.status as MembershipContractStatus,
source: contract.source as MembershipContractSource,
requireBeforeBooking: contract.requireBeforeBooking,
cardName: terms.cardName,
kind: terms.kind,
amount: terms.amount,
startDate: terms.startDate ?? contract.createdAt.toISOString(),
expireDate: terms.expireDate ?? contract.createdAt.toISOString(),
createdAt: contract.createdAt.toISOString(),
signedAt: contract.signedAt?.toISOString() ?? null,
voidReason: contract.voidReason,
}
}
private async toDetail(contract: ContractWithUser): Promise<MembershipContractDetail> {
const summary = this.toSummary(contract)
if (contract.status === MembershipContractStatus.SIGNED && contract.document && contract.signatureImage && contract.signedAt) {
return {
...summary,
partyName: contract.partyName,
partyPhone: contract.partyPhone,
document: contract.document as unknown as ContractDocument,
contentSha256: contract.contentSha256 ?? '',
signature: {
signerName: contract.partyName ?? '',
signedAt: contract.signedAt.toISOString(),
memberSignature: toDataUri(contract.signatureImage) ?? '',
partyBSignature: toDataUri(contract.partyBSignatureImage),
partyBName: contract.partyBName ?? '',
contentSha256: contract.contentSha256 ?? '',
},
signable: false,
unsignableReason: null,
}
}
const { document, contentSha256, missingFields } = await this.renderPending(contract)
const pending = contract.status === MembershipContractStatus.PENDING_SIGN
const unsignableReason = !pending
? '合同已作废'
: missingFields.length
? '场馆合同信息尚未配置完成,请联系馆主'
: null
return {
...summary,
partyName: contract.partyName,
partyPhone: contract.partyPhone ?? contract.user.phone,
document,
contentSha256,
signature: null,
signable: pending && !missingFields.length,
unsignableReason,
}
}
}

View File

@@ -0,0 +1,137 @@
import { Transform, Type } from 'class-transformer'
import {
Equals,
IsBoolean,
IsDateString,
IsIn,
IsInt,
IsOptional,
IsString,
IsUUID,
Length,
Matches,
Max,
MaxLength,
Min,
ValidateIf,
} from 'class-validator'
import { CONTRACT_REAL_NAME_MAX_LENGTH, CONTRACT_SIGNATURE_MAX_BYTES, MembershipContractStatus } from '@mp-pilates/shared'
const trim = ({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value)
const MAINLAND_MOBILE = /^1\d{10}$/
// 18 位统一社会信用代码,不含 I、O、Z、S、V。
const CREDIT_CODE = /^[0-9A-HJ-NPQRTUWXY]{2}\d{6}[0-9A-HJ-NPQRTUWXY]{10}$/
export class SavePartyADto {
@Transform(trim)
@IsString()
@Length(2, CONTRACT_REAL_NAME_MAX_LENGTH, { message: '请填写 2-30 个字的真实姓名' })
realName!: string
@Transform(trim)
@Matches(MAINLAND_MOBILE, { message: '请填写 11 位手机号' })
phone!: string
}
export class SignContractDto {
@Matches(/^[a-f0-9]{64}$/)
contentSha256!: string
@IsString()
@MaxLength(Math.ceil(CONTRACT_SIGNATURE_MAX_BYTES * 1.4))
signatureImage!: string
@IsBoolean()
@Equals(true, { message: '请确认已阅读并同意合同条款' })
agreedTerms!: boolean
@IsBoolean()
@Equals(true, { message: '请确认健康声明' })
confirmedHealth!: boolean
@IsOptional()
@IsString()
@MaxLength(200)
device?: string
}
export class UpdateContractPartyDto {
@IsOptional() @Transform(trim) @IsString() @MaxLength(100)
businessName?: string
@IsOptional() @Transform(trim) @IsString() @MaxLength(60)
brandName?: string
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toUpperCase() : value))
@ValidateIf((_object, value) => value !== '')
@Matches(CREDIT_CODE, { message: '统一社会信用代码格式不正确' })
creditCode?: string
@IsOptional() @Transform(trim) @IsString() @MaxLength(30)
operatorName?: string
@IsOptional() @Transform(trim) @IsString() @MaxLength(20)
phone?: string
@IsOptional() @Transform(trim) @IsString() @MaxLength(200)
address?: string
@IsOptional()
@IsIn(['LEASED', 'OWNED'])
premisesType?: 'LEASED' | 'OWNED'
@IsOptional()
@ValidateIf((_object, value) => value !== null)
@IsDateString()
leaseEndDate?: string | null
@IsOptional()
@IsInt()
@Min(0)
@Max(10_000_000)
standardLessonPrice?: number
}
export class UpdatePartySignatureDto {
@IsString()
@MaxLength(Math.ceil(CONTRACT_SIGNATURE_MAX_BYTES * 1.4))
image!: string
}
export class CreateLegacyContractDto {
@IsUUID()
membershipId!: string
@IsInt()
@Min(0)
@Max(100_000_000)
amount!: number
}
export class VoidContractDto {
@Transform(trim)
@IsString()
@Length(2, 200, { message: '请填写 2-200 字的作废原因' })
reason!: string
}
export class AdminContractQueryDto {
@IsOptional()
@IsIn(Object.values(MembershipContractStatus))
status?: MembershipContractStatus
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
page?: number
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(50)
limit?: number
@IsOptional() @Transform(trim) @IsString() @MaxLength(50)
search?: string
}
export class TemplatePreviewQueryDto {
@IsUUID()
cardTypeId!: string
}

View File

@@ -15,6 +15,8 @@ async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule, { logger })
const configService = app.get(ConfigService)
// Contract signatures are posted as base64 PNG.
app.useBodyParser('json', { limit: '1mb' })
// Public, dependency-free educational pages; keep them outside the JSON API prefix.
app.useStaticAssets(join(__dirname, '..', 'public', 'learn'), {

View File

@@ -1,4 +1,4 @@
import { IsOptional, IsUUID } from 'class-validator'
import { IsBoolean, IsOptional, IsUUID } from 'class-validator'
export class CreateOrderDto {
@IsUUID()
@@ -7,4 +7,8 @@ export class CreateOrderDto {
@IsUUID()
@IsOptional()
inviterId?: string
@IsBoolean()
@IsOptional()
contractAccepted?: boolean
}

View File

@@ -33,7 +33,7 @@ export class PaymentController {
@CurrentUser('sub') userId: string,
@Body(new ValidationPipe({ whitelist: true })) dto: CreateOrderDto,
) {
return this.paymentService.createOrder(userId, dto.cardTypeId, dto.inviterId)
return this.paymentService.createOrder(userId, dto.cardTypeId, dto.inviterId, dto.contractAccepted)
}
/**

View File

@@ -6,9 +6,10 @@ 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'
import { ContractModule } from '../contract/contract.module'
@Module({
imports: [PrismaModule, InviteModule, MembershipModule, BodyPortraitModule],
imports: [PrismaModule, InviteModule, MembershipModule, BodyPortraitModule, ContractModule],
controllers: [PaymentController],
providers: [PaymentService, WechatPayService],
exports: [PaymentService, WechatPayService],

View File

@@ -6,12 +6,13 @@ import {
Optional,
} from '@nestjs/common'
import { CardType, Order } from '@prisma/client'
import { OrderStatus } from '@mp-pilates/shared'
import { OrderStatus, requiresMembershipContract } from '@mp-pilates/shared'
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'
import { ContractService } from '../contract/contract.service'
export interface CreateOrderResult {
order: Order
@@ -35,11 +36,12 @@ export class PaymentService {
private readonly inviteService: InviteService,
private readonly membershipService: MembershipService,
@Optional() private readonly portraitLifecycle?: BodyPortraitLifecycleService,
@Optional() private readonly contractService?: ContractService,
) {}
// ─── User: create order ────────────────────────────────────────────────────
async createOrder(userId: string, cardTypeId: string, inviterId?: string): Promise<CreateOrderResult> {
async createOrder(userId: string, cardTypeId: string, inviterId?: string, contractAccepted = false): Promise<CreateOrderResult> {
const cardType = await this.prisma.cardType.findUnique({ where: { id: cardTypeId } })
if (!cardType) {
@@ -71,6 +73,7 @@ export class PaymentService {
inviteInviterId: referral?.inviterId,
purchasedCategory: cardType.type,
status: OrderStatus.PENDING,
contractAcceptedAt: contractAccepted && requiresMembershipContract(cardType) ? new Date() : null,
},
})
@@ -150,6 +153,7 @@ export class PaymentService {
membershipId: membership.id,
},
})
await this.contractService?.issueForPaidOrder(tx, { order: existingOrder, cardType, membership, paidAt: now })
await this.inviteService.rewardPaidOrder(tx, existingOrder, now)
})