NestJS+uniapp项目业务与设计笔记
写给自己看的笔记,记录
mp-health-appointment这个项目的业务是什么、server/后端为什么长这个样子。目的不是复述 schema 有哪些字段(那部分见《Node.js 后端与数据库入门:Prisma 实战笔记》),而是记录"为什么会这样设计"——很多地方第一眼看像是不规范或者绕远路,理解了背后的业务约束和历史包袱之后,会发现基本都是权衡后的合理选择。
一、这个项目是做什么的
这是一个心理健康 / 医疗咨询预约类的微信小程序,核心业务是把"用户"和"医生/咨询服务"匹配起来,走一遍从下单、填资料、客服分诊、安排履约、完成服务的完整流程。从 ServiceType 这个枚举能直接读出三类核心服务:
ONLINE_CONSULTATION:在线问诊/心理筛查,比如安排三甲医院的医生做视频咨询。REFERRAL_APPOINTMENT:转诊预约,帮用户协调线下医院的就诊资源。EMPLOYEE_SERVICE:企业员工服务,作为企业福利的一部分,员工可以免费或低价使用。
这三类服务共享同一套下单、支付、客服处理的骨架,但具体的资料收集、履约方式、是否收费差别很大,这个差异贯穿了后面几乎所有的设计决策。
三种付费方式对应三种业务关系
PaymentMode 只有三个值:WECHAT_PAY、BENEFIT、FREE,这不只是一个支付方式的技术分类,而是直接反映了三种不同的商业关系:
WECHAT_PAY:用户自己掏钱,公开渠道购买。BENEFIT:企业已经付过费,员工凭权益免费使用——本质上是 To B 的健康福利采购,落到用户侧表现为"免费下单"。FREE:纯粹的免费服务,不涉及任何一方付费(比如活动引流)。
理解了这一点,后面 ProductOffering 上的 allowSelfPay(是否允许自费购买)和 visibility(PUBLIC/ENTERPRISE)这两个字段的存在就很自然了——同一个服务,完全可能同时存在"面向公众收费"和"面向某企业员工免费"两个版本,这两个版本不是同一行数据,而是两个独立的 ProductOffering。
二、从 uniCloud 迁移到自建 NestJS + Prisma + PostgreSQL
这是整个项目最大的一次架构决策,理解了这次迁移的动机,后面很多"看起来多此一举"的字段和表就有了解释。
迁移前是什么样
翻旧的前端仓库根目录 README.md 能看到项目最初是基于 uni-app + uniCloud(阿里云云开发)搭建的:数据存在 uni-id-base-order 这类文档型集合里,权限控制靠 JQL 风格的字符串规则,比如:
"update": "doc._id == auth.uid || 'user' in auth.role"
旧 README 里还留着这样一条备注:
考虑支付表的权限更新,回调里面不包含用户信息,只能修改表为任何人可读,测试doc
这条备注暴露了 Serverless 云函数模式在这个业务场景下的一个真实困境:微信支付的回调请求不会带用户登录态,而 uniCloud 的权限规则又是"按文档字段和当前登录用户比对"这一套思路,回调场景根本没有"当前登录用户",最后只能把支付表的读权限放开成"任何人可读"来让回调跑通——这是一个用安全换可用性的妥协,而且是在数据库权限规则这一层做的妥协,影响面很大。
迁移之后解决了什么
换成 NestJS 自建后端之后,权限判断从"数据库规则字符串"变成了普通的服务端代码(Controller/Guard/Service 里的 if 判断),微信支付回调可以在服务端用商户密钥验签、在业务代码里精确判断这是哪一笔订单的回调,不需要为了让回调跑通而放宽整张表的读权限。同时数据模型从文档型集合换成了 PostgreSQL + Prisma:外键约束、类型系统、迁移记录都是关系型数据库原生就有的能力,不再需要在应用层自己维护"这个字段应该关联哪张表"的隐式约定。
迁移留下的痕迹
迁移不是推倒重来,而是把旧数据尽量原样搬过来,这在 schema 里留下了几处明显的痕迹,都是刻意保留、不是遗漏:
LegacyIdMap表:专门记录"旧文档_id对应新的哪个 UUID",迁移完成之后这张表还留着,作用是排查历史数据时能对照回旧系统的原始 ID。Order.operatorTime/Order.coordinatorTime以及一整套refundAmount/refundCount/refundDesc/refundTime快照字段:注释里写得很清楚,这些是"旧系统时间快照,兼容迁移后后台展示"——新的退款流程已经改用独立的Refund表和Order.status状态机来表达,但历史订单的这些旧字段还留着,只为了让老订单在新后台里还能正常展示,不需要为了保持这些字段"整洁"而回填一遍或者删掉。OrderStatus里的CANCELLED:注释写着"兼容旧数据的取消状态;新流转优先使用USER_CANCELLED或ADMIN_CANCELLED"。新流程已经把"谁取消的"这个信息拆得更细了,但旧数据里的取消状态就是笼统的CANCELLED,枚举里没有把这个值删掉,而是留着 + 写注释说明"新代码不要再产生这个值"。
这三处都是同一个原则的体现:迁移历史数据时,宁可让 schema 看起来不那么"干净",也不去动历史数据本身。手动改写、回填历史订单的字段风险远大于在 schema 里多留一个带注释的兼容字段。
三、核心业务模型:服务、商品、人群入口为什么拆成三层
刚看 schema 时最容易困惑的地方是:为什么不是一张"产品表"了事,而是拆成了 ServiceDefinition(服务定义)、ProductOffering(在售商品)、AudienceSegment(人群入口)、AccessChannel(渠道)四张表。这背后是电商领域常见的 SPU/SKU 拆分思路,只是换了一套业务语言。
ServiceDefinition:服务能力本身
ServiceDefinition 描述的是"这项服务具体是什么"——收集资料用哪个表单模板(formTemplateCode)、属于哪个服务分类(categoryId)。它的注释特意强调"不直接承载前端套餐展示字段",意思是这一层不管价格、不管展示名称、不管能不能自费购买,它只回答"这是什么服务、需要收集什么资料"。
ProductOffering:同一个服务的不同售卖方式
ProductOffering 才是真正面向用户售卖的那个东西——有价格(priceCents)、有展示名称(displayName)、有上下架状态(isOnSale)、有能不能自费购买(allowSelfPay)。关键在于注释这句话:
一个服务定义可以有多个 Offering,用于区分渠道、员工类型、价格和上下架策略。
举个具体例子:同一个"心理筛查"服务(一个 ServiceDefinition),可能同时存在两个 ProductOffering:一个 offeringCode 是 psy_screen_public,公开渠道、priceCents 是 19900、allowSelfPay: true;另一个 offeringCode 是 staff_x,企业渠道、priceCents 是 0、visibility: ENTERPRISE、allowSelfPay: false。两者背后接的是同一套问诊资料收集流程和同一批医生资源,只是定价和准入方式不同。如果不拆开这两层,价格和渠道信息会被迫直接堆在服务本身上,没法表达"同一服务、多种卖法"这种关系。
AudienceSegment 与 AccessChannel:人群入口用数据表达,不用代码判断
ProductOffering 上有一个可选的 audienceSegmentId,指向 AudienceSegment(人群入口),AudienceSegment 又挂在 AccessChannel(渠道:PUBLIC/ENTERPRISE/CAMPAIGN)下面。这一层的存在是为了避免"每接入一个新企业客户就要改一次代码"——AudienceSegment 的注释写着"公共入口和企业权益入口都用数据行表达",意思是运营新增一个企业客户的员工福利入口,只需要在数据库里插入一行 AccessChannel 和一行 AudienceSegment,再挂上对应的 ProductOffering,不需要为每个新客户单独发版本。
四、订单状态机:为什么会有这么多状态
OrderStatus 有十三个值,第一次看容易觉得"是不是设计过度了",但拆开看会发现每一个状态都对应一个真实存在的业务阶段,而不是拍脑袋加的:
PENDING_PAYMENT 待支付订单等用户付款
-> PENDING_FORM 等用户填资料(免费/权益订单直接从这里开始)
-> PENDING_TRIAGE 等客服分诊接单
-> ACCEPTED 客服已接单,进入协调阶段
-> SCHEDULED 已安排履约信息(会议时间/医院科室)
-> IN_SERVICE 服务进行中
-> COMPLETED 服务完成
这条主链路之外,还有两条独立的分支:
- 取消分支:
USER_CANCELLED(用户自己取消)和ADMIN_CANCELLED(客服/运营取消)分开记录,而不是笼统一个"已取消"——这样后台统计"取消率"时能区分是用户主动放弃、还是运营判断资源协调不上而主动关单,这两种取消对业务的含义完全不同。 - 退款分支:
REFUND_PENDING→REFUNDED/REFUND_FAILED,对应"用户申请退款 → 微信支付回调成功或失败"这个异步过程,不能一步到位标记成"已退款",因为退款请求发出去和退款真正到账之间有一个不确定的等待期,这期间订单需要一个明确的中间状态。
状态只能由后端推进,前端不能直接写
Order 模型的注释第一句就是"订单状态只能通过后端状态机推进,前端不能直接写任意状态"。这不是一句客套话,而是直接决定了 API 设计:不会有一个"更新订单状态"这样的通用接口让前端传任意 status 值,而是每个业务动作(提交资料、客服接单、安排履约、标记完成、申请退款)各自对应一个专门的接口,接口内部校验"当前状态允许不允许做这个动作",再决定下一个状态是什么。这样状态之间的合法跳转关系被收在后端一处维护,不会出现"前端传了个不该出现的状态值"这种问题。
OrderStatusEvent:每一次状态变化都留痕
光有当前状态还不够,客服排查"这个订单为什么卡住了"时,需要知道状态是怎么一步步走过来的,谁在什么时候做的、为什么。这正是 OrderStatusEvent 表存在的意义——每次状态变化都写一条记录,包含 fromStatus、toStatus、actorType(谁触发的:用户/客服/系统)、reason。这张表和 Order.status 是两回事:Order.status 只是"当前是什么状态"这一个快照,OrderStatusEvent 是"这个订单完整的状态变化历史",两者配合起来,客服才能在后台完整回放一个订单的处理过程。
五、支付与退款:为什么独立成表,金额为什么用整数分
一个订单可能对应多条支付记录
Payment 没有直接做成 Order 上的几个字段,而是独立成一张表,且是一对多关系,注释解释得很直接:"一个订单可因重试产生多条支付单,以 outTradeNo 区分微信支付交易"。真实场景是:用户下单后第一次支付超时或者取消了,重新发起了第二次支付——如果 Order 上只有一组"支付金额/支付时间"字段,第一次失败的支付记录就没地方存了,而这条失败记录本身对排查"用户说我明明付过款"这类客诉是有价值的。独立成表之后,一个订单下所有的支付尝试都完整保留。
outTradeNo(商户订单号)被要求全局唯一,这不是这个项目自己的设计选择,而是微信支付 V3 接口本身的要求——微信那边用这个字段做幂等判断,同一个 outTradeNo 重复发起不会被当成两笔独立的支付。Refund 表的 outRefundNo 是同样的道理。
金额为什么全部是 xxxCents 整数
priceCents、amountCents、payableCents 全部是整数、单位是分,而不是用小数表示"元"。这是处理金额的通用common sense:浮点数做加减法会有精度误差(0.1 + 0.2 !== 0.3 这种问题),涉及真实资金的字段一律换算成最小货币单位的整数,从根源上避免这一类精度问题,不需要在业务代码里到处做四舍五入。
支付回调必须校验金额
Payment.amountCents 的注释写着"必须和订单 payableCents 校验一致"。这是一条容易被忽略但很关键的安全规则:微信支付的回调本质上是外部请求,理论上不能假设回调带来的金额一定和订单应付金额一致(哪怕正常情况下应该一致),后端处理回调时要主动比对这两个值,不一致就要报警而不是直接按回调内容更新订单状态——这是防止金额被篡改或者对接出错导致资损的最后一道校验。
六、看起来"不规范"、实际是刻意设计的几处
这几个点是第一次看 schema 时最容易带着"这样写不太规范吧"的疑虑去看的地方,但结合业务场景看,都是权衡后的正确选择。
快照字段:titleSnapshot、userMobileSnapshot、audienceSegmentCodeSnapshot
Order 上有好几个带 Snapshot 后缀的字段,内容其实在别的表里也能查到(商品名称在 ProductOffering.displayName,手机号在 User.mobile)。第一反应可能是"这不是重复存储、违反范式吗",但订单场景里这是标准做法:订单要展示的是"下单那一刻"的信息,而不是"现在"的信息。如果 ProductOffering.displayName 后来改名了,或者用户后来换了手机号,历史订单列表不应该跟着变——用户当时买的就是那个名字的商品,客服当时联系的就是那个手机号,这些是历史事实,不能因为主表数据变了就跟着改写历史订单的展示内容。这正是电商订单系统里"下单快照"这个模式的典型用法。
formData 用 JSONB 而不是拆成一堆具体字段
Order.formData 是一个 JSON 字段,注释写着"保留旧小程序 yy_*、employee_*、consult_* 等字段名"。三类服务(在线问诊、转诊预约、企业员工服务)需要收集的资料完全不同,如果把每种服务要收集的字段都拆成 Order 表上的具体列,要么这张表会有大量"这个服务类型用不上"的空字段,要么就要为每种服务类型单独建一张资料表、再各自和 Order 关联——两种做法在这种"表单结构随服务类型变化、还在持续迭代"的场景下都会让维护成本明显上升。用 JSONB 换取灵活性,代价是这部分数据失去了数据库层面的类型约束,具体字段的校验和解释要靠代码里的表单模板逻辑(formTemplateCode)来完成,不是数据库能兜底的——这是一个明确的取舍,不是疏漏。
reportFiles 只存对象键,不存签名 URL
同样是 JSON 字段,reportFiles 的注释更值得注意:"只保存对象键与展示字段,不保存签名 URL、临时密钥或本地路径"。这是一条安全设计:项目用腾讯云 COS 存检查资料这类敏感文件,访问需要临时签名 URL,而签名 URL 本身是一种"持有即可访问"的凭证,有效期内谁拿到这个 URL 都能看到文件内容。如果直接把签名 URL 存进数据库(甚至存进日志),相当于把一个有时效性的访问凭证长期落盘,之后凭证泄露的面会变得很大。正确做法是只存不会过期、本身不具备访问能力的对象键(object key),每次真正要访问文件时才现场生成一个短时效的签名 URL——这也是为什么 DEPLOYMENT.md 里能看到必须完整配置 COS 密钥相关的环境变量,生成签名 URL 这个动作本身是需要密钥参与的。
User.mobile 明文存储,同时又有加密哈希的 UserPhone 表
User.mobile 上的注释是"当前系统需要客服展示和拨打电话,测试/自部署环境先直接保存",而与此同时又存在一张 UserPhone,用 mobileHash(用于匹配查重)+ mobileEncrypted(加密存储)的方式管理手机号。这是这个 schema 里少数注释里主动承认是权宜之计、而不是宣称"这就是最优设计"的地方:客服需要能直接看到、直接拨打用户手机号,这个真实的操作需求和"手机号应该加密存储"这个安全最佳实践之间存在直接冲突,当前选择的是优先满足客服的操作效率,代价是明文存储这个已知风险。留意到这一点,是因为这是这个项目里目前少数"还没有被彻底解决、只是被有意识记录下来"的取舍,值得在后续迭代里持续关注,而不是假装它不存在。
七、异步任务为什么用数据库轮询,而不是消息队列
Job 表和 JobsProcessor 这一套,本质上是自己实现了一个最小可用的任务队列:定时任务扫描 Job 表里状态是 PENDING 且到了执行时间的记录,取出来执行,成功标记 SUCCESS,失败记录 lastError。没有引入 Redis、RabbitMQ 或者任何专门的消息队列中间件。
这不是"不知道有更专业的方案",而是和部署规模直接相关的选择。DEPLOYMENT.md 里写得很明确:这是一台 2 核 4G 的单机,PM2 用单实例 fork 模式而不是 cluster,理由直接写在文档里——"服务里有定时任务,cluster 会造成任务重复执行"。在这个规模下,再额外起一个 Redis 或者消息队列服务,会显著增加这台小机器的资源压力和运维复杂度,而当前用数据库自身实现的轮询队列,配合下面这个加锁技巧,已经足够撑住现在的业务量:
const locked = await this.prisma.job.updateMany({
where: { id: job.id, status: 'PENDING' },
data: { status: 'RUNNING', lockedAt: datetime.now() },
});
if (locked.count === 0) continue; // 已经被别的执行流程抢走了,跳过
这里的技巧是:不是先查出任务、判断状态、再更新(这样两步之间可能有并发窗口),而是直接发起一次"条件是状态还是 PENDING 才更新成 RUNNING"的原子更新,根据受影响的行数(count)判断这次抢占是否成功——这是数据库层面天然具备的原子性,不需要额外的分布式锁就能避免同一个任务被处理两次。这也解释了为什么 PM2 必须用单实例:如果开了多个 Node 进程,虽然加锁逻辑本身还是安全的(不会重复执行同一个 Job),但业务里另一部分定时扫描逻辑(比如"扫描即将开始的问诊提前提醒")如果每个实例都各自扫描一遍、各自入队,就可能重复生成 Job 本身——加锁解决的是"同一个 Job 不会被跑两次",解决不了"同一个提醒不会被生成两次",所以更省事、更不容易出错的做法是从根上让扫描逻辑本身只有一个实例在跑。
NotificationLog 是另一张紧挨着这套机制的表,专门记录每一次微信订阅消息的发送结果(成功/失败/失败原因)。它和 Job 是两个层次:Job 是"任务有没有被正确执行",NotificationLog 是"通知有没有真的发到用户手机上"——微信订阅消息本身有额度限制、用户需要提前授权等一堆容易在具体某一条消息上失败的约束,把每次发送结果单独记录下来,是客服排查"用户说没收到通知"时的直接依据。
八、排查和追责能力是从一开始就内建的,不是后来补的
这个项目里有两套记录,容易被以为是重复的,但其实各自负责不同粒度:
OrderStatusEvent:只记录订单状态机的流转,字段是强类型的(fromStatus/toStatus直接是OrderStatus枚举),专门服务于"这个订单的处理进度是怎么走的"这一个问题。AuditLog:记录范围更广,任何一个管理员在后台对任何资源做的任何操作都可以往这里写一条,字段是相对自由的before/after/metadataJSON,专门服务于"后台是谁、在什么时候、对什么东西做了什么"这一个问题,不限于订单。
AuditLog 上还有一个 traceId 字段,这个和依赖列表里的 nestjs-pino 结构化日志库对上了——意味着一次请求从进入服务到写审计日志,中间可以用同一个 trace id 串起来,出问题时不需要靠时间范围硬猜,能直接按 trace id 把一次请求的完整日志和审计记录关联着看。
这套设计说明一个态度:客服/运营排查问题的能力,以及出问题后能不能说清楚"谁干的、什么时候干的",是从数据模型设计阶段就考虑进去的,不是等出了事故之后才临时加日志。对于一个涉及真实资金(支付)和真实医疗资源协调的业务,这个投入是必要的,而不是过度设计。
九、部署规模如何反过来约束了设计
回过头看,这个项目好几处设计决策,如果脱离"2 核 4G 单机部署"这个前提去看,会显得保守甚至"不够先进"(比如没用消息队列、没用集群模式),但放回这个前提下看,每一处都是合理的:
- 单实例
fork而不是cluster:直接因为定时任务用了最简单的数据库轮询实现,还没有做成"多实例安全"的形态,索性从部署层面保证只有一个实例在跑,用更简单的方式避免了并发问题。 max_memory_restart: 768M+--max-old-space-size=512:主动给 Node 进程设置一个偏保守的内存上限,让 V8 提前触发垃圾回收/重启,而不是让一个内存泄漏的进程把整台 4G 内存的机器拖垮——这是在"单机资源有限、没有多机做冗余"的前提下,用更激进的自我重启策略换取整体稳定性。- 生产环境启动时会主动校验
DATABASE_URL是不是生产库、TZ是不是Asia/Shanghai,不满足条件直接拒绝启动:这是把"人有可能会手滑连错库、忘记设时区"这类已知风险,从"写在文档里提醒人注意"升级成了"代码里做启动前置校验,错了直接不让启动"——这一点特别值得记住:同一类风险,写文档提醒是一种防护级别,写进启动校验逻辑是更高一级的防护,条件允许的时候,能用代码约束就不要只靠文档约束。
这也是这份笔记想强调的一点:这个项目目前的技术选型不是"因为团队不知道更复杂的方案",而是每一处都能在"当前的业务规模、当前的团队规模、当前愿意承担的运维复杂度"这个前提下找到合理性。以后如果业务量真的涨到单机扛不住了,这里列出的这几处(尤其是 Job 表轮询和单实例部署)会是最先需要重新设计的地方,到时候可以直接回来看这一节。
十、这份笔记想持续追踪的几个点
留给自己以后回看的问题清单,不是当前必须改的问题,而是值得持续关注、业务规模变化时优先重新评估的地方:
User.mobile明文存储 vs 客服操作效率的取舍(第六章)——如果之后合规要求变严格,这是第一个要重新设计的点。Job表轮询队列在业务量明显上涨后的扩展性天花板(第七、九章)——目前的设计前提是"单实例、量不大",这两个前提任何一个变了都要重新评估。formDataJSONB 缺少数据库层面的类型约束(第六章)——表单模板逻辑目前完全靠代码维护正确性,模板一多,会不会出现同一个语义字段在不同模板里叫法不一致的问题,值得留意。