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

@@ -127,3 +127,9 @@ pnpm deploy:server # 部署后端到生产环境
- 案例支持 1–9 组双图对比或已合成单图,课时为正整数(节);标题、化名、练习背景和变化说明不作疗效承诺。授权记录只对管理员可见,发布需确认学员已单独同意公开宣传。
- 保存采用版本校验;撤回授权自动下架。游客首页优先展示最多 3 个案例,更多通过分页浏览;登录用户预约入口优先。无已发布案例时隐藏模块,故障不阻断首页。
- 迁移目录仅放 migration.sql;发布、回退、图片清理和真机验收约定放 docs/teaching-cases.md。未引用资产保留供重试,清理前核对引用且不得删除有效案例图片。
### 会员卡合同签署
- 独立 `contract` 模块(服务端 `src/contract`),共享契约在 `shared/src/types/contract.ts`,判定函数 `requiresMembershipContract` 在 `shared/src/contract.ts`;学员页面在 `pages/contract` 分包,馆主页面为 `pages/admin/contracts.vue`、`contract-settings.vue`。规格与发布说明在 `docs/membership-contract.md`。
- 只有月卡和多次卡签署,每笔支付订单一份合同,在支付回调发卡事务内签发;存量卡按未覆盖订单自动补签,无订单的由馆主填写金额发起。
- 合同正文只在服务端 `contract-template.ts` 生成;签署时比对内容 SHA-256 后冻结正文、双方签名和签署凭证,签署后不可修改。改条款须升级 `CONTRACT_TEMPLATE_VERSION`。
- 新购合同未签署不能自助预约(`requireBeforeBooking`),存量补签只提醒;乙方为个体工商户,显示「经营者」而非法定代表人。不得加入「最终解释权」、到期不退、转卡收费等格式条款。

View File

@@ -0,0 +1,53 @@
# 会员卡合同签署
## 适用范围
- 月卡(`DURATION`)与多次卡(`TIMES` 且课时 > 1 或不限次)必须签署;单次卡、体验卡不签。判定函数为 shared 的 `requiresMembershipContract`。
- 每笔支付订单对应一份合同(`orderId` 唯一)。次卡续费并入原会员卡时,续费订单单独签署续购合同。
- 存量会员卡:仍在有效期且有可用权益、但没有合同覆盖的卡,按未覆盖的已支付订单金额自动生成「存量会员补签」合同;后台直接开通、没有订单的卡,由馆主在「会员合同」页填写实付金额后发起。邀请奖励赠课卡不补签。
## 流程
1. 购卡确认弹窗勾选「已阅读并同意《会员卡服务合同》」,可预览合同范本;勾选时间写入 `orders.contract_accepted_at`。
2. 微信支付回调在发卡事务内签发合同(`PENDING_SIGN`),交易事实冻结在 `terms`。
3. 支付确认后跳转签署页:确认真实姓名与手机号 → 阅读全文至底部并勾选条款、健康声明 → 手写签名 → 提交。
4. 服务端重新渲染合同并比对 SHA-256,一致才签署;同时冻结正文、学员签名、乙方签名快照、签署 IP、UA 和设备信息,并记录 `contract_events`。
5. 个人中心「我的合同」查看状态、全文和双方签名,已签署合同可导出 PDF(文件内含合同编号与内容摘要)。
新购合同未签署前,学员不能用该卡自助预约(馆主代约不拦截);存量补签只提醒、不拦截。馆主可作废合同(如已线下签纸质合同、已退费),作废后解除预约拦截。
## 合同条款要点
条款由 `server/src/contract/contract-template.ts` 按卡种动态生成,模板版本 `2026-10-v1`。依据《消费者权益保护法实施条例》第 22 条及《最高人民法院关于审理预付式消费民事纠纷案件适用法律若干问题的解释》(2025-05-01 施行):
- 付款 7 日内且未上课,全额退款(续费同样适用)。
- 学员原因退费:实付 − 已用课时 × 单节标准课价,结果小于零按零,不收手续费;不限次卡按剩余天数比例退。
- 场馆原因退费:按本卡实付单价折算已用课时,并依法计息。
- 允许整体转让、免费延期 1 次(≤ 30 天)、伤病孕期可暂停;停业或迁址提前 30 日公告。
- 删除了模板中「最终解释权归场馆」「到期不退任何费用」「转卡收 10%」等可能被认定无效的格式条款;与学员重大利害相关的条款加粗提示。
单节标准课价在「合同签署设置」中配置,未配置时取在售单次卡价格。
## 配置与发布
1. 应用迁移 `20261028090000_membership_contracts`(仅新增表与 `orders.contract_accepted_at` 列),生成 Prisma Client,再发布 shared/server。
2. 新增依赖 `pdfkit`;中文字体随服务端发布在 `packages/server/assets/fonts/NotoSansSC-Regular.otf`(SIL OFL)。可用环境变量 `CONTRACT_PDF_FONT_PATH` 指向其他字体。
3. 小程序后台「开发管理 - 服务器域名」需将 API 域名加入 **downloadFile 合法域名**,否则无法导出 PDF。
4. 发布前由馆主在「管理中心 - 合同签署设置」填写经营者名称、统一社会信用代码、经营者、联系电话、经营场所,并手写经营者签名。未完善时学员可查看但不能签署。
5. 发布新版小程序。旧版小程序购卡不会勾选同意,但仍会生成待签合同,并在新版个人中心提示签署。
## 回退
- 服务端回退到上一版本即可,新增表保留不影响旧代码;不要删除 `membership_contracts`、`contract_events`,它们是签署凭证。
- 若需临时关闭预约拦截,可在后台作废对应待签合同,或回退 `booking.service.ts` 中的合同检查。
## 风险提示
- 自建手写签名属于一般电子签名,依靠微信身份、手机号、内容摘要、签名留存和审计日志形成证据链;如需 CA 认证的可靠电子签名,可后续接入第三方电子签服务。
- 部分城市(如上海)对单张预付卡金额(5000 元)、资金存管和备案另有规定,请按工作室所在地核实。合同正式使用前建议请律师审阅。
## 验证(2026-10-28)
- 服务端全量测试通过:38 个套件、605 项测试;新增合同模板、合同服务及预约拦截用例。
- shared/server 构建、小程序类型检查与微信小程序生产构建通过;本地生成样例 PDF 并逐页检查排版。
- 未在本地连接数据库做端到端验证(本地 `.env` 指向生产库),需在部署后按上述流程真机验收。

View File

@@ -0,0 +1,85 @@
<template>
<view class="contract-doc">
<text class="contract-doc__title">{{ document.title }}</text>
<text class="contract-doc__no">合同编号:{{ document.contractNo }}</text>
<text class="contract-doc__notice">{{ document.notice }}</text>
<view v-for="group in groups" :key="group.title" class="contract-doc__group">
<text class="contract-doc__heading">{{ group.title }}</text>
<view v-for="row in group.rows" :key="row.label" class="contract-doc__row">
<text class="contract-doc__label">{{ row.label }}</text>
<text class="contract-doc__value">{{ row.value }}</text>
</view>
</view>
<view v-for="section in document.sections" :key="section.title" class="contract-doc__section">
<text class="contract-doc__heading">{{ section.title }}</text>
<text
v-for="clause in section.clauses"
:key="clause.text"
class="contract-doc__clause"
:class="{ 'contract-doc__clause--bold': clause.emphasis }"
>{{ clause.text }}</text>
</view>
<text class="contract-doc__commitment">{{ document.commitment }}</text>
<view v-if="signature" class="contract-doc__signatures">
<view class="contract-doc__sign">
<text class="contract-doc__sign-label">甲方(签名)</text>
<image class="contract-doc__sign-image" :src="signature.memberSignature" mode="aspectFit" />
<text class="contract-doc__sign-meta">{{ signature.signerName }}</text>
<text class="contract-doc__sign-meta">{{ signedAtText }}</text>
</view>
<view class="contract-doc__sign">
<text class="contract-doc__sign-label">乙方(签章)</text>
<image v-if="signature.partyBSignature" class="contract-doc__sign-image" :src="signature.partyBSignature" mode="aspectFit" />
<text class="contract-doc__sign-meta">{{ signature.partyBName }}</text>
<text class="contract-doc__sign-meta">{{ signedAtText }}</text>
</view>
</view>
<text v-if="signature" class="contract-doc__digest">内容摘要(SHA-256):{{ signature.contentSha256 }}</text>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ContractDocument, ContractSignatureInfo } from '@mp-pilates/shared'
import { formatDateTimeFull } from '../utils/format'
const props = defineProps<{
document: ContractDocument
signature?: ContractSignatureInfo | null
}>()
const groups = computed(() => [
{ title: '甲方(会员)', rows: props.document.partyA },
{ title: '乙方(经营者)', rows: props.document.partyB },
{ title: '会员卡信息', rows: props.document.cardRows },
])
const signedAtText = computed(() => props.signature ? `签署于 ${formatDateTimeFull(props.signature.signedAt)}` : '')
</script>
<style lang="scss" scoped>
.contract-doc {
color: #3f3832;
&__title { display: block; text-align: center; font-family: 'Songti SC', 'STSong', serif; font-size: 36rpx; font-weight: 600; letter-spacing: 2rpx; }
&__no { display: block; text-align: center; margin-top: 10rpx; font-size: 22rpx; color: #9a8e83; }
&__notice { display: block; margin: 24rpx 0 8rpx; padding: 20rpx 22rpx; border-radius: 16rpx; background: #f4efe8; font-size: 23rpx; line-height: 1.8; color: #7b6f65; }
&__group, &__section { margin-top: 28rpx; }
&__heading { display: block; font-size: 28rpx; font-weight: 600; color: #4a4035; margin-bottom: 12rpx; }
&__row { display: flex; gap: 20rpx; padding: 14rpx 0; border-bottom: 1rpx solid #efe8e0; }
&__label { flex-shrink: 0; width: 170rpx; font-size: 24rpx; color: #9a8e83; line-height: 1.7; }
&__value { flex: 1; font-size: 25rpx; line-height: 1.7; overflow-wrap: anywhere; }
&__clause { display: block; font-size: 25rpx; line-height: 1.9; margin-bottom: 12rpx; text-align: justify; }
&__clause--bold { font-weight: 600; color: #2f2924; }
&__commitment { display: block; margin-top: 30rpx; font-size: 25rpx; line-height: 1.9; font-weight: 600; }
&__signatures { display: flex; gap: 20rpx; margin-top: 32rpx; }
&__sign { flex: 1; min-width: 0; padding: 20rpx; border-radius: 16rpx; background: #faf6f1; }
&__sign-label { display: block; font-size: 23rpx; color: #8b7f74; }
&__sign-image { display: block; width: 100%; height: 140rpx; margin: 10rpx 0; }
&__sign-meta { display: block; font-size: 21rpx; line-height: 1.7; color: #8b7f74; overflow-wrap: anywhere; }
&__digest { display: block; margin-top: 20rpx; font-size: 19rpx; line-height: 1.6; color: #b0a498; word-break: break-all; }
}
</style>

View File

@@ -0,0 +1,41 @@
<template>
<view v-if="summary && summary.pendingCount > 0" class="contract-banner" hover-class="contract-banner--hover" @tap="open">
<view class="contract-banner__mark">签</view>
<view class="contract-banner__body">
<text class="contract-banner__title">{{ summary.pendingCount }} 份会员卡合同待签署</text>
<text class="contract-banner__copy">{{ summary.blockingCount ? '签署后即可预约课程' : '请核对会员卡信息并完成补签' }}</text>
</view>
<text class="contract-banner__action">去签署 ›</text>
</view>
</template>
<script setup lang="ts">
import type { ContractPendingSummary } from '@mp-pilates/shared'
import { CONTRACT_ROUTES } from '../utils/contract'
const props = defineProps<{ summary: ContractPendingSummary | null }>()
function open() {
const summary = props.summary
if (!summary) return
uni.navigateTo({
url: summary.pendingCount === 1 && summary.firstPendingId
? `${CONTRACT_ROUTES.SIGN}?id=${summary.firstPendingId}`
: CONTRACT_ROUTES.LIST,
})
}
</script>
<style lang="scss" scoped>
.contract-banner {
display: flex; align-items: center; gap: 20rpx;
margin: 24rpx 32rpx 0; padding: 24rpx 26rpx;
border-radius: 24rpx; background: #f6ece4; border: 1rpx solid #ecdccf;
&--hover { opacity: 0.85; }
&__mark { width: 60rpx; height: 60rpx; flex-shrink: 0; border-radius: 50%; background: #9c7a6e; color: #fff; font-size: 26rpx; display: flex; align-items: center; justify-content: center; font-family: 'Songti SC', 'STSong', serif; }
&__body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6rpx; }
&__title { font-size: 27rpx; color: #5a4a40; font-weight: 500; }
&__copy { font-size: 22rpx; color: #9a8478; }
&__action { flex-shrink: 0; font-size: 24rpx; color: #9c7a6e; }
}
</style>

View File

@@ -8,6 +8,7 @@
<view v-else class="profile-menu__item" :class="{ 'profile-menu__item--admin': item.isAdmin }"
hover-class="profile-menu__item--hover" hover-stay-time="150" @tap="handleTap(item)">
<text class="profile-menu__title">{{ item.title }}</text>
<text v-if="item.badge" class="profile-menu__badge">{{ item.badge }}</text>
<text class="profile-menu__arrow">›</text>
</view>
</template>
@@ -26,11 +27,13 @@ interface MenuItem {
isAdmin?: boolean
action?: 'clear' | 'notifications'
requireAuth?: boolean
badge?: string
}
const props = defineProps<{
isAdmin: boolean
requireAuth?: boolean
contractPendingCount?: number
}>()
const emit = defineEmits<{
@@ -61,6 +64,14 @@ const menuItems = computed<MenuItem[]>(() => {
path: '/pages/profile/bookings',
requireAuth: true,
},
{
key: 'contracts',
type: 'item',
title: '我的合同',
path: '/pages/contract/index',
requireAuth: true,
badge: props.contractPendingCount ? `${props.contractPendingCount} 份待签` : undefined,
},
{
key: 'info',
type: 'item',
@@ -124,6 +135,7 @@ function handleTap(item: MenuItem) {
&__item--hover { background: #f4f1ec; }
&__item--admin { background: #eff3ed; .profile-menu__title { color: #617d73; } }
&__title { flex: 1; font-size: 27rpx; font-weight: 400; color: #6f655e; }
&__badge { flex-shrink: 0; padding: 4rpx 14rpx; border-radius: 999rpx; background: #f6ece4; color: #9c7a6e; font-size: 21rpx; }
&__arrow { font-size: 30rpx; color: #b2a79e; line-height: 1; }
&__separator { height: 12rpx; background: #fbf9f6; }
}

View File

@@ -0,0 +1,185 @@
<template>
<view class="signature-pad" :style="{ height: height + 'rpx' }">
<canvas
:id="canvasId"
type="2d"
class="signature-pad__canvas"
disable-scroll
@touchstart="handleStart"
@touchmove="handleMove"
@touchend="handleEnd"
@touchcancel="handleEnd"
/>
<view class="signature-pad__baseline" />
<text v-if="empty" class="signature-pad__hint">{{ placeholder }}</text>
</view>
</template>
<script setup lang="ts">
import { getCurrentInstance, nextTick, onMounted, ref } from 'vue'
interface Point { x: number; y: number }
interface CanvasNode {
width: number
height: number
getContext(type: '2d'): CanvasContext2D
}
interface CanvasContext2D {
scale(x: number, y: number): void
clearRect(x: number, y: number, w: number, h: number): void
beginPath(): void
moveTo(x: number, y: number): void
quadraticCurveTo(cx: number, cy: number, x: number, y: number): void
arc(x: number, y: number, r: number, start: number, end: number): void
fill(): void
stroke(): void
lineWidth: number
lineCap: string
lineJoin: string
strokeStyle: string
fillStyle: string
}
interface CanvasTouch { x: number; y: number }
const props = withDefaults(defineProps<{ height?: number; placeholder?: string }>(), {
height: 380,
placeholder: '请在此区域用正楷手写签名',
})
const emit = defineEmits<{ (e: 'change', empty: boolean): void }>()
const MIN_STROKE_LENGTH = 60
const instance = getCurrentInstance()
const canvasId = `signature-${Math.random().toString(36).slice(2, 10)}`
const empty = ref(true)
let canvas: CanvasNode | null = null
let ctx: CanvasContext2D | null = null
let size = { width: 0, height: 0 }
let last: Point | null = null
let lastMid: Point | null = null
let lastWidth = 3
let strokeLength = 0
function init() {
uni.createSelectorQuery()
.in(instance?.proxy)
.select(`#${canvasId}`)
.fields({ node: true, size: true }, () => {})
.exec((result: Array<{ node?: CanvasNode; width: number; height: number } | null>) => {
const info = result?.[0]
if (!info?.node) return
const ratio = uni.getWindowInfo().pixelRatio || 2
canvas = info.node
size = { width: info.width, height: info.height }
canvas.width = Math.round(info.width * ratio)
canvas.height = Math.round(info.height * ratio)
ctx = canvas.getContext('2d')
ctx.scale(ratio, ratio)
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.strokeStyle = '#1f1a17'
ctx.fillStyle = '#1f1a17'
})
}
// 2d canvas 的触点坐标(x、y)相对画布左上角。
function touchPoint(event: TouchEvent): Point | null {
const { touches, changedTouches } = event as unknown as { touches?: CanvasTouch[]; changedTouches?: CanvasTouch[] }
const touch = touches?.[0] ?? changedTouches?.[0]
return touch ? { x: touch.x, y: touch.y } : null
}
function handleStart(event: TouchEvent) {
const point = touchPoint(event)
if (!point || !ctx) return
last = point
lastMid = point
lastWidth = 3.2
ctx.beginPath()
ctx.arc(point.x, point.y, 1.4, 0, Math.PI * 2)
ctx.fill()
}
function handleMove(event: TouchEvent) {
const point = touchPoint(event)
if (!point || !ctx || !last || !lastMid) return
const distance = Math.hypot(point.x - last.x, point.y - last.y)
if (distance < 1) return
// 快速书写时笔画变细,模拟真实笔锋。
const width = Math.max(1.8, Math.min(4.2, 4.4 - distance * 0.12))
const mid = { x: (last.x + point.x) / 2, y: (last.y + point.y) / 2 }
ctx.lineWidth = (lastWidth + width) / 2
ctx.beginPath()
ctx.moveTo(lastMid.x, lastMid.y)
ctx.quadraticCurveTo(last.x, last.y, mid.x, mid.y)
ctx.stroke()
strokeLength += distance
last = point
lastMid = mid
lastWidth = width
if (empty.value && strokeLength >= MIN_STROKE_LENGTH) {
empty.value = false
emit('change', false)
}
}
function handleEnd() {
last = null
lastMid = null
}
function clear() {
if (ctx) ctx.clearRect(0, 0, size.width, size.height)
strokeLength = 0
if (!empty.value) {
empty.value = true
emit('change', true)
}
}
/** 导出透明底 PNG(data URI),用于合同签名。 */
function exportPng(): Promise<string> {
return new Promise((resolve, reject) => {
if (!canvas || empty.value) {
reject(new Error('请先完成签名'))
return
}
// type="2d" 画布按节点导出,不使用 canvasId。
const options = {
canvas,
fileType: 'png',
destWidth: Math.round(size.width * 2),
destHeight: Math.round(size.height * 2),
success: (res: UniApp.CanvasToTempFilePathRes) => {
uni.getFileSystemManager().readFile({
filePath: res.tempFilePath,
encoding: 'base64',
success: (file) => resolve(`data:image/png;base64,${file.data as string}`),
fail: (err) => reject(new Error(err.errMsg || '签名读取失败')),
})
},
fail: (err: { errMsg?: string }) => reject(new Error(err.errMsg || '签名导出失败')),
}
uni.canvasToTempFilePath(options as unknown as UniApp.CanvasToTempFilePathOptions, instance?.proxy)
})
}
defineExpose({ clear, exportPng, isEmpty: () => empty.value })
onMounted(() => {
nextTick(() => setTimeout(init, 60))
})
</script>
<style lang="scss" scoped>
.signature-pad {
position: relative;
width: 100%;
border-radius: 24rpx;
border: 2rpx dashed #cdbfb2;
background: #fffdf9;
overflow: hidden;
&__canvas { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 1; }
&__baseline { position: absolute; left: 48rpx; right: 48rpx; bottom: 30%; border-bottom: 1rpx solid #e6ddd3; }
&__hint { position: absolute; left: 0; right: 0; top: 42%; text-align: center; color: #b9ab9e; font-size: 26rpx; letter-spacing: 2rpx; pointer-events: none; }
}
</style>

View File

@@ -272,6 +272,42 @@
"style": {
"navigationStyle": "custom"
}
},
{
"path": "contracts",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "contract-settings",
"style": {
"navigationStyle": "custom"
}
}
]
},
{
"root": "pages/contract",
"pages": [
{
"path": "index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "sign",
"style": {
"navigationStyle": "custom",
"disableScroll": true
}
},
{
"path": "detail",
"style": {
"navigationStyle": "custom"
}
}
]
},

View File

@@ -0,0 +1,289 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight + 'px' }">
<CustomNavBar title="合同签署设置" show-back />
<view v-if="loading" class="state"><view class="skeleton" /><view class="skeleton" /></view>
<view v-else-if="error" class="state">
<text class="state-title">暂时无法读取设置</text>
<text class="state-copy">{{ error }}</text>
<button class="retry-button" @tap="load">重新加载</button>
</view>
<view v-else class="content">
<view v-if="missingFields.length" class="alert">
<text class="alert-title">以下信息未完善,学员暂时无法签署合同</text>
<text class="alert-copy">{{ missingFields.join('、') }}</text>
</view>
<view v-else class="alert alert--ok">
<text class="alert-title">合同信息已完善,学员可正常签署</text>
<text class="alert-copy">修改后,尚未签署的合同会按新信息生成;已签署的合同内容不受影响。</text>
</view>
<view class="panel">
<view class="panel-heading"><text class="section-number">01</text><text class="section-title">乙方(经营者)信息</text></view>
<text class="hint">工作室为个体工商户,合同中显示为「经营者」而非法定代表人。请与营业执照保持一致。</text>
<view class="field">
<text class="field-label">经营者名称</text>
<input v-model="form.businessName" class="field-input" maxlength="100" placeholder="营业执照上的「名称」;无字号时填经营者姓名" />
</view>
<view class="field">
<text class="field-label">统一社会信用代码</text>
<input v-model="form.creditCode" class="field-input" maxlength="18" placeholder="18 位,营业执照左上角" />
</view>
<view class="field">
<text class="field-label">经营者姓名</text>
<input v-model="form.operatorName" class="field-input" maxlength="30" placeholder="营业执照上的「经营者」" />
</view>
<view class="field">
<text class="field-label">门店名称(选填)</text>
<input v-model="form.brandName" class="field-input" maxlength="60" placeholder="对外品牌名,如 MP Pilates Studio" />
</view>
<view class="field">
<text class="field-label">联系电话</text>
<input v-model="form.phone" class="field-input" maxlength="20" placeholder="用于退费、通知等联系" />
</view>
<view class="field">
<text class="field-label">经营场所</text>
<textarea v-model="form.address" class="field-textarea" maxlength="200" auto-height placeholder="营业执照登记的经营场所,即授课地点" :show-confirm-bar="false" />
</view>
<view class="field field--row">
<text class="field-label">场所性质</text>
<view class="segment">
<text :class="{ selected: form.premisesType === 'LEASED' }" @tap="form.premisesType = 'LEASED'">租赁</text>
<text :class="{ selected: form.premisesType === 'OWNED' }" @tap="form.premisesType = 'OWNED'">自有</text>
</view>
</view>
<picker v-if="form.premisesType === 'LEASED'" mode="date" :value="form.leaseEndDate" @change="onLeaseDateChange">
<view class="field field--row">
<text class="field-label">租期至(选填)</text>
<text class="field-value">{{ form.leaseEndDate || '选择日期' }} ›</text>
</view>
</picker>
<text class="field-note">如实披露场所租期,是预付式消费的常见监管要求;会员卡有效期不宜超过租期。</text>
</view>
<view class="panel">
<view class="panel-heading"><text class="section-number">02</text><text class="section-title">退费结算价</text></view>
<text class="hint">学员自身原因退卡时,已上课时按「单节标准课价」扣除。应为对外公示的单次课价格。</text>
<view class="field">
<text class="field-label">单节标准课价(元)</text>
<input v-model="form.standardLessonPriceYuan" class="field-input" type="digit" :placeholder="fallbackPriceHint" />
</view>
<text class="field-note">{{ priceNote }}</text>
</view>
<view class="panel">
<view class="panel-heading"><text class="section-number">03</text><text class="section-title">经营者签名</text></view>
<text class="hint">签名会作为乙方签章出现在每份合同中,签署时自动加载。建议由经营者本人书写。</text>
<template v-if="signing">
<SignaturePad ref="pad" :height="360" placeholder="请经营者本人手写签名" @change="onSignatureChange" />
<view class="sign-actions">
<button class="ghost-button" :disabled="savingSignature" @tap="cancelSigning">取消</button>
<button class="ghost-button" :disabled="savingSignature" @tap="pad?.clear()">清除</button>
<button class="save-button" :loading="savingSignature" :disabled="savingSignature || signatureEmpty" @tap="saveSignature">保存签名</button>
</view>
</template>
<template v-else>
<view class="signature-preview">
<image v-if="signatureImage" :src="signatureImage" mode="aspectFit" class="signature-image" />
<text v-else class="field-note">尚未设置签名</text>
</view>
<button class="add-button" @tap="signing = true">{{ signatureImage ? '重新签名' : '手写签名' }}</button>
</template>
</view>
<view class="save-bar">
<text class="save-status">{{ isDirty ? '有未保存的修改' : '信息已同步' }}</text>
<button class="save-button" :loading="saving" :disabled="saving || !isDirty" @tap="save">保存</button>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import type { ContractPartyConfig, ContractPremisesType } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import SignaturePad from '../../components/SignaturePad.vue'
import { useAdminStore } from './stores/admin'
import { getSystemLayout } from '../../utils/system'
import { getErrorMessage } from '../../utils/auth'
import { formatPrice } from '../../utils/format'
interface PartyForm {
businessName: string
brandName: string
creditCode: string
operatorName: string
phone: string
address: string
premisesType: ContractPremisesType
leaseEndDate: string
standardLessonPriceYuan: string
}
const navBarHeight = getSystemLayout().navBarHeight
const adminStore = useAdminStore()
const loading = ref(true)
const error = ref('')
const saving = ref(false)
const savingSignature = ref(false)
const signing = ref(false)
const signatureEmpty = ref(true)
const pad = ref<InstanceType<typeof SignaturePad> | null>(null)
const config = ref<ContractPartyConfig | null>(null)
const form = ref<PartyForm>(emptyForm())
const original = ref('')
function emptyForm(): PartyForm {
return { businessName: '', brandName: '', creditCode: '', operatorName: '', phone: '', address: '', premisesType: 'LEASED', leaseEndDate: '', standardLessonPriceYuan: '' }
}
function toForm(data: ContractPartyConfig): PartyForm {
return {
businessName: data.businessName,
brandName: data.brandName,
creditCode: data.creditCode,
operatorName: data.operatorName,
phone: data.phone,
address: data.address,
premisesType: data.premisesType,
leaseEndDate: data.leaseEndDate ? data.leaseEndDate.slice(0, 10) : '',
standardLessonPriceYuan: data.standardLessonPriceConfigured ? formatPrice(data.standardLessonPrice).replace(/\.00$/, '') : '',
}
}
const isDirty = computed(() => original.value !== '' && JSON.stringify(form.value) !== original.value)
const missingFields = computed(() => config.value?.missingFields ?? [])
const signatureImage = computed(() => config.value?.signatureImage ?? null)
const fallbackPriceHint = computed(() => config.value && !config.value.standardLessonPriceConfigured && config.value.standardLessonPrice > 0
? `未填写时按单次卡售价 ¥${formatPrice(config.value.standardLessonPrice)}`
: '例如 450')
const priceNote = computed(() => config.value?.standardLessonPrice
? `当前合同使用 ¥${formatPrice(config.value.standardLessonPrice)}${config.value.standardLessonPriceConfigured ? '' : '(取自单次卡售价)'};扣除总额不超过学员实付金额。`
: '尚无单次卡可参考,请填写单节标准课价。')
function applyConfig(data: ContractPartyConfig) {
config.value = data
form.value = toForm(data)
original.value = JSON.stringify(form.value)
}
async function load() {
loading.value = true
error.value = ''
try {
applyConfig(await adminStore.fetchContractParty())
} catch (err) {
error.value = getErrorMessage(err, '加载失败,请稍后重试')
} finally {
loading.value = false
}
}
async function save() {
if (saving.value || !isDirty.value) return
const priceText = form.value.standardLessonPriceYuan.trim()
const price = priceText ? Math.round(Number(priceText) * 100) : 0
if (priceText && (!Number.isFinite(price) || price < 0)) {
uni.showToast({ title: '请填写正确的课价', icon: 'none' })
return
}
saving.value = true
try {
applyConfig(await adminStore.saveContractParty({
businessName: form.value.businessName,
brandName: form.value.brandName,
creditCode: form.value.creditCode,
operatorName: form.value.operatorName,
phone: form.value.phone,
address: form.value.address,
premisesType: form.value.premisesType,
leaseEndDate: form.value.premisesType === 'LEASED' && form.value.leaseEndDate ? form.value.leaseEndDate : null,
standardLessonPrice: price,
}))
uni.showToast({ title: '已保存', icon: 'success' })
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '保存失败,请重试'), icon: 'none' })
} finally {
saving.value = false
}
}
function onLeaseDateChange(event: { detail: { value: string } }) {
form.value.leaseEndDate = event.detail.value
}
function onSignatureChange(isEmpty: boolean) {
signatureEmpty.value = isEmpty
}
function cancelSigning() {
signing.value = false
signatureEmpty.value = true
}
async function saveSignature() {
if (!pad.value || savingSignature.value) return
savingSignature.value = true
try {
const image = await pad.value.exportPng()
const data = await adminStore.saveContractPartySignature(image)
config.value = data
signing.value = false
signatureEmpty.value = true
uni.showToast({ title: '签名已保存', icon: 'success' })
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '保存失败,请重试'), icon: 'none' })
} finally {
savingSignature.value = false
}
}
onLoad(() => { void load() })
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; box-sizing: border-box; background: #f7f6ef; color: #515747; }
.content { padding: 28rpx 28rpx calc(180rpx + env(safe-area-inset-bottom)); }
button { margin: 0; padding: 0; border: 0; border-radius: 12rpx; font-size: 25rpx; line-height: 1.5; }
button::after { border: 0; }
button[disabled] { opacity: .42; }
.alert { padding: 24rpx 26rpx; margin-bottom: 24rpx; border-radius: 20rpx; background: #fbf0e8; border: 1rpx solid #efdccd; }
.alert--ok { background: #eef2e6; border-color: #dde4d2; }
.alert-title { display: block; font-size: 26rpx; color: #8f6450; }
.alert--ok .alert-title { color: #5b704c; }
.alert-copy { display: block; margin-top: 8rpx; font-size: 23rpx; line-height: 1.7; color: #9a8073; }
.alert--ok .alert-copy { color: #7f8a72; }
.panel { background: #fffdf9; padding: 30rpx 26rpx; margin-bottom: 24rpx; border-radius: 24rpx; border: 1rpx solid #e6e5d9; }
.panel-heading { display: flex; align-items: center; gap: 18rpx; margin-bottom: 14rpx; }
.section-number { color: #a2ad91; font-size: 25rpx; font-family: Georgia, serif; }
.section-title { font-size: 30rpx; font-weight: 500; color: #45513d; }
.hint { display: block; color: #92907d; font-size: 23rpx; line-height: 1.8; margin-bottom: 16rpx; }
.field { padding: 20rpx 0; border-bottom: 1rpx solid #eeece2; }
.field--row { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; }
.field-label { display: block; font-size: 23rpx; color: #8e977f; }
.field-input { margin-top: 8rpx; height: 56rpx; font-size: 28rpx; color: #4a5040; }
.field-textarea { box-sizing: border-box; width: 100%; margin-top: 8rpx; min-height: 56rpx; font-size: 28rpx; line-height: 1.6; color: #4a5040; }
.field-value { font-size: 26rpx; color: #6d7a5d; }
.field-note { display: block; color: #a09c88; font-size: 21rpx; line-height: 1.7; margin-top: 14rpx; }
.segment { display: flex; padding: 6rpx; border-radius: 14rpx; background: #eaece1; }
.segment text { padding: 10rpx 28rpx; border-radius: 10rpx; font-size: 24rpx; color: #858b76; }
.segment .selected { background: #fffdf8; color: #4f6243; }
.signature-preview { height: 200rpx; border-radius: 16rpx; background: #f5f4ec; display: flex; align-items: center; justify-content: center; margin-bottom: 20rpx; }
.signature-image { width: 100%; height: 180rpx; }
.add-button { width: 100%; height: 84rpx; border: 1rpx dashed #bdc6ab; background: #f3f5eb; color: #74835f; }
.sign-actions { display: flex; gap: 16rpx; margin-top: 20rpx; }
.ghost-button { flex: 1; height: 80rpx; background: #eef0e6; color: #6d7a5d; }
.save-bar { position: fixed; z-index: 10; bottom: 0; left: 0; right: 0; display: flex; align-items: center; gap: 24rpx; padding: 24rpx 30rpx calc(24rpx + env(safe-area-inset-bottom)); background: #fffdf9; border-top: 1rpx solid #e6e5d9; }
.save-status { flex: 1; color: #9c9e8b; font-size: 23rpx; }
.save-button { min-width: 220rpx; padding: 22rpx 30rpx; border-radius: 999rpx; color: #fffdf5; background: #657857; font-size: 27rpx; }
.sign-actions .save-button { flex: 2; min-width: 0; }
.state { padding: 70rpx 34rpx; text-align: center; }
.state-title { display: block; font-size: 30rpx; line-height: 1.6; color: #6c775d; }
.state-copy { display: block; color: #96917f; font-size: 25rpx; line-height: 1.8; margin-top: 20rpx; }
.retry-button { padding: 22rpx; margin: 28rpx auto; width: 240rpx; color: #657857; background: #e9edde; }
.skeleton { height: 280rpx; border-radius: 24rpx; margin-bottom: 26rpx; background: #eaece2; animation: breathe 1.2s ease-in-out infinite alternate; }
@keyframes breathe { from { opacity: .45; } to { opacity: .85; } }
</style>

View File

@@ -0,0 +1,228 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight + 'px' }">
<CustomNavBar title="会员合同" show-back />
<view class="toolbar">
<view class="tabs">
<text v-for="tab in TABS" :key="tab.label" class="tab" :class="{ 'tab--active': status === tab.value }" @tap="selectTab(tab.value)">{{ tab.label }}</text>
</view>
<input v-model="search" class="search" placeholder="搜索姓名 / 昵称 / 手机号 / 合同编号" confirm-type="search" @input="onSearchInput" />
</view>
<view v-if="candidates.length" class="legacy">
<text class="legacy-title">{{ candidates.length }} 张存量会员卡需补录实付金额</text>
<text class="legacy-copy">这些会员卡由后台直接开通,没有支付订单。填写学员实际支付的金额后,系统会生成待签署的补签合同。</text>
<view v-for="item in candidates" :key="item.membershipId" class="legacy-item">
<view class="legacy-info">
<text class="legacy-name">{{ item.user.nickname || '未命名学员' }}{{ item.user.phone ? ' · ' + item.user.phone : '' }}</text>
<text class="legacy-meta">{{ item.cardName }} · {{ item.remainingTimes === null ? '不限次' : '剩余 ' + item.remainingTimes + ' 次' }} · 至 {{ formatChinaDate(item.expireDate) }}</text>
</view>
<input v-model="amounts[item.membershipId]" class="legacy-input" type="digit" placeholder="实付(元)" />
<button class="legacy-button" :disabled="creating === item.membershipId" @tap="createLegacy(item)">发起</button>
</view>
</view>
<view v-if="loading && !items.length" class="state"><view class="skeleton" /><view class="skeleton" /></view>
<view v-else-if="error && !items.length" class="state">
<text class="state-copy">{{ error }}</text>
<button class="retry-button" @tap="reload">重新加载</button>
</view>
<view v-else-if="!items.length" class="state"><text class="state-copy">暂无合同</text></view>
<view v-for="item in items" :key="item.id" class="row" hover-class="row--hover" @tap="openDetail(item)">
<view class="row-top">
<text class="row-name">{{ item.partyName || item.user.nickname || '未命名学员' }}</text>
<text class="row-status" :class="'row-status--' + item.status.toLowerCase()">{{ CONTRACT_STATUS_LABEL[item.status] }}</text>
</view>
<text class="row-meta">{{ item.cardName }} · ¥{{ formatPrice(item.amount) }}{{ item.source === MembershipContractSource.LEGACY ? ' · 存量补签' : '' }}</text>
<text class="row-meta">{{ item.contractNo }} · {{ item.signedAt ? '签署于 ' + formatDateTimeFull(item.signedAt) : '生成于 ' + formatDateTimeFull(item.createdAt) }}</text>
<text v-if="item.voidReason" class="row-meta">作废原因:{{ item.voidReason }}</text>
<view v-if="item.status !== MembershipContractStatus.VOIDED" class="row-actions">
<text class="row-action" @tap.stop="voidItem(item)">作废</text>
</view>
</view>
<text v-if="items.length" class="footer">{{ hasMore ? (loading ? '加载中…' : '上拉加载更多') : '没有更多了' }}</text>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad, onReachBottom, onShow } from '@dcloudio/uni-app'
import {
MembershipContractSource,
MembershipContractStatus,
type AdminMembershipContractRow,
type LegacyContractCandidate,
} from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { useAdminStore } from './stores/admin'
import { CONTRACT_ROUTES, CONTRACT_STATUS_LABEL } from '../../utils/contract'
import { formatChinaDate, formatDateTimeFull, formatPrice } from '../../utils/format'
import { getErrorMessage } from '../../utils/auth'
import { getSystemLayout } from '../../utils/system'
const TABS: Array<{ label: string; value?: MembershipContractStatus }> = [
{ label: '全部' },
{ label: '待签署', value: MembershipContractStatus.PENDING_SIGN },
{ label: '已签署', value: MembershipContractStatus.SIGNED },
{ label: '已作废', value: MembershipContractStatus.VOIDED },
]
const PAGE_SIZE = 20
const navBarHeight = getSystemLayout().navBarHeight
const adminStore = useAdminStore()
const status = ref<MembershipContractStatus | undefined>(undefined)
const search = ref('')
const items = ref<AdminMembershipContractRow[]>([])
const page = ref(1)
const hasMore = ref(true)
const loading = ref(false)
const error = ref('')
const candidates = ref<LegacyContractCandidate[]>([])
const amounts = ref<Record<string, string>>({})
const creating = ref('')
let sequence = 0
let searchTimer: ReturnType<typeof setTimeout> | undefined
let loaded = false
async function fetchPage(reset: boolean) {
const seq = ++sequence
if (reset) { page.value = 1; hasMore.value = true }
if (!hasMore.value) return
loading.value = true
error.value = ''
try {
const result = await adminStore.fetchAdminContracts({ page: page.value, limit: PAGE_SIZE, status: status.value, search: search.value.trim() || undefined })
if (seq !== sequence) return
items.value = reset ? [...result.items] : [...items.value, ...result.items]
hasMore.value = page.value * PAGE_SIZE < result.total
page.value += 1
} catch (err) {
if (seq === sequence) error.value = getErrorMessage(err, '加载失败,请重试')
} finally {
if (seq === sequence) loading.value = false
}
}
async function syncLegacy() {
try {
const result = await adminStore.syncLegacyContracts()
candidates.value = [...result.needsAmount]
if (result.created) uni.showToast({ title: `已生成 ${result.created} 份存量补签合同`, icon: 'none' })
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '存量会员卡同步失败'), icon: 'none' })
}
}
function reload() {
void fetchPage(true)
}
function selectTab(value?: MembershipContractStatus) {
if (status.value === value) return
status.value = value
reload()
}
function onSearchInput() {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(reload, 300)
}
async function createLegacy(item: LegacyContractCandidate) {
const text = (amounts.value[item.membershipId] ?? '').trim()
const amount = Math.round(Number(text) * 100)
if (!text || !Number.isFinite(amount) || amount < 0) {
uni.showToast({ title: '请填写实付金额', icon: 'none' })
return
}
const confirm = await uni.showModal({ title: '发起补签合同?', content: `${item.user.nickname || '该学员'} · ${item.cardName},实付 ¥${formatPrice(amount)}。学员将在个人中心看到待签署合同。` })
if (!confirm.confirm) return
creating.value = item.membershipId
try {
await adminStore.createLegacyContract({ membershipId: item.membershipId, amount })
candidates.value = candidates.value.filter(candidate => candidate.membershipId !== item.membershipId)
uni.showToast({ title: '已发起补签', icon: 'success' })
reload()
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '发起失败,请重试'), icon: 'none' })
} finally {
creating.value = ''
}
}
async function voidItem(item: AdminMembershipContractRow) {
const result = await uni.showModal({
title: '作废合同',
content: '',
editable: true,
placeholderText: item.status === MembershipContractStatus.SIGNED ? '请填写作废原因,如已退费' : '请填写作废原因,如已线下签署纸质合同',
confirmText: '作废',
confirmColor: '#a35645',
})
if (!result.confirm) return
const reason = (result.content ?? '').trim()
if (reason.length < 2) {
uni.showToast({ title: '请填写作废原因', icon: 'none' })
return
}
try {
await adminStore.voidContract(item.id, reason)
uni.showToast({ title: '合同已作废', icon: 'success' })
reload()
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '操作失败,请重试'), icon: 'none' })
}
}
function openDetail(item: AdminMembershipContractRow) {
uni.navigateTo({ url: `${CONTRACT_ROUTES.DETAIL}?id=${item.id}&from=admin` })
}
onLoad(() => {
void syncLegacy().then(reload)
loaded = true
})
onShow(() => {
if (loaded && items.value.length) reload()
})
onReachBottom(() => {
if (!loading.value && hasMore.value) void fetchPage(false)
})
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; box-sizing: border-box; padding-bottom: calc(40rpx + env(safe-area-inset-bottom)); background: #f7f6ef; color: #515747; }
button { margin: 0; padding: 0; border: 0; line-height: 1.5; }
button::after { border: 0; }
.toolbar { padding: 20rpx 28rpx 8rpx; }
.tabs { display: flex; padding: 6rpx; border-radius: 16rpx; background: #eaece1; }
.tab { flex: 1; text-align: center; padding: 14rpx 0; border-radius: 12rpx; font-size: 24rpx; color: #858b76; }
.tab--active { background: #fffdf8; color: #4f6243; }
.search { margin-top: 18rpx; height: 76rpx; padding: 0 24rpx; border-radius: 16rpx; background: #fffdf9; border: 1rpx solid #e6e5d9; font-size: 25rpx; }
.legacy { margin: 18rpx 28rpx 0; padding: 26rpx; border-radius: 22rpx; background: #fbf0e8; border: 1rpx solid #efdccd; }
.legacy-title { display: block; font-size: 27rpx; color: #8f6450; }
.legacy-copy { display: block; margin: 8rpx 0 12rpx; font-size: 22rpx; line-height: 1.7; color: #9a8073; }
.legacy-item { display: flex; align-items: center; gap: 14rpx; padding: 16rpx 0; border-top: 1rpx solid #f0e2d7; }
.legacy-info { flex: 1; min-width: 0; }
.legacy-name { display: block; font-size: 25rpx; color: #5a4a40; }
.legacy-meta { display: block; margin-top: 4rpx; font-size: 21rpx; color: #9a8478; }
.legacy-input { width: 150rpx; height: 64rpx; padding: 0 14rpx; border-radius: 12rpx; background: #fff; font-size: 24rpx; }
.legacy-button { width: 96rpx; height: 64rpx; line-height: 64rpx; border-radius: 12rpx; background: #9c7a6e; color: #fff; font-size: 24rpx; }
.row { margin: 18rpx 28rpx 0; padding: 26rpx; border-radius: 22rpx; background: #fffdf9; border: 1rpx solid #e6e5d9; }
.row--hover { background: #f5f4ec; }
.row-top { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; }
.row-name { font-size: 29rpx; color: #45513d; }
.row-status { flex-shrink: 0; padding: 4rpx 14rpx; border-radius: 999rpx; font-size: 21rpx; background: #eef2e6; color: #5b704c; }
.row-status--pending_sign { background: #f6ece4; color: #9c7a6e; }
.row-status--voided { background: #efedea; color: #9a948e; }
.row-meta { display: block; margin-top: 8rpx; font-size: 22rpx; line-height: 1.6; color: #8e917f; }
.row-actions { display: flex; justify-content: flex-end; margin-top: 10rpx; }
.row-action { padding: 6rpx 0 0 24rpx; font-size: 23rpx; color: #a35645; }
.footer { display: block; text-align: center; padding: 28rpx; font-size: 22rpx; color: #a09c88; }
.state { padding: 60rpx 34rpx; text-align: center; }
.state-copy { display: block; color: #96917f; font-size: 25rpx; line-height: 1.8; }
.retry-button { padding: 22rpx; margin: 28rpx auto; width: 240rpx; border-radius: 12rpx; color: #657857; background: #e9edde; font-size: 25rpx; }
.skeleton { height: 180rpx; border-radius: 22rpx; margin: 0 0 20rpx; background: #eaece2; animation: breathe 1.2s ease-in-out infinite alternate; }
@keyframes breathe { from { opacity: .45; } to { opacity: .85; } }
</style>

View File

@@ -64,6 +64,10 @@
<text class="item-title">订单管理</text>
<text class="arrow-text">›</text>
</view>
<view class="list-item" @tap="navigate('/pages/admin/contracts')">
<text class="item-title">会员合同</text>
<text class="arrow-text">›</text>
</view>
</view>
<view class="section-header"><text class="section-title">馆内好物</text></view>
@@ -86,6 +90,10 @@
<text class="item-title">馆主介绍</text>
<text class="arrow-text">›</text>
</view>
<view class="list-item" @tap="navigate('/pages/admin/contract-settings')">
<text class="item-title">合同签署设置</text>
<text class="arrow-text">›</text>
</view>
<view class="list-item" @tap="handleIncreaseSubscriptionCount">
<text class="item-title">增加订阅次数</text>
<text class="item-extra">剩余 {{ user?.adminBookingSubscriptionCount ?? 0 }} 次</text>

View File

@@ -33,6 +33,13 @@ import type {
CreateProfessionalAssessmentDto,
TrainingPlanRecord,
CreateTrainingPlanDto,
AdminMembershipContractRow,
ContractPartyConfig,
CreateLegacyContractDto,
LegacyContractSyncResult,
MembershipContractStatus,
MembershipContractSummary,
UpdateContractPartyDto,
} from '@mp-pilates/shared'
interface LegacyPaginatedData<T> {
@@ -306,7 +313,22 @@ export const useAdminStore = defineStore('admin', () => {
function createTeachingCaseUpload(fileName: string) { return post<TeachingCaseUpload>('/admin/teaching-cases/assets/credentials', { fileName }) }
function completeTeachingCaseUpload(id: string) { return post<{ id: string; url: string }>(`/admin/teaching-cases/assets/${id}/complete`) }
function fetchContractParty() { return get<ContractPartyConfig>('/admin/contract-party') }
function saveContractParty(dto: UpdateContractPartyDto) { return put<ContractPartyConfig>('/admin/contract-party', { ...dto }) }
function saveContractPartySignature(image: string) { return put<ContractPartyConfig>('/admin/contract-party/signature', { image }) }
async function fetchAdminContracts(params: { page: number; limit: number; status?: MembershipContractStatus; search?: string }) {
const query: Record<string, unknown> = { page: params.page, limit: params.limit }
if (params.status) query.status = params.status
if (params.search) query.search = params.search
return normalizePaginatedData(await get<PaginatedData<AdminMembershipContractRow>>('/admin/contracts', query))
}
function syncLegacyContracts() { return post<LegacyContractSyncResult>('/admin/contracts/legacy-sync') }
function createLegacyContract(dto: CreateLegacyContractDto) { return post<MembershipContractSummary>('/admin/contracts/legacy', { ...dto }) }
function voidContract(id: string, reason: string) { return post<MembershipContractSummary>(`/admin/contracts/${id}/void`, { reason }) }
return {
fetchContractParty, saveContractParty, saveContractPartySignature,
fetchAdminContracts, syncLegacyContracts, createLegacyContract, voidContract,
fetchTeachingCases, fetchTeachingCase, saveTeachingCase, withdrawTeachingCase, createTeachingCaseUpload, completeTeachingCaseUpload,
fetchReviews, fetchReviewTrend,
fetchTeachingAnalytics,

View File

@@ -213,6 +213,7 @@ import { BookingStatus, TimeSlotStatus, SubscriptionMessageScene } from '@mp-pil
import { useBookingStore } from '../../stores/booking'
import { useUserStore } from '../../stores/user'
import { getErrorMessage } from '../../utils/auth'
import { isContractUnsignedError, promptSignContract } from '../../utils/contract'
import { getSystemLayout } from '../../utils/system'
import { isSlotPast, formatDateTimeFull } from '../../utils/format'
import { irisProfile } from '../../utils/teacher'
@@ -562,6 +563,10 @@ async function onConfirmBooking(payload: { timeSlotId: string; membershipId: str
await loadBookingData()
} catch (err: unknown) {
uni.hideLoading()
if (isContractUnsignedError(err)) {
promptSignContract()
return
}
const message = err instanceof Error ? err.message : '预约失败,请重试'
uni.showToast({ title: message, icon: 'none' })
}

View File

@@ -92,6 +92,7 @@ import { BookingStatus, TIME_PERIODS, canUseMembershipForSlot } from '@mp-pilate
import { useBookingStore } from '../../stores/booking'
import { useUserStore } from '../../stores/user'
import { getErrorMessage } from '../../utils/auth'
import { isContractUnsignedError, promptSignContract } from '../../utils/contract'
import { formatDate, isSlotPast } from '../../utils/format'
import { getSystemLayout } from '../../utils/system'
import DateSelector from '../../components/DateSelector.vue'
@@ -362,6 +363,10 @@ async function onConfirmBooking(payload: { timeSlotId: string; membershipId: str
await loadSlots(selectedDate.value)
} catch (err: unknown) {
uni.hideLoading()
if (isContractUnsignedError(err)) {
promptSignContract()
return
}
const message = err instanceof Error ? err.message : '预约失败,请重试'
uni.showToast({ title: message, icon: 'none' })
}

View File

@@ -311,9 +311,20 @@
<text class="sheet-warning-text">{{ purchaseConfirmationWarning }}</text>
</view>
<view v-if="contractRequired" class="sheet-contract">
<view class="sheet-contract-check" :class="{ 'sheet-contract-check--on': contractAccepted }" @tap="contractAccepted = !contractAccepted">
{{ contractAccepted ? '✓' : '' }}
</view>
<view class="sheet-contract-text">
<text @tap="contractAccepted = !contractAccepted">我已阅读并同意</text>
<text class="sheet-contract-link" @tap="previewContract">《会员卡服务合同》</text>
<text @tap="contractAccepted = !contractAccepted">,支付成功后需填写姓名并手写签署</text>
</view>
</view>
<view class="sheet-actions">
<button class="sheet-cancel" :disabled="buying" @tap="closePurchaseConfirmation">再想想</button>
<button class="sheet-confirm" :disabled="buying" @tap="doPurchase">
<button class="sheet-confirm" :class="{ 'sheet-confirm--blocked': contractRequired && !contractAccepted }" :disabled="buying" @tap="doPurchase">
{{ buying ? '正在唤起支付...' : '确认支付' }}
</button>
</view>
@@ -356,8 +367,10 @@ import {
pickRenewalTarget,
isMembershipExpired,
getMembershipEffectiveState,
requiresMembershipContract,
} from '@mp-pilates/shared'
import { getErrorMessage } from '../../utils/auth'
import { CONTRACT_ROUTES } from '../../utils/contract'
import { get, post } from '../../utils/request'
import { formatPrice, getCardCoverClass } from '../../utils/format'
import { getSystemLayout } from '../../utils/system'
@@ -449,6 +462,13 @@ const typeLabel = computed(() => card.value ? getTypeLabel(card.value.type) : ''
const isTrialCard = computed(() => card.value?.type === CardTypeCategory.TRIAL)
const isUnlimitedCard = computed(() => card.value?.totalTimes === null)
const cardData = computed<CardType>(() => card.value as CardType)
const contractRequired = computed(() => !!card.value && requiresMembershipContract(card.value))
const contractAccepted = ref(false)
function previewContract() {
if (!card.value) return
uni.navigateTo({ url: `${CONTRACT_ROUTES.DETAIL}?previewCardTypeId=${card.value.id}` })
}
const heroClass = computed(() => {
if (!card.value) return ''
@@ -590,16 +610,17 @@ const purchaseNotes = computed(() => {
}
if (card.value.type === CardTypeCategory.DURATION) {
notes.push('如需退卡,已上课时按次卡单价计费扣除,不以月卡优惠价结算。')
notes.push('每期独立计算,未用课时到期失效;本期用完后可确认启用下一期。')
} else {
notes.push(card.value.totalTimes === null ? '到期后自动失效。' : '到期或课时用完后自动失效。')
}
notes.push(
card.value.type === CardTypeCategory.DURATION
? '每期独立计算,未用课时到期失效;本期用完后可确认启用下一期。'
: card.value.totalTimes === null
? '到期后自动失效。'
: '到期或课时用完后自动失效,不可退款。',
)
if (requiresMembershipContract(card.value)) {
notes.push('付款 7 日内未上课可全额退款;之后退卡,已上课时按单节标准课价扣除,不收手续费,详见合同。')
notes.push('支付成功后需在线签署《会员卡服务合同》,签署后即可预约课程。')
} else if (card.value.totalTimes !== null) {
notes[notes.length - 1] = '到期或课时用完后自动失效,不可退款。'
}
notes.push('支持微信支付,支付过程由微信安全保障。')
return notes
})
@@ -814,6 +835,7 @@ async function preparePurchaseConfirmation() {
return
}
syncRenewalClock()
contractAccepted.value = false
purchaseConfirmVisible.value = true
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '暂时无法核对优惠,请重试'), icon: 'none' })
@@ -890,6 +912,15 @@ async function settlePaidOrder(orderId: string) {
pendingOrderId.value = ''
paymentRedirecting.value = true
if (contractRequired.value) {
uni.showToast({ title: '支付成功,请签署服务合同', icon: 'none' })
setTimeout(() => {
if (session !== paymentConfirmationSession.value) return
paymentRedirecting.value = false
uni.redirectTo({ url: `${CONTRACT_ROUTES.SIGN}?orderId=${orderId}` })
}, 900)
return
}
const scheduledMonthly = card.value?.type === CardTypeCategory.DURATION && userStore.memberships.some(m => m.cardTypeId === card.value?.id && m.status === MembershipStatus.SCHEDULED)
uni.showToast({ title: scheduledMonthly ? '下期月卡已安排' : isRenewal.value ? '续卡权益已到账' : '会员卡已到账', icon: 'success' })
setTimeout(() => {
@@ -923,6 +954,10 @@ function goToMembership() {
async function doPurchase() {
if (!card.value || buying.value) return
if (contractRequired.value && !contractAccepted.value) {
uni.showToast({ title: '请先阅读并同意《会员卡服务合同》', icon: 'none' })
return
}
buying.value = true
purchaseConfirmVisible.value = false
@@ -941,6 +976,7 @@ async function doPurchase() {
uni.showLoading({ title: '创建订单...' })
const result = await post<CreateOrderResponse>('/payment/create-order', {
cardTypeId: card.value.id,
...(contractRequired.value ? { contractAccepted: contractAccepted.value } : {}),
})
uni.hideLoading()
@@ -1576,7 +1612,13 @@ onUnmounted(() => {
line-height: 1.55;
}
.sheet-contract { margin-top: 20rpx; display: flex; align-items: flex-start; gap: 14rpx; }
.sheet-contract-check { flex-shrink: 0; width: 32rpx; height: 32rpx; margin-top: 2rpx; border-radius: 8rpx; border: 2rpx solid #bfb2a5; color: #fff; font-size: 20rpx; line-height: 32rpx; text-align: center; }
.sheet-contract-check--on { background: #6b8276; border-color: #6b8276; }
.sheet-contract-text { flex: 1; color: #756a61; font-size: 22rpx; line-height: 1.6; }
.sheet-contract-link { color: #9c7a6e; }
.sheet-actions { margin-top: 28rpx; display: flex; gap: 14rpx; }
.sheet-confirm--blocked { opacity: 0.6; }
.sheet-cancel,
.sheet-confirm {

View File

@@ -0,0 +1,135 @@
<template>
<view class="detail-page" :style="{ paddingTop: navBarHeight + 'px' }">
<CustomNavBar :title="previewCardTypeId ? '合同范本' : '合同详情'" show-back />
<view v-if="state === 'loading'" class="state"><view class="skeleton" /><view class="skeleton tall" /></view>
<view v-else-if="state === 'error' || !contractDocument" class="state state-card">
<text class="state-title">暂时无法读取合同</text>
<text class="state-copy">{{ error }}</text>
<button class="secondary" @tap="load">重新加载</button>
</view>
<template v-else>
<view v-if="previewCardTypeId" class="status status--preview">
<text class="status__title">合同范本预览</text>
<text class="status__copy">支付成功后将生成正式合同,填写姓名并手写签名后生效。金额、日期以支付确认后的正式合同为准。</text>
</view>
<view v-else-if="detail" class="status" :class="'status--' + detail.status.toLowerCase()">
<text class="status__title">{{ CONTRACT_STATUS_LABEL[detail.status] }}</text>
<text class="status__copy">{{ statusCopy }}</text>
</view>
<view class="paper">
<ContractDocumentView :document="contractDocument" :signature="detail?.signature ?? null" />
</view>
<view v-if="detail && !previewCardTypeId" class="dock">
<button v-if="detail.status === MembershipContractStatus.SIGNED" class="primary" :loading="exporting" :disabled="exporting" @tap="exportPdf">导出 PDF</button>
<button v-else-if="detail.status === MembershipContractStatus.PENDING_SIGN && !fromAdmin" class="primary" @tap="goSign">去签署</button>
<text v-if="detail.status === MembershipContractStatus.SIGNED" class="dock__hint">打开后可通过右上角菜单转发或保存到手机</text>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { MembershipContractStatus, type ContractDocument, type MembershipContractDetail } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import ContractDocumentView from '../../components/ContractDocumentView.vue'
import {
CONTRACT_ROUTES,
CONTRACT_STATUS_LABEL,
fetchContractDetail,
fetchContractTemplatePreview,
openContractPdf,
} from '../../utils/contract'
import { formatDateTimeFull } from '../../utils/format'
import { getErrorMessage } from '../../utils/auth'
import { getSystemLayout } from '../../utils/system'
const navBarHeight = getSystemLayout().navBarHeight
const contractId = ref('')
const previewCardTypeId = ref('')
const fromAdmin = ref(false)
const detail = ref<MembershipContractDetail | null>(null)
const contractDocument = ref<ContractDocument | null>(null)
const state = ref<'loading' | 'ready' | 'error'>('loading')
const error = ref('')
const exporting = ref(false)
const statusCopy = computed(() => {
const current = detail.value
if (!current) return ''
if (current.status === MembershipContractStatus.SIGNED) return `${current.partyName ?? ''} 于 ${formatDateTimeFull(current.signedAt!)} 签署,合同内容已锁定。`
if (current.status === MembershipContractStatus.VOIDED) return current.voidReason || '该合同已作废。'
return current.unsignableReason || (current.requireBeforeBooking ? '签署后即可预约课程。' : '请核对会员卡信息并完成补签。')
})
async function load() {
state.value = 'loading'
error.value = ''
try {
if (previewCardTypeId.value) {
contractDocument.value = (await fetchContractTemplatePreview(previewCardTypeId.value)).document
} else {
detail.value = await fetchContractDetail(contractId.value)
contractDocument.value = detail.value.document
}
state.value = 'ready'
} catch (err) {
error.value = getErrorMessage(err, '请检查网络后重试')
state.value = 'error'
}
}
async function exportPdf() {
if (!detail.value || exporting.value) return
exporting.value = true
try {
await openContractPdf(detail.value.id)
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '导出失败,请重试'), icon: 'none' })
} finally {
exporting.value = false
}
}
function goSign() {
if (detail.value) uni.redirectTo({ url: `${CONTRACT_ROUTES.SIGN}?id=${detail.value.id}` })
}
onLoad((query) => {
contractId.value = String(query?.id || '')
previewCardTypeId.value = String(query?.previewCardTypeId || '')
fromAdmin.value = query?.from === 'admin'
void load()
})
</script>
<style lang="scss" scoped>
.detail-page { min-height: 100vh; box-sizing: border-box; padding-bottom: calc(220rpx + env(safe-area-inset-bottom)); background: #fbf9f6; color: #514943; }
button { font-weight: 400; }
button::after { border: 0; }
.status { margin: 28rpx 32rpx 0; padding: 26rpx; border-radius: 24rpx; background: #edf1e6; border: 1rpx solid #dfe5d6; }
.status__title { display: block; font-size: 30rpx; color: #4f6a5c; }
.status__copy { display: block; margin-top: 8rpx; font-size: 23rpx; line-height: 1.8; color: #6f7d68; }
.status--pending_sign, .status--preview { background: #f6ece4; border-color: #ecdccf; }
.status--pending_sign .status__title, .status--preview .status__title { color: #9c7a6e; }
.status--pending_sign .status__copy, .status--preview .status__copy { color: #9a8478; }
.status--voided { background: #f0eeeb; border-color: #e5e1dc; }
.status--voided .status__title, .status--voided .status__copy { color: #8f8881; }
.paper { margin: 24rpx 32rpx 0; padding: 32rpx 28rpx; border-radius: 24rpx; background: #fff; border: 1rpx solid #eee6dd; }
.dock { position: fixed; left: 0; right: 0; bottom: 0; padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom)); background: #fbf9f6; border-top: 1rpx solid #ece6de; }
.dock__hint { display: block; margin-top: 12rpx; text-align: center; font-size: 21rpx; color: #a39689; }
.primary, .secondary { width: 100%; margin: 0; border-radius: 20rpx; line-height: 88rpx; font-size: 28rpx; background: #617d70; color: #fff; }
.secondary { background: #eaece3; color: #60745a; }
.state { margin: 36rpx 32rpx; }
.state-card { margin-top: 90rpx; padding: 50rpx 32rpx; background: #f4f1ec; border-radius: 28rpx; text-align: center; }
.state-title { display: block; font-family: 'Songti SC', 'STSong', serif; font-size: 32rpx; color: #635b4f; }
.state-copy { display: block; margin: 18rpx 0 30rpx; font-size: 24rpx; line-height: 1.9; color: #8d8271; }
.skeleton { height: 220rpx; background: #efebe5; border-radius: 24rpx; margin-bottom: 24rpx; animation: breathe 1.2s infinite alternate; }
.skeleton.tall { height: 520rpx; }
@keyframes breathe { to { opacity: .45 } }
</style>

View File

@@ -0,0 +1,120 @@
<template>
<view class="contracts-page" :style="{ paddingTop: navBarHeight + 'px' }">
<CustomNavBar title="我的合同" show-back />
<view v-if="state === 'loading'" class="state">
<view class="skeleton" /><view class="skeleton" />
</view>
<view v-else-if="state === 'error'" class="state state-card">
<text class="state-title">暂时无法读取合同</text>
<text class="state-copy">{{ error }}</text>
<button class="secondary" @tap="load">重新加载</button>
</view>
<view v-else-if="!contracts.length" class="state state-card">
<text class="state-title">还没有会员卡合同</text>
<text class="state-copy">购买月卡或多次卡后,会在这里生成服务合同,签署后可随时查看和导出。</text>
</view>
<template v-else>
<text class="intro">月卡、多次卡均需签署服务合同。合同签署后不可修改,可随时导出 PDF 留存。</text>
<view
v-for="item in contracts"
:key="item.id"
class="contract-card"
:class="'contract-card--' + item.status.toLowerCase()"
hover-class="contract-card--hover"
@tap="open(item)"
>
<view class="contract-card__top">
<text class="contract-card__name">{{ item.cardName }}</text>
<text class="contract-card__status">{{ statusLabel(item) }}</text>
</view>
<text class="contract-card__meta">合同编号 {{ item.contractNo }}</text>
<view class="contract-card__facts">
<text>¥{{ formatPrice(item.amount) }}</text>
<text>有效期至 {{ formatChinaDate(item.expireDate) }}</text>
</view>
<view class="contract-card__foot">
<text v-if="item.status === MembershipContractStatus.SIGNED">签署于 {{ formatDateTimeFull(item.signedAt!) }}</text>
<text v-else-if="item.status === MembershipContractStatus.VOIDED">{{ item.voidReason || '合同已作废' }}</text>
<text v-else>{{ item.requireBeforeBooking ? '签署后即可预约课程' : '存量会员卡补签' }}</text>
<text class="contract-card__action">{{ item.status === MembershipContractStatus.PENDING_SIGN ? '去签署 ›' : '查看 ›' }}</text>
</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { MembershipContractSource, MembershipContractStatus, type MembershipContractSummary } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { CONTRACT_ROUTES, CONTRACT_STATUS_LABEL, fetchMyContracts } from '../../utils/contract'
import { formatChinaDate, formatDateTimeFull, formatPrice } from '../../utils/format'
import { getErrorMessage } from '../../utils/auth'
import { getSystemLayout } from '../../utils/system'
const navBarHeight = getSystemLayout().navBarHeight
const contracts = ref<MembershipContractSummary[]>([])
const state = ref<'loading' | 'ready' | 'error'>('loading')
const error = ref('')
let sequence = 0
function statusLabel(item: MembershipContractSummary) {
const label = CONTRACT_STATUS_LABEL[item.status]
return item.source === MembershipContractSource.LEGACY && item.status === MembershipContractStatus.PENDING_SIGN ? `${label} · 补签` : label
}
async function load() {
const seq = ++sequence
if (!contracts.value.length) state.value = 'loading'
try {
const result = await fetchMyContracts()
if (seq !== sequence) return
contracts.value = result
state.value = 'ready'
} catch (err) {
if (seq !== sequence) return
error.value = getErrorMessage(err, '请检查网络后重试')
state.value = 'error'
}
}
function open(item: MembershipContractSummary) {
uni.navigateTo({
url: item.status === MembershipContractStatus.PENDING_SIGN
? `${CONTRACT_ROUTES.SIGN}?id=${item.id}`
: `${CONTRACT_ROUTES.DETAIL}?id=${item.id}`,
})
}
onShow(() => { void load() })
</script>
<style lang="scss" scoped>
.contracts-page { min-height: 100vh; box-sizing: border-box; padding-bottom: calc(40rpx + env(safe-area-inset-bottom)); background: #fbf9f6; color: #514943; }
button::after { border: 0; }
.intro { display: block; margin: 28rpx 32rpx 8rpx; font-size: 23rpx; line-height: 1.8; color: #9a8e83; }
.contract-card {
margin: 20rpx 32rpx 0; padding: 28rpx; border-radius: 24rpx; background: #fff; border: 1rpx solid #eee6dd;
&--hover { background: #f8f4ef; }
&__top { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; }
&__name { font-size: 30rpx; font-weight: 500; color: #4a4035; }
&__status { flex-shrink: 0; padding: 4rpx 16rpx; border-radius: 999rpx; font-size: 22rpx; background: #eef2ec; color: #617d73; }
&__meta { display: block; margin-top: 8rpx; font-size: 22rpx; color: #a39689; }
&__facts { display: flex; justify-content: space-between; margin-top: 20rpx; font-size: 25rpx; color: #6f655e; }
&__foot { display: flex; justify-content: space-between; gap: 16rpx; margin-top: 18rpx; padding-top: 18rpx; border-top: 1rpx solid #f2ece5; font-size: 22rpx; color: #9a8e83; }
&__action { flex-shrink: 0; color: #9c7a6e; }
&--pending_sign { border-color: #ecd9cb; }
&--pending_sign &__status { background: #f6ece4; color: #9c7a6e; }
&--voided { opacity: 0.6; }
&--voided &__status { background: #f0eeeb; color: #9a948e; }
}
.state { margin: 36rpx 32rpx; }
.state-card { margin-top: 90rpx; padding: 50rpx 32rpx; background: #f4f1ec; border-radius: 28rpx; text-align: center; }
.state-title { display: block; font-family: 'Songti SC', 'STSong', serif; font-size: 32rpx; color: #635b4f; }
.state-copy { display: block; margin: 18rpx 0 30rpx; font-size: 24rpx; line-height: 1.9; color: #8d8271; }
.secondary { width: 100%; margin: 0; border-radius: 20rpx; line-height: 84rpx; font-size: 27rpx; background: #eaece3; color: #60745a; }
.skeleton { height: 220rpx; background: #efebe5; border-radius: 24rpx; margin-bottom: 24rpx; animation: breathe 1.2s infinite alternate; }
@keyframes breathe { to { opacity: .45 } }
</style>

View File

@@ -0,0 +1,331 @@
<template>
<view class="sign-page" :style="{ paddingTop: navBarHeight + 'px' }">
<CustomNavBar title="签署会员卡合同" show-back :before-back="handleBack" />
<view v-if="state === 'loading'" class="state"><view class="skeleton" /><view class="skeleton tall" /></view>
<view v-else-if="state !== 'ready' || !detail" class="state state-card">
<text class="state-title">{{ stateTitle }}</text>
<text class="state-copy">{{ error }}</text>
<button v-if="state === 'error'" class="secondary" @tap="load">重新加载</button>
<button class="text-button" @tap="goList">查看我的合同</button>
</view>
<template v-else>
<view v-if="step !== 'done'" class="steps">
<view v-for="(item, index) in STEPS" :key="item.key" class="steps__item" :class="{ 'steps__item--active': stepIndex >= index }">
<text class="steps__index">{{ index + 1 }}</text>
<text class="steps__label">{{ item.label }}</text>
</view>
</view>
<!-- Step 1: identity -->
<view v-if="step === 'identity'" class="panel">
<view class="card-summary">
<text class="card-summary__name">{{ detail.cardName }}</text>
<text class="card-summary__meta">¥{{ formatPrice(detail.amount) }} · 有效期至 {{ formatChinaDate(detail.expireDate) }}</text>
<text v-if="detail.source === MembershipContractSource.LEGACY" class="card-summary__tag">存量会员卡补签</text>
</view>
<text class="panel__title">确认签约人信息</text>
<text class="panel__copy">请填写与身份证件一致的真实姓名,合同将以此作为甲方信息。</text>
<view class="field">
<text class="field__label">真实姓名</text>
<input v-model="realName" class="field__input" maxlength="30" placeholder="请输入真实姓名" />
</view>
<view class="field">
<text class="field__label">手机号</text>
<input v-model="phone" class="field__input" type="number" maxlength="11" placeholder="请输入 11 位手机号" />
</view>
<text v-if="formError" class="error">{{ formError }}</text>
<view class="dock">
<button class="primary" :loading="busy" :disabled="busy" @tap="submitIdentity">下一步:阅读合同</button>
</view>
</view>
<!-- Step 2: read -->
<template v-else-if="step === 'read'">
<text class="read-tip">{{ readToEnd ? '已阅读至底部,请确认以下事项' : '请完整阅读合同至底部,加粗条款请重点阅读' }}</text>
<scroll-view class="reader" scroll-y lower-threshold="120" @scrolltolower="readToEnd = true">
<view class="reader__paper">
<ContractDocumentView :document="detail.document" />
</view>
</scroll-view>
<view class="dock dock--fixed">
<text v-if="detail.unsignableReason" class="error">{{ detail.unsignableReason }}</text>
<view class="check" @tap="agreedTerms = !agreedTerms">
<view class="check__box" :class="{ 'check__box--on': agreedTerms }">{{ agreedTerms ? '✓' : '' }}</view>
<text class="check__text">我已阅读并理解合同全部条款,特别是加粗显示的有效期、取消预约、退费等条款</text>
</view>
<view class="check" @tap="confirmedHealth = !confirmedHealth">
<view class="check__box" :class="{ 'check__box--on': confirmedHealth }">{{ confirmedHealth ? '✓' : '' }}</view>
<text class="check__text">我确认已如实告知身体健康状况,不存在合同第八条所列不适宜运动的情形</text>
</view>
<button class="primary" :disabled="!canProceedToSign" @tap="step = 'sign'">
{{ readToEnd ? '下一步:手写签名' : '请先阅读至合同底部' }}
</button>
</view>
</template>
<!-- Step 3: handwrite -->
<view v-else-if="step === 'sign'" class="panel">
<text class="panel__title">手写签名</text>
<text class="panel__copy">请在下方区域用正楷书写姓名「{{ detail.partyName }}」。签名将与合同全文一并留存,签署后不可修改。</text>
<SignaturePad ref="pad" :height="420" @change="onSignatureChange" />
<view class="sign-actions">
<button class="secondary sign-actions__clear" :disabled="busy" @tap="clearSignature">清除重写</button>
<button class="primary sign-actions__submit" :loading="busy" :disabled="busy || signatureEmpty" @tap="submitSignature">确认签署</button>
</view>
<text class="legal-note">签署即表示你同意以电子方式订立本合同,手写电子签名与纸质签字具有同等效力。系统将记录签署时间、合同内容摘要及设备信息作为签署凭证。</text>
</view>
<!-- Done -->
<view v-else class="done">
<view class="done__mark">✓</view>
<text class="done__title">合同已签署</text>
<text class="done__copy">合同编号 {{ detail.contractNo }},可在「个人中心 - 我的合同」随时查看和导出。</text>
<button class="primary" @tap="viewContract">查看合同</button>
<button v-if="nextPendingId" class="secondary" @tap="signNext">继续签署下一份</button>
<button class="text-button" @tap="goBooking">去预约课程</button>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { MembershipContractSource, MembershipContractStatus, type MembershipContractDetail } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import ContractDocumentView from '../../components/ContractDocumentView.vue'
import SignaturePad from '../../components/SignaturePad.vue'
import {
CONTRACT_ROUTES,
fetchContractByOrder,
fetchContractDetail,
fetchContractPendingSummary,
saveContractPartyA,
signContract,
} from '../../utils/contract'
import { formatChinaDate, formatPrice } from '../../utils/format'
import { getErrorMessage } from '../../utils/auth'
import { getSystemLayout } from '../../utils/system'
type Step = 'identity' | 'read' | 'sign' | 'done'
const STEPS = [
{ key: 'identity', label: '确认信息' },
{ key: 'read', label: '阅读合同' },
{ key: 'sign', label: '手写签名' },
] as const
const navBarHeight = getSystemLayout().navBarHeight
const contractId = ref('')
const orderId = ref('')
const detail = ref<MembershipContractDetail | null>(null)
const state = ref<'loading' | 'ready' | 'error' | 'voided'>('loading')
const error = ref('')
const step = ref<Step>('identity')
const realName = ref('')
const phone = ref('')
const formError = ref('')
const busy = ref(false)
const readToEnd = ref(false)
const agreedTerms = ref(false)
const confirmedHealth = ref(false)
const signatureEmpty = ref(true)
const nextPendingId = ref<string | null>(null)
const pad = ref<InstanceType<typeof SignaturePad> | null>(null)
const stepIndex = computed(() => STEPS.findIndex(item => item.key === step.value))
const canProceedToSign = computed(() => readToEnd.value && agreedTerms.value && confirmedHealth.value && !!detail.value?.signable)
const stateTitle = computed(() => state.value === 'voided' ? '该合同已作废' : '暂时无法读取合同')
function wait(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function resolveContractId(): Promise<string> {
if (contractId.value) return contractId.value
for (const delay of [0, 800, 1500, 2500]) {
if (delay) await wait(delay)
const summary = await fetchContractByOrder(orderId.value)
if (summary) return summary.id
}
throw new Error('合同正在生成,请稍后在「我的合同」中签署')
}
async function load() {
state.value = 'loading'
error.value = ''
try {
contractId.value = await resolveContractId()
const result = await fetchContractDetail(contractId.value)
if (result.status === MembershipContractStatus.SIGNED) {
uni.redirectTo({ url: `${CONTRACT_ROUTES.DETAIL}?id=${result.id}` })
return
}
if (result.status === MembershipContractStatus.VOIDED) {
error.value = result.voidReason || '请联系馆主了解详情'
state.value = 'voided'
return
}
detail.value = result
realName.value = result.partyName ?? ''
phone.value = result.partyPhone ?? ''
step.value = 'identity'
state.value = 'ready'
} catch (err) {
error.value = getErrorMessage(err, '请检查网络后重试')
state.value = 'error'
}
}
async function submitIdentity() {
if (!detail.value || busy.value) return
const name = realName.value.trim()
const mobile = phone.value.trim()
if (name.length < 2) { formError.value = '请填写真实姓名'; return }
if (!/^1\d{10}$/.test(mobile)) { formError.value = '请填写 11 位手机号'; return }
formError.value = ''
busy.value = true
try {
detail.value = await saveContractPartyA(detail.value.id, name, mobile)
readToEnd.value = false
step.value = 'read'
} catch (err) {
formError.value = getErrorMessage(err, '保存失败,请重试')
} finally {
busy.value = false
}
}
function onSignatureChange(isEmpty: boolean) {
signatureEmpty.value = isEmpty
}
function clearSignature() {
pad.value?.clear()
}
function deviceSummary(): string | undefined {
try {
const device = uni.getDeviceInfo()
const app = uni.getAppBaseInfo()
return `${device.brand ?? ''} ${device.model ?? ''} / ${device.system ?? ''} / 微信 ${app.version ?? ''} / 基础库 ${app.SDKVersion ?? ''}`.slice(0, 200)
} catch {
return undefined
}
}
async function submitSignature() {
if (!detail.value || !pad.value || busy.value) return
busy.value = true
try {
const signatureImage = await pad.value.exportPng()
detail.value = await signContract(detail.value.id, {
contentSha256: detail.value.contentSha256,
signatureImage,
agreedTerms: agreedTerms.value,
confirmedHealth: confirmedHealth.value,
device: deviceSummary(),
})
step.value = 'done'
const summary = await fetchContractPendingSummary().catch(() => null)
nextPendingId.value = summary?.firstPendingId ?? null
} catch (err) {
const message = getErrorMessage(err, '签署失败,请重试')
if (message.includes('合同内容已更新')) {
uni.showToast({ title: '合同内容有更新,请重新阅读', icon: 'none' })
detail.value = await fetchContractDetail(detail.value.id).catch(() => detail.value)
readToEnd.value = false
step.value = 'read'
} else {
uni.showToast({ title: message, icon: 'none', duration: 3000 })
}
} finally {
busy.value = false
}
}
function handleBack(): boolean {
if (state.value !== 'ready') return true
if (step.value === 'sign') { step.value = 'read'; return false }
if (step.value === 'read') { step.value = 'identity'; return false }
return true
}
function viewContract() {
if (detail.value) uni.redirectTo({ url: `${CONTRACT_ROUTES.DETAIL}?id=${detail.value.id}` })
}
function signNext() {
if (nextPendingId.value) uni.redirectTo({ url: `${CONTRACT_ROUTES.SIGN}?id=${nextPendingId.value}` })
}
function goBooking() {
uni.switchTab({ url: '/pages/booking/index' })
}
function goList() {
uni.redirectTo({ url: CONTRACT_ROUTES.LIST })
}
onLoad((query) => {
contractId.value = String(query?.id || '')
orderId.value = String(query?.orderId || '')
if (!contractId.value && !orderId.value) {
state.value = 'error'
error.value = '缺少合同信息'
return
}
void load()
})
</script>
<style lang="scss" scoped>
.sign-page { height: 100vh; box-sizing: border-box; display: flex; flex-direction: column; background: #fbf9f6; color: #514943; overflow: hidden; }
button { font-weight: 400; }
button::after { border: 0; }
.steps { display: flex; justify-content: space-between; gap: 12rpx; padding: 24rpx 32rpx 16rpx; flex-shrink: 0; }
.steps__item { flex: 1; display: flex; align-items: center; gap: 10rpx; opacity: 0.45; }
.steps__item--active { opacity: 1; }
.steps__index { width: 40rpx; height: 40rpx; border-radius: 50%; background: #617d70; color: #fff; font-size: 22rpx; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.steps__label { font-size: 24rpx; color: #5f564e; }
.panel { flex: 1; overflow-y: auto; padding: 12rpx 32rpx calc(200rpx + env(safe-area-inset-bottom)); }
.panel__title { display: block; margin-top: 28rpx; font-family: 'Songti SC', 'STSong', serif; font-size: 34rpx; color: #4a4035; }
.panel__copy { display: block; margin: 12rpx 0 24rpx; font-size: 24rpx; line-height: 1.8; color: #8d8271; }
.card-summary { padding: 26rpx; border-radius: 24rpx; background: #edf1e6; border: 1rpx solid #dfe5d6; }
.card-summary__name { display: block; font-size: 30rpx; color: #4a4035; }
.card-summary__meta { display: block; margin-top: 8rpx; font-size: 24rpx; color: #6f7d68; }
.card-summary__tag { display: inline-block; margin-top: 12rpx; padding: 2rpx 14rpx; border-radius: 999rpx; background: #fff; font-size: 21rpx; color: #7d8b72; }
.field { margin-top: 20rpx; padding: 22rpx 26rpx; border-radius: 20rpx; background: #fff; border: 1rpx solid #ece4db; }
.field__label { display: block; font-size: 22rpx; color: #9a8e83; }
.field__input { margin-top: 8rpx; height: 56rpx; font-size: 30rpx; color: #3f3832; }
.read-tip { flex-shrink: 0; display: block; margin: 0 32rpx 14rpx; padding: 14rpx 20rpx; border-radius: 14rpx; background: #f6ece4; font-size: 22rpx; color: #9c7a6e; }
.reader { flex: 1; height: 0; }
.reader__paper { margin: 0 32rpx 24rpx; padding: 32rpx 28rpx; border-radius: 24rpx; background: #fff; border: 1rpx solid #eee6dd; }
.dock { position: fixed; left: 0; right: 0; bottom: 0; padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom)); background: #fbf9f6; border-top: 1rpx solid #ece6de; }
.dock--fixed { position: relative; flex-shrink: 0; }
.check { display: flex; gap: 16rpx; align-items: flex-start; margin-bottom: 16rpx; }
.check__box { width: 34rpx; height: 34rpx; flex-shrink: 0; margin-top: 4rpx; border-radius: 8rpx; border: 2rpx solid #bfb2a5; color: #fff; font-size: 22rpx; display: flex; align-items: center; justify-content: center; }
.check__box--on { background: #617d70; border-color: #617d70; }
.check__text { flex: 1; font-size: 23rpx; line-height: 1.7; color: #6f655e; }
.primary, .secondary { width: 100%; margin: 0; border-radius: 20rpx; line-height: 88rpx; font-size: 28rpx; background: #617d70; color: #fff; }
.primary[disabled] { opacity: 0.5; background: #617d70; color: #fff; }
.secondary { background: #eaece3; color: #60745a; }
.sign-actions { display: flex; gap: 20rpx; margin-top: 28rpx; }
.sign-actions__clear { flex: 1; }
.sign-actions__submit { flex: 2; }
.legal-note { display: block; margin-top: 24rpx; font-size: 21rpx; line-height: 1.8; color: #a39689; }
.error { display: block; margin: 12rpx 0; font-size: 23rpx; line-height: 1.7; color: #a35645; }
.text-button { margin: 20rpx auto 0; padding: 8rpx 0; background: transparent; color: #8b816f; font-size: 24rpx; line-height: 1.8; }
.done { padding: 80rpx 48rpx; text-align: center; display: flex; flex-direction: column; gap: 22rpx; }
.done__mark { width: 120rpx; height: 120rpx; margin: 0 auto 10rpx; border-radius: 50%; background: #edf1e6; color: #617d70; font-size: 56rpx; display: flex; align-items: center; justify-content: center; }
.done__title { font-family: 'Songti SC', 'STSong', serif; font-size: 38rpx; color: #4a4035; }
.done__copy { font-size: 24rpx; line-height: 1.8; color: #8d8271; margin-bottom: 20rpx; }
.state { margin: 36rpx 32rpx; }
.state-card { margin-top: 90rpx; padding: 50rpx 32rpx; background: #f4f1ec; border-radius: 28rpx; text-align: center; }
.state-title { display: block; font-family: 'Songti SC', 'STSong', serif; font-size: 32rpx; color: #635b4f; }
.state-copy { display: block; margin: 18rpx 0 30rpx; font-size: 24rpx; line-height: 1.9; color: #8d8271; }
.skeleton { height: 220rpx; background: #efebe5; border-radius: 24rpx; margin-bottom: 24rpx; animation: breathe 1.2s infinite alternate; }
.skeleton.tall { height: 420rpx; }
@keyframes breathe { to { opacity: .45 } }
</style>

View File

@@ -10,12 +10,15 @@
:show-today-lessons="loggedIn && isAdmin" :today-lessons-refresh-key="activityRefreshKey"
:loading="loginLoading" @login="handleLogin" @edit="goToInfo" @refresh-memberships="userStore.fetchMemberships()" />
<ContractPendingBanner v-if="loggedIn" :summary="contractSummary" />
<InviteCard />
<!-- Menu section: always visible -->
<ProfileMenu
:is-admin="isAdmin"
:require-auth="loggedIn"
:contract-pending-count="loggedIn ? contractSummary?.pendingCount ?? 0 : 0"
@clear-cache="handleClearCache"
@require-login="handleLogin"
@open-notifications="showNotificationsModal = true"
@@ -36,7 +39,7 @@
<script setup lang="ts">
import InviteCard from '../../components/InviteCard.vue'
import { useInviteStore } from '../../stores/invite'
import { ref, onMounted } from 'vue'
import { ref, onMounted, watch } from 'vue'
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { storeToRefs } from 'pinia'
import { useUserStore } from '../../stores/user'
@@ -46,12 +49,33 @@ import PracticeActivityCard from '../../components/PracticeActivityCard.vue'
import UserCard from '../../components/UserCard.vue'
import ProfileMenu from '../../components/ProfileMenu.vue'
import SubscriptionSettingsModal from '../../components/SubscriptionSettingsModal.vue'
import ContractPendingBanner from '../../components/ContractPendingBanner.vue'
import type { ContractPendingSummary } from '@mp-pilates/shared'
import { fetchContractPendingSummary } from '../../utils/contract'
const invite = useInviteStore()
const userStore = useUserStore()
const { loggedIn, hasProfile, user, memberships, membershipsLoading, membershipsLoaded, membershipsError, isAdmin } = storeToRefs(userStore)
const showNotificationsModal = ref(false)
const contractSummary = ref<ContractPendingSummary | null>(null)
let contractSummaryRequest = 0
async function refreshContractSummary() {
const request = ++contractSummaryRequest
const token = userStore.token
try {
const summary = await fetchContractPendingSummary()
if (request === contractSummaryRequest && token === userStore.token) contractSummary.value = summary
} catch {
if (request === contractSummaryRequest) contractSummary.value = null
}
}
watch(() => userStore.token, () => {
contractSummaryRequest++
contractSummary.value = null
})
const activityRefreshKey = ref(0)
const membershipNow = ref(Date.now())
const loginLoading = ref(false)
@@ -87,6 +111,7 @@ onShow(async () => {
invite.refreshActivity().catch(() => {}),
userStore.fetchProfile(),
userStore.fetchMemberships(),
refreshContractSummary(),
])
}
})
@@ -97,7 +122,7 @@ async function handleLogin() {
try {
const { isNewUser } = await userStore.loginWithSetup()
if (!isNewUser) {
await invite.refreshActivity().catch(() => {})
await Promise.all([invite.refreshActivity().catch(() => {}), refreshContractSummary()])
}
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '登录失败,请重试'), icon: 'none' })

View File

@@ -2,6 +2,7 @@
<view class="membership-page" :style="{ paddingTop: navBarHeight, height: pageHeight }">
<CustomNavBar title="我的会员卡" show-back />
<scroll-view class="scroll" scroll-y refresher-enabled :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
<ContractPendingBanner v-if="userStore.loggedIn" :summary="contractSummary" />
<view v-if="userStore.loggedIn" class="practice-summary">
<view class="summary-heading"><text class="summary-kicker">MY PRACTICE</text><text class="summary-title">每一次练习,都在积累。</text></view>
<view class="summary-grid">
@@ -96,8 +97,12 @@ import { post } from '../../utils/request'
import { getErrorMessage } from '../../utils/auth'
import CustomNavBar from '../../components/CustomNavBar.vue'
import OwnedMembershipCard from '../../components/OwnedMembershipCard.vue'
import ContractPendingBanner from '../../components/ContractPendingBanner.vue'
import type { ContractPendingSummary } from '@mp-pilates/shared'
import { fetchContractPendingSummary } from '../../utils/contract'
const userStore = useUserStore()
const contractSummary = ref<ContractPendingSummary | null>(null)
const navBarHeight = `${getSystemLayout().navBarHeight}px`
const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
@@ -152,7 +157,11 @@ const remainingLabel = computed(() => !userStore.membershipsLoaded || userStore.
async function loadMemberships() {
membershipNow.value = Date.now()
if (!userStore.loggedIn) return
await Promise.all([userStore.fetchMemberships(), userStore.fetchStats()])
await Promise.all([
userStore.fetchMemberships(),
userStore.fetchStats(),
fetchContractPendingSummary().then(summary => { contractSummary.value = summary }).catch(() => {}),
])
}
async function onRefresh() {

View File

@@ -0,0 +1,92 @@
import {
CONTRACT_UNSIGNED_MESSAGE,
MembershipContractStatus,
type ContractDocument,
type ContractPendingSummary,
type MembershipContractDetail,
type MembershipContractSummary,
type SignMembershipContractDto,
} from '@mp-pilates/shared'
import { get, getApiBaseUrl, post, put } from './request'
export const CONTRACT_ROUTES = {
LIST: '/pages/contract/index',
SIGN: '/pages/contract/sign',
DETAIL: '/pages/contract/detail',
} as const
export const CONTRACT_STATUS_LABEL: Record<MembershipContractStatus, string> = {
[MembershipContractStatus.PENDING_SIGN]: '待签署',
[MembershipContractStatus.SIGNED]: '已签署',
[MembershipContractStatus.VOIDED]: '已作废',
}
export function fetchMyContracts() {
return get<MembershipContractSummary[]>('/contracts/mine')
}
export function fetchContractPendingSummary() {
return get<ContractPendingSummary>('/contracts/pending-summary')
}
export function fetchContractByOrder(orderId: string) {
return get<MembershipContractSummary | null>(`/contracts/by-order/${orderId}`)
}
export function fetchContractDetail(id: string) {
return get<MembershipContractDetail>(`/contracts/${id}`)
}
export function fetchContractTemplatePreview(cardTypeId: string) {
return get<{ document: ContractDocument }>('/contracts/template-preview', { cardTypeId })
}
export function saveContractPartyA(id: string, realName: string, phone: string) {
return put<MembershipContractDetail>(`/contracts/${id}/party-a`, { realName, phone })
}
export function signContract(id: string, dto: SignMembershipContractDto) {
return post<MembershipContractDetail>(`/contracts/${id}/sign`, { ...dto })
}
export function isContractUnsignedError(err: unknown): boolean {
return err instanceof Error && err.message.includes(CONTRACT_UNSIGNED_MESSAGE)
}
export function promptSignContract(contractId?: string | null) {
uni.showModal({
title: '请先签署会员卡合同',
content: '你购买的会员卡尚未签署服务合同,签署后即可预约课程。',
confirmText: '去签署',
cancelText: '稍后',
success: (res) => {
if (!res.confirm) return
uni.navigateTo({ url: contractId ? `${CONTRACT_ROUTES.SIGN}?id=${contractId}` : CONTRACT_ROUTES.LIST })
},
})
}
/** 带登录凭证下载 PDF 后用系统文档预览,预览页右上角可转发或保存。 */
export function openContractPdf(id: string): Promise<void> {
const token = (uni.getStorageSync('token') as string) || ''
return new Promise((resolve, reject) => {
uni.downloadFile({
url: `${getApiBaseUrl()}/contracts/${id}/pdf`,
header: token ? { Authorization: `Bearer ${token}` } : {},
success: (res) => {
if (res.statusCode !== 200) {
reject(new Error(res.statusCode === 401 ? 'Unauthorized' : '合同导出失败,请稍后重试'))
return
}
uni.openDocument({
filePath: res.tempFilePath,
fileType: 'pdf',
showMenu: true,
success: () => resolve(),
fail: (err) => reject(new Error(err.errMsg || '无法打开合同文件')),
})
},
fail: (err) => reject(new Error(err.errMsg || '合同下载失败')),
})
})
}

View File

@@ -13,6 +13,10 @@ interface RequestOptions {
readonly header?: Record<string, string>
}
export function getApiBaseUrl(): string {
return BASE_URL
}
export class HttpRequestError extends Error {
constructor(message: string, readonly statusCode: number) {
super(message)

Binary file not shown.

View File

@@ -31,6 +31,7 @@
"nest-winston": "^1.10.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pdfkit": "^0.17.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"winston": "^3.19.0",
@@ -43,6 +44,7 @@
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/passport-jwt": "^4.0.1",
"@types/pdfkit": "^0.17.7",
"jest": "^29.7.0",
"prisma": "^5.19.0",
"source-map-support": "^0.5.21",

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

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

View File

@@ -0,0 +1,14 @@
import { CardTypeCategory } from './enums'
/** 月卡与多次卡属于预付式服务,必须签署书面合同;单次卡和体验卡不签。 */
export function requiresMembershipContract(card: { type: CardTypeCategory | string; totalTimes: number | null }): boolean {
if (card.type === CardTypeCategory.DURATION) return true
if (card.type === CardTypeCategory.TIMES) return card.totalTimes === null || card.totalTimes > 1
return false
}
/** 预约接口返回此文案时,前端引导学员去签署合同。 */
export const CONTRACT_UNSIGNED_MESSAGE = '请先签署会员卡合同后再预约'
export const CONTRACT_REAL_NAME_MAX_LENGTH = 30
export const CONTRACT_SIGNATURE_MAX_BYTES = 300 * 1024

View File

@@ -47,6 +47,18 @@ export enum OrderStatus {
REFUNDED = 'REFUNDED',
}
// ===== Membership Contract =====
export enum MembershipContractStatus {
PENDING_SIGN = 'PENDING_SIGN',
SIGNED = 'SIGNED',
VOIDED = 'VOIDED',
}
export enum MembershipContractSource {
PURCHASE = 'PURCHASE', // 支付后签署
LEGACY = 'LEGACY', // 存量会员卡补签
}
// ===== Invite =====
export enum InviteReferralStatus {
REGISTERED = 'REGISTERED',

View File

@@ -28,6 +28,8 @@ export {
ProfessionalAssessmentKind,
TrainingPlanStatus,
ProgressPhotoAngle,
MembershipContractStatus,
MembershipContractSource,
} from './enums'
// Constants
@@ -150,3 +152,6 @@ export * from './types/body-portrait'
export * from './types/shop'
export * from './types/teaching-case'
export * from './contract'
export * from './types/contract'

View File

@@ -0,0 +1,145 @@
import type { MembershipContractSource, MembershipContractStatus } from '../enums'
export type ContractKind = 'MONTHLY' | 'MULTI_TIMES'
export type ContractPremisesType = 'LEASED' | 'OWNED'
export interface ContractRow {
readonly label: string
readonly value: string
}
export interface ContractClause {
readonly text: string
/** 与学员有重大利害关系的条款,需加粗提示。 */
readonly emphasis?: boolean
}
export interface ContractSection {
readonly title: string
readonly clauses: readonly ContractClause[]
}
/** 渲染后的合同正文;签署时冻结并计算 SHA-256。 */
export interface ContractDocument {
readonly title: string
readonly contractNo: string
readonly templateVersion: string
readonly notice: string
readonly partyA: readonly ContractRow[]
readonly partyB: readonly ContractRow[]
readonly cardRows: readonly ContractRow[]
readonly sections: readonly ContractSection[]
readonly commitment: string
}
export interface ContractSignatureInfo {
readonly signerName: string
readonly signedAt: string
/** data:image/png;base64,... */
readonly memberSignature: string
readonly partyBSignature: string | null
readonly partyBName: string
readonly contentSha256: string
}
export interface MembershipContractSummary {
readonly id: string
readonly contractNo: string
readonly status: MembershipContractStatus
readonly source: MembershipContractSource
readonly requireBeforeBooking: boolean
readonly cardName: string
readonly kind: ContractKind
readonly amount: number
readonly startDate: string
readonly expireDate: string
readonly createdAt: string
readonly signedAt: string | null
readonly voidReason: string | null
}
export interface MembershipContractDetail extends MembershipContractSummary {
readonly partyName: string | null
readonly partyPhone: string | null
readonly document: ContractDocument
readonly contentSha256: string
readonly signature: ContractSignatureInfo | null
/** 乙方信息未配置完成时不能签署。 */
readonly signable: boolean
readonly unsignableReason: string | null
}
export interface AdminMembershipContractRow extends MembershipContractSummary {
readonly user: { readonly id: string; readonly nickname: string; readonly phone: string | null }
readonly partyName: string | null
}
export interface ContractPendingSummary {
readonly pendingCount: number
readonly blockingCount: number
readonly firstPendingId: string | null
}
export interface SaveContractPartyADto {
readonly realName: string
readonly phone: string
}
export interface SignMembershipContractDto {
readonly contentSha256: string
readonly signatureImage: string
readonly agreedTerms: boolean
readonly confirmedHealth: boolean
readonly device?: string
}
export interface ContractPartyConfig {
readonly businessName: string
readonly brandName: string
readonly creditCode: string
readonly operatorName: string
readonly phone: string
readonly address: string
readonly premisesType: ContractPremisesType
readonly leaseEndDate: string | null
/** 单节标准课价(分),用于学员原因退费结算;未配置时取单次卡售价。 */
readonly standardLessonPrice: number
readonly standardLessonPriceConfigured: boolean
readonly signatureImage: string | null
readonly signatureUpdatedAt: string | null
readonly missingFields: readonly string[]
}
export interface UpdateContractPartyDto {
readonly businessName?: string
readonly brandName?: string
readonly creditCode?: string
readonly operatorName?: string
readonly phone?: string
readonly address?: string
readonly premisesType?: ContractPremisesType
readonly leaseEndDate?: string | null
readonly standardLessonPrice?: number
}
export interface LegacyContractCandidate {
readonly membershipId: string
readonly user: { readonly id: string; readonly nickname: string; readonly phone: string | null }
readonly cardName: string
readonly kind: ContractKind
readonly remainingTimes: number | null
readonly totalTimes: number | null
readonly startDate: string
readonly expireDate: string
}
export interface LegacyContractSyncResult {
readonly created: number
readonly needsAmount: readonly LegacyContractCandidate[]
}
export interface CreateLegacyContractDto {
readonly membershipId: string
/** 实付金额(分) */
readonly amount: number
}

392
pnpm-lock.yaml generated
View File

@@ -114,6 +114,9 @@ importers:
passport-jwt:
specifier: ^4.0.1
version: 4.0.1
pdfkit:
specifier: ^0.17.2
version: 0.17.2
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
@@ -129,7 +132,7 @@ importers:
devDependencies:
'@nestjs/cli':
specifier: ^10.4.0
version: 10.4.9
version: 10.4.9(esbuild@0.20.2)
'@nestjs/schematics':
specifier: ^10.1.0
version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3)
@@ -145,9 +148,12 @@ importers:
'@types/passport-jwt':
specifier: ^4.0.1
version: 4.0.1
'@types/pdfkit':
specifier: ^0.17.7
version: 0.17.7
jest:
specifier: ^29.7.0
version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
version: 29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3))
prisma:
specifier: ^5.19.0
version: 5.22.0
@@ -159,10 +165,10 @@ importers:
version: 7.2.2
ts-jest:
specifier: ^29.2.0
version: 29.4.9(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3)
version: 29.4.9(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(esbuild@0.20.2)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)))(typescript@5.9.3)
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@25.5.2)(typescript@5.9.3)
version: 10.9.2(@types/node@20.19.39)(typescript@5.9.3)
typescript:
specifier: ^5.4.0
version: 5.9.3
@@ -1865,6 +1871,9 @@ packages:
'@so-ric/colorspace@1.1.6':
resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==}
'@swc/helpers@0.5.23':
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
'@tokenizer/inflate@0.2.7':
resolution: {integrity: sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==}
engines: {node: '>=18'}
@@ -1956,9 +1965,6 @@ packages:
'@types/node@20.19.39':
resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==}
'@types/node@25.5.2':
resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==}
'@types/passport-jwt@4.0.1':
resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==}
@@ -1968,6 +1974,9 @@ packages:
'@types/passport@1.0.17':
resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==}
'@types/pdfkit@0.17.7':
resolution: {integrity: sha512-VMOZDWw7HtNtLbjIuHIhVArm9PlV0gxpz32jAl1IuXWlIgbr1lKFoTsrtvEzY8a9BIpQJEE5DwNPKD50otTs6g==}
'@types/qs@6.15.0':
resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==}
@@ -2338,6 +2347,10 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
base64-js@0.0.8:
resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==}
engines: {node: '>= 0.4'}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -2378,6 +2391,12 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
brotli@1.3.3:
resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==}
browserify-zlib@0.2.0:
resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==}
browserslist-to-esbuild@2.1.1:
resolution: {integrity: sha512-KN+mty6C3e9AN8Z5dI1xeN15ExcRNeISoC3g7V0Kax/MMF9MSoYA2G7lkTTcVUFntiEjkpI0HNgqJC1NjdyNUw==}
engines: {node: '>=18'}
@@ -2527,6 +2546,10 @@ packages:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'}
clone@2.1.2:
resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
engines: {node: '>=0.8'}
co@4.6.0:
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
@@ -2677,6 +2700,9 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
crypto-js@4.2.0:
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
css-font-size-keywords@1.0.0:
resolution: {integrity: sha512-Q+svMDbMlelgCfH/RVDKtTDaf5021O486ZThQPIpahnIjUkMUslC+WuOQSWTgGSrNCH08Y7tYNEmmy0hkfMI8Q==}
@@ -2778,6 +2804,9 @@ packages:
dezalgo@1.0.4:
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
dfa@1.2.0:
resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==}
diff-sequences@29.6.3:
resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -3040,6 +3069,9 @@ packages:
debug:
optional: true
fontkit@2.0.4:
resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
@@ -3470,6 +3502,9 @@ packages:
jimp@0.10.3:
resolution: {integrity: sha512-meVWmDMtyUG5uYjFkmzu0zBgnCvvxwWNi27c4cg55vWNVC9ES4Lcwb+ogx+uBBQE3Q+dLKjXaLl0JVW+nUNwbQ==}
jpeg-exif@1.1.4:
resolution: {integrity: sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==}
jpeg-js@0.3.7:
resolution: {integrity: sha512-9IXdWudL61npZjvLuVe/ktHiA41iE8qFyLB+4VDTblEsWBzeg8WQTlktdUK4CdncUqtUgUg0bbOmTE2bKBKaBQ==}
@@ -3565,6 +3600,9 @@ packages:
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
engines: {node: '>=10'}
linebreak@1.1.0:
resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==}
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
@@ -3900,6 +3938,9 @@ packages:
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
pako@0.2.9:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
@@ -3982,6 +4023,9 @@ packages:
pause@0.0.1:
resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==}
pdfkit@0.17.2:
resolution: {integrity: sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==}
phin@2.9.3:
resolution: {integrity: sha512-CzFr90qM24ju5f88quFC/6qohjC144rehe5n6DH900lgXmUe86+xCKc10ev56gRKC4/BkHUoG4uSiQgBiIXwDA==}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
@@ -4044,6 +4088,9 @@ packages:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
png-js@1.1.0:
resolution: {integrity: sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==}
pngjs@3.4.0:
resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==}
engines: {node: '>=4.0.0'}
@@ -4269,6 +4316,9 @@ packages:
resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
engines: {node: '>=8'}
restructure@3.0.2:
resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==}
reusify@1.1.0:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
@@ -4560,6 +4610,9 @@ packages:
timm@1.7.1:
resolution: {integrity: sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw==}
tiny-inflate@1.0.3:
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
tinycolor2@1.6.0:
resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==}
@@ -4697,9 +4750,6 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
unicode-canonical-property-names-ecmascript@2.0.1:
resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==}
engines: {node: '>=4'}
@@ -4712,10 +4762,16 @@ packages:
resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==}
engines: {node: '>=4'}
unicode-properties@1.4.1:
resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==}
unicode-property-aliases-ecmascript@2.2.0:
resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==}
engines: {node: '>=4'}
unicode-trie@2.0.0:
resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==}
unimport@4.1.1:
resolution: {integrity: sha512-j9+fijH6aDd05yv1fXlyt7HSxtOWtGtrZeYTVBsSUg57Iuf+Ps2itIZjeyu7bEQ4k0WOgYhHrdW8m/pJgOpl5g==}
engines: {node: '>=18.12.0'}
@@ -6509,7 +6565,7 @@ snapshots:
'@jest/console@29.7.0':
dependencies:
'@jest/types': 29.6.3
'@types/node': 25.5.2
'@types/node': 20.19.39
chalk: 4.1.2
jest-message-util: 29.7.0
jest-util: 29.7.0
@@ -6522,49 +6578,14 @@ snapshots:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 25.5.2
'@types/node': 20.19.39
ansi-escapes: 4.3.2
chalk: 4.1.2
ci-info: 3.9.0
exit: 0.1.2
graceful-fs: 4.2.11
jest-changed-files: 29.7.0
jest-config: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3))
jest-haste-map: 29.7.0
jest-message-util: 29.7.0
jest-regex-util: 29.6.3
jest-resolve: 29.7.0
jest-resolve-dependencies: 29.7.0
jest-runner: 29.7.0
jest-runtime: 29.7.0
jest-snapshot: 29.7.0
jest-util: 29.7.0
jest-validate: 29.7.0
jest-watcher: 29.7.0
micromatch: 4.0.8
pretty-format: 29.7.0
slash: 3.0.0
strip-ansi: 6.0.1
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
- ts-node
'@jest/core@29.7.0(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))':
dependencies:
'@jest/console': 29.7.0
'@jest/reporters': 29.7.0
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 25.5.2
ansi-escapes: 4.3.2
chalk: 4.1.2
ci-info: 3.9.0
exit: 0.1.2
graceful-fs: 4.2.11
jest-changed-files: 29.7.0
jest-config: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
jest-config: 29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3))
jest-haste-map: 29.7.0
jest-message-util: 29.7.0
jest-regex-util: 29.6.3
@@ -6589,7 +6610,7 @@ snapshots:
dependencies:
'@jest/fake-timers': 29.7.0
'@jest/types': 29.6.3
'@types/node': 25.5.2
'@types/node': 20.19.39
jest-mock: 29.7.0
'@jest/expect-utils@29.7.0':
@@ -6607,7 +6628,7 @@ snapshots:
dependencies:
'@jest/types': 29.6.3
'@sinonjs/fake-timers': 10.3.0
'@types/node': 25.5.2
'@types/node': 20.19.39
jest-message-util: 29.7.0
jest-mock: 29.7.0
jest-util: 29.7.0
@@ -6629,7 +6650,7 @@ snapshots:
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@jridgewell/trace-mapping': 0.3.31
'@types/node': 25.5.2
'@types/node': 20.19.39
chalk: 4.1.2
collect-v8-coverage: 1.0.3
exit: 0.1.2
@@ -6699,7 +6720,7 @@ snapshots:
'@jest/schemas': 29.6.3
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
'@types/node': 25.5.2
'@types/node': 20.19.39
'@types/yargs': 17.0.35
chalk: 4.1.2
@@ -7017,7 +7038,7 @@ snapshots:
'@lukeed/csprng@1.1.0': {}
'@nestjs/cli@10.4.9':
'@nestjs/cli@10.4.9(esbuild@0.20.2)':
dependencies:
'@angular-devkit/core': 17.3.11(chokidar@3.6.0)
'@angular-devkit/schematics': 17.3.11(chokidar@3.6.0)
@@ -7027,7 +7048,7 @@ snapshots:
chokidar: 3.6.0
cli-table3: 0.6.5
commander: 4.1.1
fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.7.2)(webpack@5.97.1)
fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.7.2)(webpack@5.97.1(esbuild@0.20.2))
glob: 10.4.5
inquirer: 8.2.6
node-emoji: 1.11.0
@@ -7036,7 +7057,7 @@ snapshots:
tsconfig-paths: 4.2.0
tsconfig-paths-webpack-plugin: 4.2.0
typescript: 5.7.2
webpack: 5.97.1
webpack: 5.97.1(esbuild@0.20.2)
webpack-node-externals: 3.0.0
transitivePeerDependencies:
- esbuild
@@ -7354,6 +7375,10 @@ snapshots:
color: 5.0.3
text-hex: 1.0.0
'@swc/helpers@0.5.23':
dependencies:
tslib: 2.8.1
'@tokenizer/inflate@0.2.7':
dependencies:
debug: 4.4.3
@@ -7396,11 +7421,11 @@ snapshots:
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
'@types/node': 25.5.2
'@types/node': 20.19.39
'@types/connect@3.4.38':
dependencies:
'@types/node': 25.5.2
'@types/node': 20.19.39
'@types/eslint-scope@3.7.7':
dependencies:
@@ -7416,7 +7441,7 @@ snapshots:
'@types/express-serve-static-core@4.19.8':
dependencies:
'@types/node': 25.5.2
'@types/node': 20.19.39
'@types/qs': 6.15.0
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
@@ -7430,7 +7455,7 @@ snapshots:
'@types/graceful-fs@4.1.9':
dependencies:
'@types/node': 25.5.2
'@types/node': 20.19.39
'@types/http-errors@2.0.5': {}
@@ -7454,11 +7479,11 @@ snapshots:
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 2.1.0
'@types/node': 25.5.2
'@types/node': 20.19.39
'@types/jsonwebtoken@9.0.5':
dependencies:
'@types/node': 25.5.2
'@types/node': 20.19.39
'@types/luxon@3.4.2': {}
@@ -7470,10 +7495,6 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/node@25.5.2':
dependencies:
undici-types: 7.18.2
'@types/passport-jwt@4.0.1':
dependencies:
'@types/jsonwebtoken': 9.0.10
@@ -7488,6 +7509,10 @@ snapshots:
dependencies:
'@types/express': 4.17.25
'@types/pdfkit@0.17.7':
dependencies:
'@types/node': 20.19.39
'@types/qs@6.15.0': {}
'@types/range-parser@1.2.7': {}
@@ -7495,16 +7520,16 @@ snapshots:
'@types/send@0.17.6':
dependencies:
'@types/mime': 1.3.5
'@types/node': 25.5.2
'@types/node': 20.19.39
'@types/send@1.2.1':
dependencies:
'@types/node': 25.5.2
'@types/node': 20.19.39
'@types/serve-static@1.15.10':
dependencies:
'@types/http-errors': 2.0.5
'@types/node': 25.5.2
'@types/node': 20.19.39
'@types/send': 0.17.6
'@types/stack-utils@2.0.3': {}
@@ -7994,6 +8019,8 @@ snapshots:
balanced-match@1.0.2: {}
base64-js@0.0.8: {}
base64-js@1.5.1: {}
base64url@3.0.1: {}
@@ -8057,6 +8084,14 @@ snapshots:
dependencies:
fill-range: 7.1.1
brotli@1.3.3:
dependencies:
base64-js: 1.5.1
browserify-zlib@0.2.0:
dependencies:
pako: 1.0.11
browserslist-to-esbuild@2.1.1(browserslist@4.28.2):
dependencies:
browserslist: 4.28.2
@@ -8201,6 +8236,8 @@ snapshots:
clone@1.0.4: {}
clone@2.1.2: {}
co@4.6.0: {}
collect-v8-coverage@1.0.3: {}
@@ -8326,21 +8363,6 @@ snapshots:
- supports-color
- ts-node
create-jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)):
dependencies:
'@jest/types': 29.6.3
chalk: 4.1.2
exit: 0.1.2
graceful-fs: 4.2.11
jest-config: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
jest-util: 29.7.0
prompts: 2.4.2
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
- supports-color
- ts-node
create-require@1.1.1: {}
cron@3.2.1:
@@ -8358,6 +8380,8 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
crypto-js@4.2.0: {}
css-font-size-keywords@1.0.0: {}
css-font-stretch-keywords@1.0.1: {}
@@ -8422,6 +8446,8 @@ snapshots:
asap: 2.0.6
wrappy: 1.0.2
dfa@1.2.0: {}
diff-sequences@29.6.3: {}
diff@4.0.4: {}
@@ -8769,12 +8795,24 @@ snapshots:
follow-redirects@1.15.11: {}
fontkit@2.0.4:
dependencies:
'@swc/helpers': 0.5.23
brotli: 1.3.3
clone: 2.1.2
dfa: 1.2.0
fast-deep-equal: 3.1.3
restructure: 3.0.2
tiny-inflate: 1.0.3
unicode-properties: 1.4.1
unicode-trie: 2.0.0
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
signal-exit: 4.1.0
fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1):
fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1(esbuild@0.20.2)):
dependencies:
'@babel/code-frame': 7.29.0
chalk: 4.1.2
@@ -8789,7 +8827,7 @@ snapshots:
semver: 7.7.4
tapable: 2.3.2
typescript: 5.7.2
webpack: 5.97.1
webpack: 5.97.1(esbuild@0.20.2)
form-data@4.0.5:
dependencies:
@@ -9110,7 +9148,7 @@ snapshots:
'@jest/expect': 29.7.0
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
'@types/node': 25.5.2
'@types/node': 20.19.39
chalk: 4.1.2
co: 4.6.0
dedent: 1.7.2
@@ -9149,25 +9187,6 @@ snapshots:
- supports-color
- ts-node
jest-cli@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)):
dependencies:
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
chalk: 4.1.2
create-jest: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
exit: 0.1.2
import-local: 3.2.0
jest-config: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
jest-util: 29.7.0
jest-validate: 29.7.0
yargs: 17.7.2
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
- supports-color
- ts-node
jest-config@29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)):
dependencies:
'@babel/core': 7.29.0
@@ -9199,68 +9218,6 @@ snapshots:
- babel-plugin-macros
- supports-color
jest-config@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)):
dependencies:
'@babel/core': 7.29.0
'@jest/test-sequencer': 29.7.0
'@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.29.0)
chalk: 4.1.2
ci-info: 3.9.0
deepmerge: 4.3.1
glob: 7.2.3
graceful-fs: 4.2.11
jest-circus: 29.7.0
jest-environment-node: 29.7.0
jest-get-type: 29.6.3
jest-regex-util: 29.6.3
jest-resolve: 29.7.0
jest-runner: 29.7.0
jest-util: 29.7.0
jest-validate: 29.7.0
micromatch: 4.0.8
parse-json: 5.2.0
pretty-format: 29.7.0
slash: 3.0.0
strip-json-comments: 3.1.1
optionalDependencies:
'@types/node': 25.5.2
ts-node: 10.9.2(@types/node@20.19.39)(typescript@5.9.3)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
jest-config@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)):
dependencies:
'@babel/core': 7.29.0
'@jest/test-sequencer': 29.7.0
'@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.29.0)
chalk: 4.1.2
ci-info: 3.9.0
deepmerge: 4.3.1
glob: 7.2.3
graceful-fs: 4.2.11
jest-circus: 29.7.0
jest-environment-node: 29.7.0
jest-get-type: 29.6.3
jest-regex-util: 29.6.3
jest-resolve: 29.7.0
jest-runner: 29.7.0
jest-util: 29.7.0
jest-validate: 29.7.0
micromatch: 4.0.8
parse-json: 5.2.0
pretty-format: 29.7.0
slash: 3.0.0
strip-json-comments: 3.1.1
optionalDependencies:
'@types/node': 25.5.2
ts-node: 10.9.2(@types/node@25.5.2)(typescript@5.9.3)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
jest-diff@29.7.0:
dependencies:
chalk: 4.1.2
@@ -9285,7 +9242,7 @@ snapshots:
'@jest/environment': 29.7.0
'@jest/fake-timers': 29.7.0
'@jest/types': 29.6.3
'@types/node': 25.5.2
'@types/node': 20.19.39
jest-mock: 29.7.0
jest-util: 29.7.0
@@ -9295,7 +9252,7 @@ snapshots:
dependencies:
'@jest/types': 29.6.3
'@types/graceful-fs': 4.1.9
'@types/node': 25.5.2
'@types/node': 20.19.39
anymatch: 3.1.3
fb-watchman: 2.0.2
graceful-fs: 4.2.11
@@ -9334,7 +9291,7 @@ snapshots:
jest-mock@29.7.0:
dependencies:
'@jest/types': 29.6.3
'@types/node': 25.5.2
'@types/node': 20.19.39
jest-util: 29.7.0
jest-pnp-resolver@1.2.3(jest-resolve@29.7.0):
@@ -9369,7 +9326,7 @@ snapshots:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 25.5.2
'@types/node': 20.19.39
chalk: 4.1.2
emittery: 0.13.1
graceful-fs: 4.2.11
@@ -9397,7 +9354,7 @@ snapshots:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 25.5.2
'@types/node': 20.19.39
chalk: 4.1.2
cjs-module-lexer: 1.4.3
collect-v8-coverage: 1.0.3
@@ -9443,7 +9400,7 @@ snapshots:
jest-util@29.7.0:
dependencies:
'@jest/types': 29.6.3
'@types/node': 25.5.2
'@types/node': 20.19.39
chalk: 4.1.2
ci-info: 3.9.0
graceful-fs: 4.2.11
@@ -9462,7 +9419,7 @@ snapshots:
dependencies:
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
'@types/node': 25.5.2
'@types/node': 20.19.39
ansi-escapes: 4.3.2
chalk: 4.1.2
emittery: 0.13.1
@@ -9471,13 +9428,13 @@ snapshots:
jest-worker@27.5.1:
dependencies:
'@types/node': 25.5.2
'@types/node': 20.19.39
merge-stream: 2.0.0
supports-color: 8.1.1
jest-worker@29.7.0:
dependencies:
'@types/node': 25.5.2
'@types/node': 20.19.39
jest-util: 29.7.0
merge-stream: 2.0.0
supports-color: 8.1.1
@@ -9494,18 +9451,6 @@ snapshots:
- supports-color
- ts-node
jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)):
dependencies:
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
'@jest/types': 29.6.3
import-local: 3.2.0
jest-cli: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
- supports-color
- ts-node
jimp@0.10.3:
dependencies:
'@babel/runtime': 7.29.2
@@ -9517,6 +9462,8 @@ snapshots:
transitivePeerDependencies:
- debug
jpeg-exif@1.1.4: {}
jpeg-js@0.3.7: {}
js-tokens@4.0.0: {}
@@ -9618,6 +9565,11 @@ snapshots:
lilconfig@2.1.0: {}
linebreak@1.1.0:
dependencies:
base64-js: 0.0.8
unicode-trie: 2.0.0
lines-and-columns@1.2.4: {}
lines-and-columns@2.0.4: {}
@@ -9906,6 +9858,8 @@ snapshots:
package-json-from-dist@1.0.1: {}
pako@0.2.9: {}
pako@1.0.11: {}
parent-module@1.0.1:
@@ -9982,6 +9936,14 @@ snapshots:
pause@0.0.1: {}
pdfkit@0.17.2:
dependencies:
crypto-js: 4.2.0
fontkit: 2.0.4
jpeg-exif: 1.1.4
linebreak: 1.1.0
png-js: 1.1.0
phin@2.9.3: {}
phin@3.7.1:
@@ -10036,6 +9998,10 @@ snapshots:
pluralize@8.0.0: {}
png-js@1.1.0:
dependencies:
browserify-zlib: 0.2.0
pngjs@3.4.0: {}
postcss-import@14.1.0(postcss@8.5.8):
@@ -10256,6 +10222,8 @@ snapshots:
onetime: 5.1.2
signal-exit: 3.0.7
restructure@3.0.2: {}
reusify@1.1.0: {}
rollup@4.60.1:
@@ -10586,13 +10554,15 @@ snapshots:
tapable@2.3.2: {}
terser-webpack-plugin@5.4.0(webpack@5.97.1):
terser-webpack-plugin@5.4.0(esbuild@0.20.2)(webpack@5.97.1(esbuild@0.20.2)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.3
terser: 5.46.1
webpack: 5.97.1
webpack: 5.97.1(esbuild@0.20.2)
optionalDependencies:
esbuild: 0.20.2
terser@5.46.1:
dependencies:
@@ -10613,6 +10583,8 @@ snapshots:
timm@1.7.1: {}
tiny-inflate@1.0.3: {}
tinycolor2@1.6.0: {}
tmp@0.0.33:
@@ -10641,12 +10613,12 @@ snapshots:
triple-beam@1.4.1: {}
ts-jest@29.4.9(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3):
ts-jest@29.4.9(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(esbuild@0.20.2)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
handlebars: 4.7.9
jest: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
jest: 29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3))
json5: 2.2.3
lodash.memoize: 4.1.2
make-error: 1.3.6
@@ -10659,6 +10631,7 @@ snapshots:
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.29.0)
esbuild: 0.20.2
jest-util: 29.7.0
ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3):
@@ -10678,25 +10651,6 @@ snapshots:
typescript: 5.9.3
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
optional: true
ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3):
dependencies:
'@cspotcode/source-map-support': 0.8.1
'@tsconfig/node10': 1.0.12
'@tsconfig/node12': 1.0.11
'@tsconfig/node14': 1.0.3
'@tsconfig/node16': 1.0.4
'@types/node': 25.5.2
acorn: 8.16.0
acorn-walk: 8.3.5
arg: 4.1.3
create-require: 1.1.1
diff: 4.0.4
make-error: 1.3.6
typescript: 5.9.3
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
tsconfig-paths-webpack-plugin@4.2.0:
dependencies:
@@ -10743,8 +10697,6 @@ snapshots:
undici-types@6.21.0: {}
undici-types@7.18.2: {}
unicode-canonical-property-names-ecmascript@2.0.1: {}
unicode-match-property-ecmascript@2.0.0:
@@ -10754,8 +10706,18 @@ snapshots:
unicode-match-property-value-ecmascript@2.2.1: {}
unicode-properties@1.4.1:
dependencies:
base64-js: 1.5.1
unicode-trie: 2.0.0
unicode-property-aliases-ecmascript@2.2.0: {}
unicode-trie@2.0.0:
dependencies:
pako: 0.2.9
tiny-inflate: 1.0.3
unimport@4.1.1:
dependencies:
acorn: 8.16.0
@@ -10891,7 +10853,7 @@ snapshots:
webpack-virtual-modules@0.6.2: {}
webpack@5.97.1:
webpack@5.97.1(esbuild@0.20.2):
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.8
@@ -10913,7 +10875,7 @@ snapshots:
neo-async: 2.6.2
schema-utils: 3.3.0
tapable: 2.3.2
terser-webpack-plugin: 5.4.0(webpack@5.97.1)
terser-webpack-plugin: 5.4.0(esbuild@0.20.2)(webpack@5.97.1(esbuild@0.20.2))
watchpack: 2.5.1
webpack-sources: 3.3.4
transitivePeerDependencies: