Files
YelayMaths/yelaymaths_api/prisma/schema.prisma
T
2026-09-30 16:47:27 +08:00

352 lines
15 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 叶雷讲数学 — 数据模型
//
// 约定:
// - 表名与字段名统一英文 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")
}