跳到主要内容

Prisma、Node.js 与数据库新手教程

这份文档面向刚开始接触数据库的人。你可以先把数据库理解成“很多张 Excel 表”,Prisma 是项目里帮 Node.js 读写这些表的工具。

本文只讲当前项目实际用到的做法,不追求覆盖所有 Prisma 和 PostgreSQL 知识。

1. 项目里谁负责什么

当前项目的后端在 server/ 目录,技术关系可以这样理解:

小程序页面
-> 调用后端 API
-> NestJS Controller 接收请求
-> Service 写业务逻辑
-> PrismaService 操作数据库
-> PostgreSQL 保存数据

几个关键文件:

文件作用
server/prisma/schema.prisma定义数据库有哪些表、字段、关系和枚举
server/src/prisma/prisma.service.ts创建 Prisma 数据库客户端,并在服务启动/关闭时连接和断开
server/src/prisma/prisma.module.tsPrismaService 注册成全局服务,其他模块可以直接注入使用
server/.env本地或服务器真实运行配置,包含数据库连接地址,不提交 git
server/.env.example本地开发配置模板
server/.env.production.example生产配置模板
server/prisma/migrations/每次表结构变化生成的迁移文件

2. 数据库先按 Excel 理解

数据库概念可以理解成
table / 表一张 Excel 表
row / record / 记录Excel 里的一行
column / 字段Excel 里的一列
primary key / 主键每行数据的唯一编号,常见字段是 id
foreign key / 外键指向另一张表某一行的编号,比如 offeringId
relation / 关系Prisma 帮你根据外键把两张表连起来
enum / 枚举固定选项,比如订单状态只能是 PENDING_FORM

例如 Order 可以理解成订单 Excel 表:

idorderNouserIdofferingIdstatus
order-1202607030001user-1offering-1PENDING_FORM

ProductOffering 可以理解成在售产品 Excel 表:

idofferingCodedisplayNamevisibility
offering-1staff_x企业员工问诊ENTERPRISE

订单表里的 offeringId = offering-1,就表示这张订单对应 ProductOffering 表里 id = offering-1 的产品。

3. Prisma schema 怎么看

数据库结构定义在:

server/prisma/schema.prisma

开头这段说明项目用 PostgreSQL,并且数据库地址来自环境变量 DATABASE_URL

datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}

model 表示一张表。例如:

model Order {
id String
orderNo String
userId String
offeringId String
status OrderStatus
}

你可以读成:

有一张 Order 表。
它有 id、orderNo、userId、offeringId、status 这些字段。

enum 表示固定选项。例如:

enum OrderStatus {
PENDING_PAYMENT
PENDING_FORM
PENDING_TRIAGE
COMPLETED
}

你可以读成:

订单状态只能从这些值里选。

4. 关系字段怎么理解

以订单和在售产品为例,Order 里有:

offeringId String
offering ProductOffering @relation(fields: [offeringId], references: [id])

这两行要分开理解:

offeringId 是数据库 Order 表里的真实字段。
offering 是 Prisma 提供的关系字段,不是 Order 表里的真实字段。

这句话:

@relation(fields: [offeringId], references: [id])

意思是:

Order.offeringId 指向 ProductOffering.id。

所以你在 Prisma 代码里可以写:

await this.prisma.order.findFirst({
where: {
offering: {
is: {
offeringCode: 'staff_x',
},
},
},
});

它的中文意思是:

查订单,并且要求这条订单关联的在售产品 offeringCode 是 staff_x。

但如果你已经有当前产品的 id,更推荐直接查真实外键:

await this.prisma.order.findFirst({
where: {
offeringId: offering.id,
},
});

这个更容易读:

查订单,要求订单的 offeringId 等于当前产品 id。

5. Node.js 是怎么用 Prisma 的

项目里封装了一个 PrismaService

export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit(): Promise<void> {
await this.$connect();
}

async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
}

它做两件事:

服务启动时连接数据库。
服务关闭时断开数据库。

PrismaModule 把它注册成全局模块:

@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

所以其他 Service 里可以这样用:

constructor(private readonly prisma: PrismaService) {}

然后就能写:

await this.prisma.order.findFirst(...)
await this.prisma.order.create(...)
await this.prisma.productOffering.findUniqueOrThrow(...)

6. 数据库是怎么连接上的

连接地址在 DATABASE_URL

本地开发模板:

server/.env.example

里面有:

DATABASE_URL="postgresql://postgres@127.0.0.1:5432/mp_health_dev?schema=public"

这段可以拆开理解:

postgresql://              使用 PostgreSQL 数据库
postgres 用户名
127.0.0.1 数据库地址,本机
5432 数据库端口
mp_health_dev 数据库名称
schema=public 使用 public 这个 schema

生产模板:

server/.env.production.example

里面是:

DATABASE_URL="postgresql://postgres@127.0.0.1:5432/health_lvtong?schema=public"

本地和生产都会读取实际的:

server/.env

注意:

server/.env 不提交 git。
生产环境不要连接 mp_health_dev 这种开发库。

7. Prisma Client 是什么

schema.prisma 是数据库结构说明,但 Node.js 代码真正调用的是生成出来的 Prisma Client。

生成命令:

cd server
npm run prisma:generate

它会根据 schema.prisma 生成类型和查询方法。

什么时候需要执行:

场景是否需要
修改了 schema.prisma需要
拉取了别人新增的迁移和 schema建议执行
安装依赖后首次启动需要
只是改业务代码通常不需要

8. 常见 Prisma 查询怎么读

查一条订单:

await this.prisma.order.findFirst({
where: {
userId,
status: OrderStatus.PENDING_FORM,
},
});

读法:

找这个用户的一条待完善订单。

按唯一字段查产品:

await this.prisma.productOffering.findUniqueOrThrow({
where: {
offeringCode: input.offeringCode,
},
});

读法:

根据 offeringCode 找一个产品。
找不到就抛出错误。

创建订单:

await this.prisma.order.create({
data: {
orderNo: createOrderNo(),
userId: input.userId,
offeringId: offering.id,
status: OrderStatus.PENDING_FORM,
},
});

读法:

新增一条订单。

更新订单:

await this.prisma.order.update({
where: {
id: order.id,
},
data: {
status: OrderStatus.PENDING_TRIAGE,
},
});

读法:

找到这条订单,把状态改成 PENDING_TRIAGE。

查列表:

await this.prisma.order.findMany({
where: {
userId,
},
orderBy: {
createdAt: 'desc',
},
take: 10,
});

读法:

查这个用户的订单列表,按创建时间倒序,最多取 10 条。

9. include 和 select 怎么选

include 表示“把关联数据也带回来”。

await this.prisma.order.findFirst({
where: { orderNo },
include: {
offering: true,
payments: true,
},
});

读法:

查订单,同时把关联的产品和支付记录也查出来。

select 表示“只取我指定的字段”。

await this.prisma.order.findFirst({
where: { orderNo },
select: {
orderNo: true,
status: true,
offering: {
select: {
offeringCode: true,
displayName: true,
},
},
},
});

读法:

只取订单号、状态、关联产品的编码和名称。

简单规则:

写法用途
where查询条件
data创建或更新时写入的数据
include把整个关联对象带回来
select只取指定字段
orderBy排序
skip / take分页

10. 写新查询的思考顺序

写 Prisma 查询时,按这个顺序想:

  1. 我要查哪张表?
  2. 我要查一条还是多条?
  3. 查询条件是什么?
  4. 需要关联表数据吗?
  5. 是读取、新增、修改,还是删除?

例如需求是:

查当前用户是否已有同一个产品的待完善订单。

拆开:

主表:Order
一条还是多条:只要知道有没有,一条即可
条件:
- userId 是当前用户
- status 是 PENDING_FORM
- offeringId 是当前产品 id

代码:

await this.prisma.order.findFirst({
where: {
userId,
status: OrderStatus.PENDING_FORM,
offeringId: offering.id,
},
});

11. 表结构修改怎么做

表结构修改包括:

新增表
新增字段
修改字段类型
新增枚举值
新增索引
新增关系
删除字段

标准流程:

  1. 修改 server/prisma/schema.prisma
  2. 在本地生成迁移。
  3. 重新生成 Prisma Client。
  4. 修改业务代码。
  5. 跑测试。
  6. 提交 schema.prismaserver/prisma/migrations/

本地开发命令:

cd server
npm run prisma:migrate
npm run prisma:generate
npm test

npm run prisma:migrate 实际执行的是:

prisma migrate dev

它会:

读取 schema.prisma
对比本地数据库当前结构
生成新的 migration 文件
把 migration 应用到本地数据库

重要:

prisma migrate dev 只在本地开发环境使用。
不要在线上生产环境执行 prisma migrate dev。

生产环境发布时使用:

cd server
npm run prisma:migrate:deploy
npm run prisma:generate

migrate deploy 只会执行已经提交的迁移文件,不会在生产环境临时生成新迁移。

12. 新增字段示例

假设要给订单增加一个“用户备注”字段。

第一步,修改 schema.prisma

model Order {
id String @id @default(uuid())
orderNo String @unique
userNote String?
}

String?? 表示可以为空。

第二步,生成迁移:

cd server
npm run prisma:migrate

Prisma 会让你输入迁移名称,可以填:

add_order_user_note

第三步,生成 Prisma Client:

npm run prisma:generate

第四步,在代码里使用:

await this.prisma.order.update({
where: { id: order.id },
data: { userNote: input.userNote },
});

第五步,跑测试:

npm test

13. 新增表和关系示例

假设要新增一张“订单备注记录”表,一个订单可以有多条备注。

schema 可以这样写:

model Order {
id String @id @default(uuid())
orderNo String @unique
notes OrderNote[]
}

model OrderNote {
id String @id @default(uuid())
orderId String
content String
createdAt DateTime @default(now()) @db.Timestamptz(3)
order Order @relation(fields: [orderId], references: [id])

@@index([orderId])
}

理解方式:

OrderNote.orderId 是真实字段,保存订单 id。
OrderNote.order 是 Prisma 关系字段,表示这条备注属于哪个订单。
Order.notes 是反向关系,表示一个订单有多条备注。

查询一个订单并带备注:

await this.prisma.order.findFirst({
where: { orderNo },
include: {
notes: true,
},
});

14. 索引是什么,什么时候加

索引可以先理解成“目录”。

没有索引时,数据库可能要从头到尾翻表。

有索引时,数据库可以更快找到符合条件的数据。

项目里常见索引:

@@index([userId, createdAt])
@@index([offeringId])
@@index([status, createdAt])

大致读法:

经常按 userId + createdAt 查订单列表,所以加索引。
经常按 offeringId 查订单,所以加索引。
经常按 status + createdAt 查订单,所以加索引。

什么时候考虑加索引:

场景是否考虑索引
列表页经常按某字段筛选考虑
经常按某字段排序考虑
某字段用于关联另一张表通常要有
字段很少用于查询通常不用

不要为了所有字段都加索引。索引会加快查询,但也会让写入、更新变慢一点。

15. 迁移文件是什么

迁移文件在:

server/prisma/migrations/

每个目录通常像这样:

20260702190000_order_intake_forms_jsonb/
migration.sql

它记录了“数据库结构要怎么变化”。

你可以理解为:

schema.prisma 是最终结构说明书。
migrations 是一步一步改造数据库的施工记录。

提交代码时,如果改了表结构,通常要同时提交:

server/prisma/schema.prisma
server/prisma/migrations/<新迁移目录>/migration.sql

16. 本地迁移和生产迁移区别

环境命令做什么
本地开发npm run prisma:migrate根据 schema 生成新迁移,并应用到本地库
本地/生产npm run prisma:generate根据 schema 生成 Prisma Client
生产发布npm run prisma:migrate:deploy执行已经提交的迁移

生产环境不要执行:

prisma migrate dev

原因:

它是开发命令,可能要求重置数据库或生成新迁移。
生产数据不能随便重置。

16.1 生产数据库怎么同步表结构

新增表、删除表、修改字段、增加索引、增加枚举值,都属于表结构变更。

正确流程是:

本地修改 schema.prisma
-> 本地生成 migration
-> 本地测试
-> 提交 schema.prisma 和 migrations
-> 生产服务器拉取代码
-> 生产执行 prisma migrate deploy

本地开发时执行:

cd server
npm run prisma:migrate
npm run prisma:generate
npm test

这一步会生成新的迁移目录,例如:

server/prisma/migrations/20260703120000_add_xxx_table/migration.sql

提交代码时,至少要包含:

server/prisma/schema.prisma
server/prisma/migrations/<新迁移目录>/migration.sql

生产服务器上不要执行:

prisma migrate dev

生产只执行已经提交的迁移:

cd server
npm run prisma:migrate:deploy
npm run prisma:generate

本项目日常发布脚本已经包含生产迁移步骤,正常发布直接执行:

bash server/scripts/deploy-production.sh

这个脚本默认会按顺序做:

git pull
npm ci
prisma migrate deploy
prisma generate
npm run build
pm2 reload
健康检查

删除表、删除字段、修改字段类型要额外谨慎。执行生产迁移前,至少确认:

1. 生产数据库已有备份。
2. migration.sql 已人工检查。
3. 代码已经不再读取被删除的表或字段。
4. 可以接受这次结构变更的回滚成本。

如果 migration.sql 里出现下面这些语句,要停下来认真确认:

DROP TABLE
DROP COLUMN
ALTER COLUMN

这些通常意味着会删除数据或改变已有数据的保存方式。

17. 日常数据排查怎么做

排查问题时,优先从业务问题开始拆。

例如:

用户说下单后看不到订单。

先问:

用户是谁?userId 或手机号是什么?
订单号是什么?
产品 offeringCode 是什么?
大概什么时间下单?

然后按表查:

想查什么主要看哪张表
用户是否存在User
用户订单Order
下单产品ProductOffering
支付状态Payment
退款状态Refund
状态流转记录OrderStatusEvent
后台任务Job 相关表,以 schema 实际定义为准

18. 用 Prisma 写临时排查脚本

如果不熟 SQL,建议用 Prisma 写临时脚本排查。

可以在 server/scripts/ 下新增临时脚本,例如:

server/scripts/debug-order.ts

示例:

import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

async function main() {
const order = await prisma.order.findFirst({
where: {
orderNo: '替换成订单号',
},
include: {
offering: true,
serviceDefinition: true,
payments: true,
refunds: true,
events: {
orderBy: { createdAt: 'asc' },
},
},
});

console.dir(order, { depth: null });
}

main()
.finally(async () => {
await prisma.$disconnect();
});

运行:

cd server
npx tsx scripts/debug-order.ts

注意:

临时排查脚本不要提交真实订单号、手机号、openid 等敏感信息。
排查完如果没有长期价值,应删除脚本。

19. 用 psql 做简单排查

如果需要直接进数据库,可以用 psql

连接本地库:

psql "postgresql://postgres@127.0.0.1:5432/mp_health_dev?schema=public"

进入后常用命令:

\dt

查看有哪些表。

\d "Order"

查看 Order 表结构。

SELECT "orderNo", "userId", status, "offeringId", "createdAt"
FROM "Order"
ORDER BY "createdAt" DESC
LIMIT 10;

查看最近 10 条订单。

SELECT id, "offeringCode", "displayName", visibility, "isOnSale"
FROM "ProductOffering"
ORDER BY "updatedAt" DESC
LIMIT 20;

查看最近更新的产品。

注意:

如果表名或字段名是大写开头,SQL 里要用双引号。
比如 "Order"、"ProductOffering"、"orderNo"。

如果你还不熟 SQL,优先用 Prisma 临时脚本。SQL 查询要先从只读 SELECT 开始,不要一上来写 UPDATE 或 DELETE。

19.1 常用数据库排查 SQL

下面这些 SQL 主要用于只读排查。复制使用前,先把示例里的订单号、用户 ID、产品编码替换成真实值。

本项目使用 PostgreSQL,很多表名和字段名是大写或驼峰写法,所以 SQL 里要加双引号:

"Order"
"ProductOffering"
"orderNo"
"offeringCode"

查最近订单

用于快速确认最近是否有新订单写入。

SELECT
"orderNo",
"userId",
status,
"serviceType",
"paymentMode",
"payableCents",
"createdAt"
FROM "Order"
ORDER BY "createdAt" DESC
LIMIT 20;

按订单号查订单主信息

这是订单排查最常用的入口。

SELECT
id,
"orderNo",
"userId",
"offeringId",
"serviceDefinitionId",
"serviceType",
status,
"titleSnapshot",
"priceCents",
"payableCents",
"paymentMode",
source,
"createdAt",
"updatedAt",
"paidAt",
"completedAt",
"cancelledAt"
FROM "Order"
WHERE "orderNo" = '替换成订单号';

按订单号查产品信息

订单表只保存 offeringId,产品名称、编码、上下架状态在 ProductOffering

SELECT
o."orderNo",
o.status AS "orderStatus",
p.id AS "offeringId",
p."offeringCode",
p."displayName",
p.visibility,
p."isOnSale",
p."allowSelfPay",
p."priceCents"
FROM "Order" o
JOIN "ProductOffering" p ON p.id = o."offeringId"
WHERE o."orderNo" = '替换成订单号';

按用户查订单列表

用于排查“用户说看不到订单”。

SELECT
"orderNo",
status,
"serviceType",
"titleSnapshot",
"payableCents",
"paymentMode",
"createdAt"
FROM "Order"
WHERE "userId" = '替换成用户ID'
ORDER BY "createdAt" DESC
LIMIT 50;

按手机号查用户

手机号是敏感信息,查询结果不要复制到公开文档或提交到 git。

SELECT
id,
mobile,
"wechatOpenid",
status,
"createdAt",
"lastLoginAt"
FROM "User"
WHERE mobile = '替换成手机号';

查到用户 id 后,再用上一段“按用户查订单列表”继续排查。

按产品编码查在售产品

用于确认前端传入的 offeringCode 是否存在、是否企业权益、是否在售。

SELECT
id,
"offeringCode",
"displayName",
visibility,
"isOnSale",
"allowSelfPay",
"priceCents",
"serviceDefinitionId",
"audienceSegmentId",
"createdAt",
"updatedAt"
FROM "ProductOffering"
WHERE "offeringCode" = '替换成产品编码';

查所有在售产品

用于核对当前有哪些产品可下单。

SELECT
"offeringCode",
"displayName",
visibility,
"isOnSale",
"allowSelfPay",
"priceCents",
"sortOrder"
FROM "ProductOffering"
WHERE "isOnSale" = true
ORDER BY visibility, "sortOrder", "updatedAt" DESC;

查同一产品的待完善订单

用于排查“为什么下单被拦截去完善旧订单”。

SELECT
o."orderNo",
o."userId",
o.status,
o."titleSnapshot",
p."offeringCode",
p."displayName",
o."createdAt"
FROM "Order" o
JOIN "ProductOffering" p ON p.id = o."offeringId"
WHERE o."userId" = '替换成用户ID'
AND o.status = 'PENDING_FORM'
AND p."offeringCode" = '替换成产品编码'
ORDER BY o."createdAt" DESC;

查订单支付记录

用于排查“已支付但订单状态不对”或“支付回调是否成功”。

SELECT
p.id,
p."orderId",
p."outTradeNo",
p.provider,
p.status,
p."amountCents",
p."transactionId",
p."createdAt",
p."updatedAt",
p."paidAt"
FROM "Payment" p
JOIN "Order" o ON o.id = p."orderId"
WHERE o."orderNo" = '替换成订单号'
ORDER BY p."createdAt" DESC;

按商户单号查支付记录

如果微信支付侧给的是 out_trade_no,通常对应这里的 outTradeNo

SELECT
id,
"orderId",
"outTradeNo",
status,
"amountCents",
"transactionId",
"createdAt",
"paidAt"
FROM "Payment"
WHERE "outTradeNo" = '替换成商户单号';

查订单退款记录

用于排查退款是否发起、是否成功、失败原因是什么。

SELECT
r.id,
r.status,
r."amountCents",
r.reason,
r."outRefundNo",
r."operatorId",
r."requestedAt",
r."succeededAt",
r."failedAt"
FROM "Refund" r
JOIN "Order" o ON o.id = r."orderId"
WHERE o."orderNo" = '替换成订单号'
ORDER BY r."requestedAt" DESC;

查订单状态流转记录

用于排查订单状态是何时、因为什么原因变化的。

SELECT
e."fromStatus",
e."toStatus",
e."actorType",
e."actorId",
e.reason,
e.metadata,
e."createdAt"
FROM "OrderStatusEvent" e
JOIN "Order" o ON o.id = e."orderId"
WHERE o."orderNo" = '替换成订单号'
ORDER BY e."createdAt" ASC;

查订单通知发送记录

用于排查管理员或用户是否收到微信订阅消息。

SELECT
"orderNo",
scene,
status,
"receiverUserId",
"receiverOpenid",
"templateId",
error,
"sentAt",
"createdAt"
FROM "NotificationLog"
WHERE "orderNo" = '替换成订单号'
ORDER BY "createdAt" DESC;

查待执行或失败任务

用于排查通知、提醒等异步任务是否卡住。

SELECT
id,
type,
status,
payload,
attempts,
"maxAttempts",
"lastError",
"runAt",
"lockedAt",
"finishedAt",
"createdAt"
FROM "Job"
WHERE status IN ('PENDING', 'FAILED')
ORDER BY "runAt" ASC
LIMIT 50;

如果只想按订单号查任务,可以在 JSON 载荷里搜订单号:

SELECT
id,
type,
status,
payload,
attempts,
"lastError",
"createdAt"
FROM "Job"
WHERE payload::text LIKE '%替换成订单号%'
ORDER BY "createdAt" DESC;

查审计日志

用于排查后台谁操作过订单、做了什么动作。

SELECT
action,
"actorType",
"actorUserId",
"resourceType",
"resourceId",
"orderNo",
before,
after,
metadata,
"createdAt"
FROM "AuditLog"
WHERE "orderNo" = '替换成订单号'
ORDER BY "createdAt" DESC;

统计不同状态订单数量

用于快速看订单状态分布。

SELECT
status,
COUNT(*) AS count
FROM "Order"
GROUP BY status
ORDER BY count DESC;

查某天创建的订单

项目使用 Asia/Shanghai 业务时区,但数据库时间字段是 timestamptz。按自然日排查时,建议明确写出时区边界。

SELECT
"orderNo",
status,
"titleSnapshot",
"createdAt"
FROM "Order"
WHERE "createdAt" >= TIMESTAMPTZ '2026-07-03 00:00:00+08'
AND "createdAt" < TIMESTAMPTZ '2026-07-04 00:00:00+08'
ORDER BY "createdAt" DESC;

排查 SQL 的基本套路

遇到问题时,按这个顺序查:

1. 先查 Order 主表,确认订单是否存在、状态是什么。
2. 再查 ProductOffering,确认下单产品是否正确。
3. 涉及支付,查 Payment。
4. 涉及退款,查 Refund。
5. 涉及状态变化,查 OrderStatusEvent。
6. 涉及通知或异步任务,查 NotificationLog 和 Job。
7. 涉及后台操作,查 AuditLog。

19.2 Navicat 入门

Navicat 是图形化数据库管理工具。你可以把它理解成“数据库版 Excel + 查询窗口”。它适合浏览表、筛选数据、写只读 SQL、导出少量排查结果。

连接数据库

新建 PostgreSQL 连接时,通常填写:

Navicat 字段对应 DATABASE_URL
Host / 主机127.0.0.1 或服务器数据库地址
Port / 端口5432
User Name / 用户名postgres
Password / 密码数据库密码
Database / 数据库mp_health_devhealth_lvtong
Schema通常是 public

以本地开发连接为例:

DATABASE_URL="postgresql://postgres@127.0.0.1:5432/mp_health_dev?schema=public"

对应 Navicat:

Host: 127.0.0.1
Port: 5432
User: postgres
Database: mp_health_dev
Schema: public

如果生产数据库只允许服务器内网访问,不要直接把数据库端口暴露到公网。需要远程连接时,优先使用 SSH Tunnel 或服务器安全组白名单。

浏览表结构

连接成功后,常见路径是:

连接名
-> Databases
-> 目标数据库
-> Schemas
-> public
-> Tables

常看的表:

Order
ProductOffering
Payment
Refund
OrderStatusEvent
NotificationLog
Job
AuditLog
User

打开表后可以看到字段、数据和索引。字段名要和 schema.prisma 对照看,避免只凭界面猜业务含义。

用筛选器查数据

Navicat 打开表后通常有筛选功能。适合简单条件:

orderNo 等于某个订单号
userId 等于某个用户 ID
status 等于 PENDING_FORM
offeringCode 等于某个产品编码

如果涉及多张表,比如“订单关联产品”,不要在表格里来回点,直接打开查询窗口写 SQL 更清楚。

打开查询窗口

在 Navicat 中新建 Query / 查询,然后粘贴本文档里的 SQL。

建议先从这类只读语句开始:

SELECT
"orderNo",
status,
"createdAt"
FROM "Order"
ORDER BY "createdAt" DESC
LIMIT 20;

只读排查时,优先使用:

SELECT ...

不要随手执行:

UPDATE ...
DELETE ...
DROP ...
TRUNCATE ...

保存常用查询

你可以在 Navicat 里把常用查询保存成文件或收藏,例如:

按订单号查订单全链路
按用户查订单列表
查支付记录
查退款记录
查通知发送记录
查失败任务

保存查询时,不要把真实订单号、手机号、openid、身份证号写死在文件里。用占位符:

WHERE "orderNo" = '替换成订单号'

导出结果

排查时有时需要导出 CSV 或 Excel。导出前注意:

1. 只导出必要字段。
2. 不导出身份证号、openid、手机号等敏感字段,除非确实需要。
3. 文件不要提交 git。
4. 发给别人前先脱敏。

生产库使用 Navicat 的安全习惯

连接生产库时,建议这样做:

1. 连接名明确标注 PROD,例如 health_lvtong_PROD。
2. 生产连接使用不同颜色,避免误操作。
3. 默认只写 SELECT。
4. 执行 UPDATE 或 DELETE 前先 SELECT 确认影响范围。
5. 真要修改数据,优先写 Prisma 脚本,不要在 Navicat 里临时手改。
6. 不熟 SQL 时,不要在生产库使用“设计表”“删除表”“清空表”等按钮。

Navicat 界面里如果看到这些操作,要特别谨慎:

Delete Table / 删除表
Truncate Table / 清空表
Design Table / 设计表
New Query 里执行 DROP、DELETE、UPDATE

Navicat 是直接看数据库,Prisma 是项目代码操作数据库。

Navicat 适合排查和查看。
Prisma 适合业务代码和可复查的数据修复脚本。
schema.prisma 才是项目认可的表结构来源。

如果你在 Navicat 里手动改了表结构,但没有同步修改 schema.prisma 和 migrations,项目代码可能不知道这个变化,后续发布也可能覆盖或冲突。

所以表结构变更不要用 Navicat 点界面改,仍然走:

修改 schema.prisma
生成 migration
提交代码
生产 migrate deploy

20. 生产数据排查安全规则

生产环境排查要更谨慎:

  1. 优先只读查询。
  2. 不直接执行 DELETE
  3. 不直接执行大范围 UPDATE
  4. 修改生产数据前先备份、确认条件、找人复核。
  5. 查询结果不要复制到公开聊天或提交到 git。
  6. 不要把 DATABASE_URL、openid、手机号、身份证号写进文档或代码。

危险操作示例:

DELETE FROM "Order";

这会删除订单表数据,不要执行。

更安全的思路:

先 SELECT 确认影响范围。
再写极小范围的修复脚本。
修复脚本里明确 where 条件。
修复前备份。
修复后再次 SELECT 验证。

21. 修改数据应该怎么做

如果只是排查,用 SELECT 或 Prisma 查询。

如果要修复数据,优先写一次性脚本,而不是手动在数据库里乱改。

例如修复某个订单标题:

import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

async function main() {
const order = await prisma.order.update({
where: {
orderNo: '替换成订单号',
},
data: {
titleSnapshot: '修复后的标题',
},
});

console.log(order.orderNo, order.titleSnapshot);
}

main()
.finally(async () => {
await prisma.$disconnect();
});

运行前先把 update 改成 findUniquefindFirst 看看命中的是否是预期订单。

21.1 如何为生产数据库新增一条数据

生产新增数据和表结构迁移不是一回事。

表结构迁移:改变有哪些表、字段、索引和关系。
新增业务数据:往已有表里增加一条产品、配置、角色或其他业务记录。

生产新增业务数据,优先写一次性 Prisma 脚本,不建议直接手敲 SQL。

原因是:

Prisma 脚本可读、可复查、可测试。
可以用 upsert 避免重复插入。
可以保留执行记录。
比手写 INSERT 更不容易写错字段名和关系。

推荐流程:

本地写脚本
-> 在本地或测试库验证
-> 确认生产 server/.env 指向生产库
-> 生产服务器执行脚本
-> 查询验证结果

例如要新增或更新一个在售产品,可以写:

// server/scripts/ops/upsert-product-offering.ts
import { PrismaClient, ProductVisibility } from '@prisma/client';

const prisma = new PrismaClient();

async function main() {
const serviceDefinition = await prisma.serviceDefinition.findFirstOrThrow({
where: {
category: {
code: 'EMPLOYEE_SERVICE',
},
enabled: true,
},
});

const offering = await prisma.productOffering.upsert({
where: {
offeringCode: 'staff_x',
},
update: {
displayName: '企业员工问诊',
visibility: ProductVisibility.ENTERPRISE,
isOnSale: true,
},
create: {
offeringCode: 'staff_x',
serviceDefinitionId: serviceDefinition.id,
displayName: '企业员工问诊',
priceCents: 0,
visibility: ProductVisibility.ENTERPRISE,
isOnSale: true,
allowSelfPay: false,
},
});

console.log({
id: offering.id,
offeringCode: offering.offeringCode,
displayName: offering.displayName,
});
}

main()
.finally(async () => {
await prisma.$disconnect();
});

upsert 可以理解成:

如果 offeringCode 已存在,就更新。
如果 offeringCode 不存在,就创建。

生产服务器执行前,先确认当前目录和环境变量:

cd server
cat .env

重点确认:

NODE_ENV=production
DATABASE_URL 指向生产库 health_lvtong,而不是 mp_health_dev

然后执行脚本:

npx tsx scripts/ops/upsert-product-offering.ts

执行后用 Prisma 查询脚本或只读 SQL 验证数据是否正确。

如果必须直接写 SQL,至少先用事务包起来:

BEGIN;

SELECT id, "offeringCode", "displayName"
FROM "ProductOffering"
WHERE "offeringCode" = 'staff_x';

-- 确认无误后再 INSERT 或 UPDATE。

SELECT id, "offeringCode", "displayName"
FROM "ProductOffering"
WHERE "offeringCode" = 'staff_x';

ROLLBACK;

确认结果完全正确后,再把 ROLLBACK 改成 COMMIT。如果你还不熟 SQL,本项目优先使用 Prisma 脚本。

22. 常见错误怎么判断

现象常见原因处理
DATABASE_URL is required没有配置数据库连接检查 server/.env
Prisma P1003数据库不存在或连接到了错误库检查 DATABASE_URL 的库名
找不到某个 Prisma 字段改了 schema 但没 generate执行 npm run prisma:generate
生产启动拒绝连接生产连到了 dev/test/local 库修改生产 server/.env
迁移失败migration 和数据库当前结构不一致停止发布,先查迁移 SQL 和备份
查询结果没有关联对象没写 includeselect 关联字段加上 include: { offering: true }

23. 开发时的推荐流程

只改业务查询:

cd server
npm test

改了 schema.prisma

cd server
npm run prisma:migrate
npm run prisma:generate
npm test

拉取了别人改的 schema 或 migration:

cd server
npm install
npm run prisma:generate
npm run prisma:migrate:deploy
npm test

本地启动服务:

cd server
npm run dev

生产发布:

bash server/scripts/deploy-production.sh

24. 当前项目里最常见的写法

订单服务里常见:

constructor(private readonly prisma: PrismaService) {}

查产品:

const offering = await this.prisma.productOffering.findUniqueOrThrow({
where: { offeringCode: input.offeringCode },
include: {
serviceDefinition: { include: { category: true } },
audienceSegment: true,
},
});

创建订单:

await this.prisma.order.create({
data: {
orderNo: createOrderNo(),
userId: input.userId,
serviceDefinitionId: offering.serviceDefinitionId,
offeringId: offering.id,
status: OrderStatus.PENDING_FORM,
},
});

查订单详情:

await this.prisma.order.findFirst({
where: {
orderNo,
userId,
},
include: {
offering: true,
serviceDefinition: { include: { category: true } },
payments: { orderBy: { createdAt: 'desc' } },
refunds: true,
events: { orderBy: { createdAt: 'asc' } },
},
});

状态更新通常要配合状态机、事件和通知,不要只改一个字段就结束。

25. 最重要的几条习惯

  1. 写查询前先看 schema.prisma
  2. 主表真实字段可以直接写在 where
  3. 关系字段不是数据库真实列,是 Prisma 根据 @relation 提供的入口。
  4. 已经有外键 id 时,优先用外键字段查询。
  5. 改表结构一定要生成并提交 migration。
  6. 本地用 prisma migrate dev,生产用 prisma migrate deploy
  7. 生产排查先只读,修改数据要脚本化、可复核、可回滚。