diff --git a/CLAUDE.md b/CLAUDE.md index 1b7c26d..5b9f919 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`),存量补签只提醒;乙方为个体工商户,显示「经营者」而非法定代表人。不得加入「最终解释权」、到期不退、转卡收费等格式条款。 diff --git a/docs/membership-contract.md b/docs/membership-contract.md new file mode 100644 index 0000000..564c9e7 --- /dev/null +++ b/docs/membership-contract.md @@ -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` 指向生产库),需在部署后按上述流程真机验收。 diff --git a/packages/app/src/components/ContractDocumentView.vue b/packages/app/src/components/ContractDocumentView.vue new file mode 100644 index 0000000..d1c95e1 --- /dev/null +++ b/packages/app/src/components/ContractDocumentView.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/packages/app/src/components/ContractPendingBanner.vue b/packages/app/src/components/ContractPendingBanner.vue new file mode 100644 index 0000000..16c83b9 --- /dev/null +++ b/packages/app/src/components/ContractPendingBanner.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/packages/app/src/components/ProfileMenu.vue b/packages/app/src/components/ProfileMenu.vue index c1fabec..2d8945e 100644 --- a/packages/app/src/components/ProfileMenu.vue +++ b/packages/app/src/components/ProfileMenu.vue @@ -8,6 +8,7 @@ {{ item.title }} + {{ item.badge }} › @@ -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(() => { 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; } } diff --git a/packages/app/src/components/SignaturePad.vue b/packages/app/src/components/SignaturePad.vue new file mode 100644 index 0000000..76d093a --- /dev/null +++ b/packages/app/src/components/SignaturePad.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/packages/app/src/pages.json b/packages/app/src/pages.json index 501f358..064cb8f 100644 --- a/packages/app/src/pages.json +++ b/packages/app/src/pages.json @@ -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" + } } ] }, diff --git a/packages/app/src/pages/admin/contract-settings.vue b/packages/app/src/pages/admin/contract-settings.vue new file mode 100644 index 0000000..4d8c922 --- /dev/null +++ b/packages/app/src/pages/admin/contract-settings.vue @@ -0,0 +1,289 @@ +