// 叶雷讲数学 — 数据模型 // // 约定: // - 表名与字段名统一英文 snake_case,模型名用 PascalCase 并 @@map 到表名 // - 主键统一 id INT UNSIGNED AUTO_INCREMENT(本量级无需 BIGINT,且避免 JS BigInt 序列化问题) // - 时间统一 DATETIME,存 UTC,展示层转 Asia/Shanghai // - 金额统一 INT UNSIGNED,单位「分」,全程不换算 // - 状态字段统一 TINYINT + 代码内枚举(不使用数据库 ENUM,便于演进) // - 不建外键约束,关系由应用层保证 // // 数据库:MySQL 5.7 generator client { provider = "prisma-client-js" } datasource db { provider = "mysql" url = env("DATABASE_URL") } /// 用户(对应旧表「用户信息」) model User { id Int @id @default(autoincrement()) @db.UnsignedInt openid String @unique @db.VarChar(64) nickname String? @db.VarChar(32) headicon_id Int @default(0) @map("headicon_id") @db.UnsignedInt /// 微信 sessionKey,AES-256-GCM 加密后存储;虚拟支付用户态签名必需 wx_session_key String? @map("wx_session_key") @db.VarChar(255) session_key_updated_at DateTime? @map("session_key_updated_at") /// 1 正常 / 0 封禁 status Int @default(1) @db.TinyInt /// 1 = 用户主动使用过登录功能(即认可/接受这个小程序),0 = 未登录或已退出。 /// 和静默登录无关:静默登录只建立身份,不改这一位。购买前必须为 1。 is_login Int @default(0) @map("is_login") @db.TinyInt registered_at DateTime @map("registered_at") last_login_at DateTime? @map("last_login_at") created_at DateTime @default(now()) @map("created_at") updated_at DateTime @updatedAt @map("updated_at") entitlements Entitlement[] orders Order[] @@map("users") } /// 付费课程(由旧表「课程口令码读取信息」升级而来) model Course { id Int @id @default(autoincrement()) @db.UnsignedInt /// 旧 64 位 sha256 串,原值保留。 /// 同时是视频目录名 Course/{code}/... 与封面名 Course/{code}.png,绝对不可改写。 code String @unique @db.VarChar(64) title String @db.VarChar(128) intro String? @db.VarChar(255) cover_image String? @map("cover_image") @db.VarChar(255) price_fen Int @default(0) @map("price_fen") @db.UnsignedInt /// 0 下架 / 1 上架 status Int @default(1) @db.TinyInt sort_order Int @default(0) @map("sort_order") created_at DateTime @default(now()) @map("created_at") updated_at DateTime @updatedAt @map("updated_at") lessons Lesson[] entitlements Entitlement[] orders Order[] products Product[] home_courses HomeCourse[] @@map("courses") } /// 付费课时(对应旧表「视频课程数据」) model Lesson { id Int @id @default(autoincrement()) @db.UnsignedInt course_id Int @map("course_id") @db.UnsignedInt /// 原值保留,参与视频路径 Course/{code}/{chapter_id}/{play_id}/playlist.m3u8 play_id String @map("play_id") @db.VarChar(64) chapter_id String @map("chapter_id") @db.VarChar(64) chapter_title String? @map("chapter_title") @db.VarChar(255) lesson_title String? @map("lesson_title") @db.VarChar(255) duration String? @db.VarChar(16) published_at DateTime? @map("published_at") cover_image String? @map("cover_image") @db.VarChar(255) sort_order Int @default(0) @map("sort_order") created_at DateTime @default(now()) @map("created_at") course Course @relation(fields: [course_id], references: [id]) @@unique([course_id, chapter_id, play_id]) @@index([course_id, sort_order]) @@map("lessons") } /// 免费试听课时(对应旧表「免费视频课程数据」) model FreeLesson { id Int @id @default(autoincrement()) @db.UnsignedInt /// 旧「序号」字段,对应首页试听入口 series_no Int @map("series_no") sort_order Int @default(0) @map("sort_order") course_name String @map("course_name") @db.VarChar(255) video_title String? @map("video_title") @db.VarChar(255) intro String? @db.VarChar(255) publisher String? @db.VarChar(255) published_at DateTime? @map("published_at") duration String? @db.VarChar(16) /// 相对路径,如 FreeVideos/711/playlist.m3u8 video_m3u8 String? @map("video_m3u8") @db.VarChar(255) cover_image String? @map("cover_image") @db.VarChar(255) created_at DateTime @default(now()) @map("created_at") @@index([series_no, sort_order]) @@map("free_lessons") } /// 首页「新课上线」长图页(对应旧表「首页课程列表」) model HomeCourse { id Int @id @default(autoincrement()) @db.UnsignedInt /// 原「课程名称」原值保留:首页图片路径为 Images/NewCourse/{course_name}.png course_name String @map("course_name") @db.VarChar(255) intro String? @db.VarChar(255) published_at DateTime? @map("published_at") cover_image String? @map("cover_image") @db.VarChar(255) /// 长图 HTML。注意:内容中含绝对 URL,迁移时需做域名改写。 content String? @db.LongText /// 关联可购买课程,用于挂载购买入口(旧数据可能为空) course_id Int? @map("course_id") @db.UnsignedInt sort_order Int @default(0) @map("sort_order") status Int @default(1) @db.TinyInt created_at DateTime @default(now()) @map("created_at") updated_at DateTime @updatedAt @map("updated_at") course Course? @relation(fields: [course_id], references: [id]) @@index([status, sort_order]) @@map("home_courses") } /// 首页免费试听入口(对应旧表「首页免费课程列表」) model HomeFreeCourse { id Int @id @default(autoincrement()) @db.UnsignedInt /// 旧「序号」,前端按此跳转 /v1/free-lessons?series_no= series_no Int @map("series_no") /// 原值保留:图片路径为 Images/FreeCourse/{course_name}.png course_name String @map("course_name") @db.VarChar(255) intro String? @db.VarChar(255) published_at DateTime? @map("published_at") cover_image String? @map("cover_image") @db.VarChar(255) sort_order Int @default(0) @map("sort_order") status Int @default(1) @db.TinyInt @@index([status, sort_order]) @@map("home_free_courses") } /// 首页轮播图(对应旧表「首页横幅图片」) model Banner { id Int @id @default(autoincrement()) @db.UnsignedInt /// 原「图片名」原值保留:图片路径为 Images/Banner/{filename} filename String @db.VarChar(255) sort_order Int @default(0) @map("sort_order") status Int @default(1) @db.TinyInt created_at DateTime @default(now()) @map("created_at") updated_at DateTime @updatedAt @map("updated_at") @@index([status, sort_order]) @@map("banners") } /// 知识速通文章(对应旧表「网页列表」) model Article { id Int @id @default(autoincrement()) @db.UnsignedInt /// 旧「网页id」原值保留:首页缩略图路径为 WebPages/{legacy_page_id}.png legacy_page_id String? @map("legacy_page_id") @db.VarChar(32) title String @db.VarChar(255) intro String? @db.VarChar(255) publisher String? @db.VarChar(255) published_at DateTime? @map("published_at") /// 正文 HTML。注意:内容中含绝对 URL,迁移时需做域名改写。 content String? @db.LongText sort_order Int @default(0) @map("sort_order") status Int @default(1) @db.TinyInt created_at DateTime @default(now()) @map("created_at") updated_at DateTime @updatedAt @map("updated_at") @@index([status, published_at]) @@index([legacy_page_id]) @@map("articles") } /// 课程页文案(对应旧表「课程页面Tips」) model CourseTip { id Int @id @default(autoincrement()) @db.UnsignedInt tip_key Int @unique @map("tip_key") content String? @db.Text title String? @db.TinyText @@map("course_tips") } /// 站点配置(对应旧表 cloudconfig,数字 ID 改为字符串 key) model Setting { id Int @id @default(autoincrement()) @db.UnsignedInt key String @unique @db.VarChar(64) /// TEXT:首页广播这类长文案要放进来,255 不够用 value String? @db.Text remark String? @db.VarChar(255) @@map("settings") } /// 权益 — 播放鉴权的唯一判断入口 model Entitlement { id Int @id @default(autoincrement()) @db.UnsignedInt user_id Int @map("user_id") @db.UnsignedInt course_id Int @map("course_id") @db.UnsignedInt /// 1 订单 / 2 老数据(历史已购) / 3 后台发放 source Int @db.TinyInt ref_id Int? @map("ref_id") @db.UnsignedInt granted_at DateTime @map("granted_at") expire_at DateTime? @map("expire_at") note String? @db.VarChar(255) created_at DateTime @default(now()) @map("created_at") user User @relation(fields: [user_id], references: [id]) course Course @relation(fields: [course_id], references: [id]) @@unique([user_id, course_id]) @@index([course_id]) @@map("entitlements") } /// 订单 model Order { id Int @id @default(autoincrement()) @db.UnsignedInt out_trade_no String @unique @map("out_trade_no") @db.VarChar(32) user_id Int @map("user_id") @db.UnsignedInt course_id Int @map("course_id") @db.UnsignedInt product_id String @map("product_id") @db.VarChar(64) quantity Int @default(1) @db.UnsignedInt amount_fen Int @map("amount_fen") @db.UnsignedInt /// 0 待支付 / 1 已支付 / 2 已发货 / 3 已退款 / 4 已关闭 status Int @db.TinyInt /// 固定 0(正式环境) env Int @default(0) @db.TinyInt /// 下单设备,由小程序上报 wx.getDeviceInfo().platform:ios / android / windows / devtools... /// 平台按设备路由支付渠道(iOS 走 Apple 支付),所以它决定这笔单能不能主动退款 terminal String? @db.VarChar(16) /// 管理员已同意退款的时间。iOS 用户申请退款时苹果会发问询,看到这一列非空就直接放过。 /// 这是「事前授权」:必须在下一次问询到达前标记,标记之后用户再去 App Store 申请才有效。 refund_consent_at DateTime? @map("refund_consent_at") /// 已向微信发起退款的时间。退款是异步的——微信退完才推 xpay_refund_notify 回写「已退款」, /// 中间这段时间靠它显示「退款中」,避免重复点退款。 refund_requested_at DateTime? @map("refund_requested_at") /// 微信平台单号,发货幂等依据 wx_order_id String? @unique @map("wx_order_id") @db.VarChar(64) /// 下单时透传字段,发货通知原样带回 attach String? @db.VarChar(255) created_at DateTime @default(now()) @map("created_at") paid_at DateTime? @map("paid_at") delivered_at DateTime? @map("delivered_at") refunded_at DateTime? @map("refunded_at") user User @relation(fields: [user_id], references: [id]) course Course @relation(fields: [course_id], references: [id]) refund_requests RefundRequest[] @@index([user_id, created_at]) @@index([status, created_at]) @@map("orders") } /// 退款申请。 /// /// Apple 支付不允许开发者主动退款,用户得去 App Store 申请;申请后 Apple 会向我们发 /// 退款问询(xpay_subscribe_ios_refund_query_notify),必须 3 秒内表态,所以决策是自动的。 /// 这张表把每次问询和我们的答复记下来,供后台查看。 /// 微信支付渠道(安卓等)没有用户申请入口,所以这里基本只有 iOS 会产生记录。 model RefundRequest { id Int @id @default(autoincrement()) @db.UnsignedInt /// 问询里的支付订单号。平台会重复问询,用它做幂等 pay_order_id String @unique @map("pay_order_id") @db.VarChar(64) /// 匹配到的本地订单;查不到时为空 order_id Int? @map("order_id") @db.UnsignedInt /// 用户申请退款的原因 reason String? @db.VarChar(255) /// 推送里的发货状态:0 未发货 / 1 已发货 / 2 发货中 provide_status String? @map("provide_status") @db.VarChar(4) /// 我们的答复:0 建议退款 / 1 拒绝退款 our_result Int @map("our_result") @db.TinyInt /// 答复依据,写给人看 our_evidence String? @map("our_evidence") @db.VarChar(255) /// 平台重复问询的次数 query_count Int @default(1) @map("query_count") @db.UnsignedInt created_at DateTime @default(now()) @map("created_at") updated_at DateTime @updatedAt @map("updated_at") order Order? @relation(fields: [order_id], references: [id]) @@index([created_at]) @@map("refund_requests") } /// 发货推送原始记录,用于幂等、重放与排查 model PaymentNotifyLog { id Int @id @default(autoincrement()) @db.UnsignedInt wx_order_id String? @map("wx_order_id") @db.VarChar(64) out_trade_no String? @map("out_trade_no") @db.VarChar(32) event String? @db.VarChar(64) raw_body String? @map("raw_body") @db.MediumText processed Int @default(0) @db.TinyInt err_msg String? @map("err_msg") @db.VarChar(255) created_at DateTime @default(now()) @map("created_at") @@index([wx_order_id]) @@map("payment_notify_log") } /// 虚拟支付道具映射(course ↔ MP 后台道具) model Product { id Int @id @default(autoincrement()) @db.UnsignedInt course_id Int @map("course_id") @db.UnsignedInt wx_product_id String @unique @map("wx_product_id") @db.VarChar(64) name String @db.VarChar(128) /// 必须与 MP 后台道具价格一致,单位「分」 price_fen Int @map("price_fen") @db.UnsignedInt status Int @default(1) @db.TinyInt created_at DateTime @default(now()) @map("created_at") updated_at DateTime @updatedAt @map("updated_at") course Course @relation(fields: [course_id], references: [id]) @@map("products") } /// 管理员(与微信用户体系完全隔离) model AdminUser { id Int @id @default(autoincrement()) @db.UnsignedInt username String @unique @db.VarChar(64) password_hash String @map("password_hash") @db.VarChar(255) display_name String? @map("display_name") @db.VarChar(64) /// 1 超管 / 2 运营 role Int @default(2) @db.TinyInt totp_secret String? @map("totp_secret") @db.VarChar(64) status Int @default(1) @db.TinyInt last_login_at DateTime? @map("last_login_at") created_at DateTime @default(now()) @map("created_at") updated_at DateTime @updatedAt @map("updated_at") @@map("admin_users") }