709 lines
17 KiB
Markdown
709 lines
17 KiB
Markdown
# 数据库设计
|
||
|
||
**我是卡若。**
|
||
|
||
这个项目当前使用LocalStorage做数据持久化,但未来会切换到MongoDB。这个文档定义了完整的数据库设计方案。
|
||
|
||
---
|
||
|
||
## 一、数据库选型
|
||
|
||
### 1.1 为什么选MongoDB?
|
||
|
||
1. **文档型数据库**: 适合内容类产品,数据结构灵活
|
||
2. **无Schema约束**: 快速迭代不需要频繁改表结构
|
||
3. **JSON原生支持**: 前后端数据格式一致
|
||
4. **横向扩展能力**: 支持未来大规模用户增长
|
||
5. **向量搜索支持**: MongoDB Atlas支持向量检索(未来AI功能)
|
||
|
||
### 1.2 当前方案 vs 未来方案
|
||
|
||
**当前方案** (LocalStorage):
|
||
\`\`\`javascript
|
||
// 优点
|
||
- 无需服务器
|
||
- 开发调试方便
|
||
- 适合MVP验证
|
||
|
||
// 缺点
|
||
- 数据仅存浏览器本地
|
||
- 多设备无法同步
|
||
- 数据容易丢失
|
||
\`\`\`
|
||
|
||
**未来方案** (MongoDB):
|
||
\`\`\`javascript
|
||
// 优点
|
||
- 数据持久化存储
|
||
- 多设备数据同步
|
||
- 支持复杂查询
|
||
- 支持事务(ACID)
|
||
|
||
// 迁移计划
|
||
1. 安装MongoDB驱动
|
||
2. 创建数据库连接
|
||
3. 逐步替换LocalStorage
|
||
4. 添加数据迁移脚本
|
||
\`\`\`
|
||
|
||
---
|
||
|
||
## 二、数据库连接配置
|
||
|
||
### 2.1 本地开发环境
|
||
|
||
\`\`\`bash
|
||
# MongoDB本地安装
|
||
brew install mongodb-community@6.0
|
||
|
||
# 启动MongoDB
|
||
brew services start mongodb-community@6.0
|
||
|
||
# 连接字符串
|
||
mongodb://localhost:27017/soul-experiment
|
||
\`\`\`
|
||
|
||
### 2.2 生产环境 (MongoDB Atlas)
|
||
|
||
\`\`\`bash
|
||
# 连接字符串
|
||
mongodb+srv://<username>:<password>@cluster0.mongodb.net/soul-experiment?retryWrites=true&w=majority
|
||
\`\`\`
|
||
|
||
### 2.3 环境变量配置
|
||
|
||
**.env.local**:
|
||
\`\`\`bash
|
||
# MongoDB配置
|
||
MONGODB_URI=mongodb://localhost:27017/soul-experiment
|
||
MONGODB_DB_NAME=soul-experiment
|
||
|
||
# 或使用云数据库
|
||
# MONGODB_URI=mongodb://10.88.182.62:3306/soul-experiment
|
||
# MONGODB_USERNAME=root
|
||
# MONGODB_PASSWORD=Vtka(agu)-1
|
||
\`\`\`
|
||
|
||
---
|
||
|
||
## 三、数据模型设计
|
||
|
||
### 3.1 用户集合 (users)
|
||
|
||
**集合名称**: `users`
|
||
|
||
**索引**:
|
||
\`\`\`javascript
|
||
{
|
||
phone: 1, // 唯一索引
|
||
referralCode: 1, // 唯一索引
|
||
referredBy: 1, // 普通索引
|
||
createdAt: -1 // 降序索引
|
||
}
|
||
\`\`\`
|
||
|
||
**文档结构**:
|
||
\`\`\`javascript
|
||
{
|
||
_id: ObjectId("65a1234567890abcdef12345"),
|
||
phone: "15880802661", // 手机号
|
||
nickname: "卡若", // 昵称
|
||
avatar: "https://cdn.example.com/avatar.jpg", // 头像
|
||
openid: "wx_openid_xxx", // 微信openid
|
||
unionid: "wx_unionid_xxx", // 微信unionid
|
||
|
||
// 购买记录
|
||
purchasedSections: [ // 已购章节
|
||
"1.1", "1.2", "3.3"
|
||
],
|
||
hasFullBook: false, // 是否购买整本书
|
||
|
||
// 分销数据
|
||
referralCode: "REFABC123", // 推荐码
|
||
referredBy: "REFXYZ789", // 推荐人的码
|
||
referralCount: 28, // 推荐人数
|
||
earnings: 256.80, // 总收益(元)
|
||
pendingEarnings: 128.90, // 待提现(元)
|
||
withdrawnEarnings: 127.90, // 已提现(元)
|
||
|
||
// 阅读数据
|
||
readingTime: 12480, // 阅读时长(秒)
|
||
readingProgress: 45, // 阅读进度(%)
|
||
lastReadSection: "3.2", // 最后阅读章节
|
||
lastReadAt: ISODate("2025-01-14T12:00:00Z"),
|
||
|
||
// 权限
|
||
isAdmin: false, // 是否管理员
|
||
isBanned: false, // 是否封禁
|
||
|
||
// 时间戳
|
||
createdAt: ISODate("2025-01-01T00:00:00Z"),
|
||
updatedAt: ISODate("2025-01-14T12:00:00Z")
|
||
}
|
||
\`\`\`
|
||
|
||
**查询示例**:
|
||
\`\`\`javascript
|
||
// 根据手机号查找用户
|
||
db.users.findOne({ phone: "15880802661" })
|
||
|
||
// 根据推荐码查找用户
|
||
db.users.findOne({ referralCode: "REFABC123" })
|
||
|
||
// 查找某推荐人的所有下级
|
||
db.users.find({ referredBy: "REFABC123" })
|
||
|
||
// 查找收益前10的推广者
|
||
db.users.find({}).sort({ earnings: -1 }).limit(10)
|
||
\`\`\`
|
||
|
||
---
|
||
|
||
### 3.2 订单集合 (orders)
|
||
|
||
**集合名称**: `orders`
|
||
|
||
**索引**:
|
||
\`\`\`javascript
|
||
{
|
||
orderId: 1, // 唯一索引
|
||
userId: 1, // 普通索引
|
||
status: 1, // 普通索引
|
||
createdAt: -1 // 降序索引
|
||
}
|
||
\`\`\`
|
||
|
||
**文档结构**:
|
||
\`\`\`javascript
|
||
{
|
||
_id: ObjectId("65a1234567890abcdef12345"),
|
||
orderId: "ORDER_1705200000_abc123", // 订单号
|
||
userId: ObjectId("65a1234567890abcdef00001"), // 用户ID
|
||
userPhone: "15880802661", // 用户手机号
|
||
userNickname: "卡若", // 用户昵称
|
||
|
||
// 订单信息
|
||
type: "section", // "section" | "fullbook"
|
||
sectionId: "1.1", // 章节ID(单章购买时)
|
||
sectionTitle: "自行车荷总...", // 章节标题
|
||
amount: 1.00, // 金额(元)
|
||
|
||
// 支付信息
|
||
paymentMethod: "wechat", // 支付方式
|
||
transactionId: "wx_pay_123456789", // 第三方交易号
|
||
status: "completed", // "pending" | "completed" | "failed" | "refunded"
|
||
|
||
// 分销信息
|
||
referralCode: "REFXYZ789", // 推荐码
|
||
referrerUserId: ObjectId("65a1234567890abcdef00002"), // 推荐人ID
|
||
referrerEarnings: 0.90, // 推荐人佣金(元)
|
||
|
||
// 时间戳
|
||
createdAt: ISODate("2025-01-14T12:00:00Z"), // 创建时间
|
||
paidAt: ISODate("2025-01-14T12:05:00Z"), // 支付时间
|
||
expireAt: ISODate("2025-01-14T12:30:00Z"), // 过期时间(30分钟)
|
||
updatedAt: ISODate("2025-01-14T12:05:00Z")
|
||
}
|
||
\`\`\`
|
||
|
||
**查询示例**:
|
||
\`\`\`javascript
|
||
// 查找用户所有订单
|
||
db.orders.find({ userId: ObjectId("65a...") }).sort({ createdAt: -1 })
|
||
|
||
// 查找待支付订单
|
||
db.orders.find({ status: "pending", expireAt: { $gt: new Date() } })
|
||
|
||
// 统计今日收益
|
||
db.orders.aggregate([
|
||
{ $match: {
|
||
status: "completed",
|
||
paidAt: {
|
||
$gte: ISODate("2025-01-14T00:00:00Z"),
|
||
$lt: ISODate("2025-01-15T00:00:00Z")
|
||
}
|
||
}},
|
||
{ $group: {
|
||
_id: null,
|
||
totalRevenue: { $sum: "$amount" },
|
||
totalOrders: { $sum: 1 }
|
||
}}
|
||
])
|
||
|
||
// 查找某推荐人的所有佣金记录
|
||
db.orders.find({
|
||
referralCode: "REFABC123",
|
||
status: "completed"
|
||
})
|
||
\`\`\`
|
||
|
||
---
|
||
|
||
### 3.3 提现记录集合 (withdrawals)
|
||
|
||
**集合名称**: `withdrawals`
|
||
|
||
**索引**:
|
||
\`\`\`javascript
|
||
{
|
||
userId: 1, // 普通索引
|
||
status: 1, // 普通索引
|
||
createdAt: -1 // 降序索引
|
||
}
|
||
\`\`\`
|
||
|
||
**文档结构**:
|
||
\`\`\`javascript
|
||
{
|
||
_id: ObjectId("65a1234567890abcdef12345"),
|
||
withdrawalId: "WD_1705200000_abc123", // 提现单号
|
||
userId: ObjectId("65a1234567890abcdef00001"), // 用户ID
|
||
userPhone: "15880802661", // 用户手机号
|
||
userNickname: "卡若", // 用户昵称
|
||
|
||
// 提现信息
|
||
amount: 100.00, // 提现金额(元)
|
||
method: "wechat", // "wechat" | "alipay"
|
||
account: "微信号或支付宝账号",
|
||
name: "真实姓名",
|
||
|
||
// 状态
|
||
status: "pending", // "pending" | "completed" | "rejected"
|
||
rejectReason: "", // 拒绝原因
|
||
|
||
// 时间戳
|
||
createdAt: ISODate("2025-01-14T12:00:00Z"), // 申请时间
|
||
completedAt: ISODate("2025-01-14T14:00:00Z"), // 完成时间
|
||
updatedAt: ISODate("2025-01-14T14:00:00Z")
|
||
}
|
||
\`\`\`
|
||
|
||
**查询示例**:
|
||
\`\`\`javascript
|
||
// 查找待审核提现
|
||
db.withdrawals.find({ status: "pending" }).sort({ createdAt: 1 })
|
||
|
||
// 查找用户提现记录
|
||
db.withdrawals.find({ userId: ObjectId("65a...") }).sort({ createdAt: -1 })
|
||
|
||
// 统计今日提现金额
|
||
db.withdrawals.aggregate([
|
||
{ $match: {
|
||
status: "completed",
|
||
completedAt: {
|
||
$gte: ISODate("2025-01-14T00:00:00Z"),
|
||
$lt: ISODate("2025-01-15T00:00:00Z")
|
||
}
|
||
}},
|
||
{ $group: {
|
||
_id: null,
|
||
totalAmount: { $sum: "$amount" },
|
||
totalCount: { $sum: 1 }
|
||
}}
|
||
])
|
||
\`\`\`
|
||
|
||
---
|
||
|
||
### 3.4 章节内容集合 (sections)
|
||
|
||
**集合名称**: `sections`
|
||
|
||
**索引**:
|
||
\`\`\`javascript
|
||
{
|
||
sectionId: 1, // 唯一索引
|
||
isFree: 1, // 普通索引
|
||
createdAt: -1 // 降序索引
|
||
}
|
||
\`\`\`
|
||
|
||
**文档结构**:
|
||
\`\`\`javascript
|
||
{
|
||
_id: ObjectId("65a1234567890abcdef12345"),
|
||
sectionId: "1.1", // 章节ID
|
||
|
||
// 章节信息
|
||
title: "自行车荷总:一个行业做到极致是什么样",
|
||
content: "# 自行车荷总\n\n...", // Markdown内容
|
||
summary: "本章讲述了...", // 摘要
|
||
keywords: ["创业", "行业深耕"], // 关键词
|
||
|
||
// 层级关系
|
||
partId: "part-1", // 所属篇
|
||
partTitle: "真实的人",
|
||
chapterId: "chapter-1", // 所属章
|
||
chapterTitle: "人与人之间的底层逻辑",
|
||
|
||
// 定价
|
||
price: 1, // 价格(元)
|
||
isFree: true, // 是否免费
|
||
unlockAfterDays: 0, // 定时解锁(天数)
|
||
|
||
// 统计数据
|
||
viewCount: 1234, // 浏览次数
|
||
purchaseCount: 456, // 购买次数
|
||
avgReadingTime: 180, // 平均阅读时长(秒)
|
||
|
||
// 文件信息
|
||
filePath: "book/_第一篇|真实的人/...",
|
||
wordCount: 3580, // 字数
|
||
|
||
// 发布状态
|
||
status: "published", // "draft" | "published"
|
||
publishedAt: ISODate("2025-01-01T00:00:00Z"),
|
||
|
||
// 时间戳
|
||
createdAt: ISODate("2025-01-01T00:00:00Z"),
|
||
updatedAt: ISODate("2025-01-14T12:00:00Z")
|
||
}
|
||
\`\`\`
|
||
|
||
**查询示例**:
|
||
\`\`\`javascript
|
||
// 获取所有免费章节
|
||
db.sections.find({ isFree: true })
|
||
|
||
// 获取最新发布的10章
|
||
db.sections.find({ status: "published" })
|
||
.sort({ publishedAt: -1 })
|
||
.limit(10)
|
||
|
||
// 按浏览量排序
|
||
db.sections.find().sort({ viewCount: -1 }).limit(10)
|
||
|
||
// 全文搜索(需创建文本索引)
|
||
db.sections.createIndex({
|
||
title: "text",
|
||
content: "text",
|
||
keywords: "text"
|
||
})
|
||
db.sections.find({ $text: { $search: "创业 私域" } })
|
||
\`\`\`
|
||
|
||
---
|
||
|
||
### 3.5 阅读记录集合 (reading_logs)
|
||
|
||
**集合名称**: `reading_logs`
|
||
|
||
**索引**:
|
||
\`\`\`javascript
|
||
{
|
||
userId: 1,
|
||
sectionId: 1,
|
||
createdAt: -1
|
||
}
|
||
\`\`\`
|
||
|
||
**文档结构**:
|
||
\`\`\`javascript
|
||
{
|
||
_id: ObjectId("65a1234567890abcdef12345"),
|
||
userId: ObjectId("65a1234567890abcdef00001"),
|
||
sectionId: "3.2",
|
||
|
||
// 阅读数据
|
||
progress: 68, // 阅读进度(%)
|
||
readingTime: 180, // 阅读时长(秒)
|
||
scrollDepth: 75, // 滚动深度(%)
|
||
|
||
// 设备信息
|
||
device: "iPhone 14 Pro",
|
||
browser: "Safari",
|
||
ip: "121.xxx.xxx.xxx",
|
||
|
||
// 时间戳
|
||
createdAt: ISODate("2025-01-14T12:00:00Z")
|
||
}
|
||
\`\`\`
|
||
|
||
---
|
||
|
||
### 3.6 系统配置集合 (settings)
|
||
|
||
**集合名称**: `settings`
|
||
|
||
**文档结构**:
|
||
\`\`\`javascript
|
||
{
|
||
_id: "global_settings",
|
||
|
||
// 分润配置
|
||
distributorShare: 90, // 推广者分成(%)
|
||
authorShare: 10, // 作者分成(%)
|
||
|
||
// 定价配置
|
||
sectionPrice: 1, // 单章价格(元)
|
||
fullBookPrice: 9.9, // 整书价格(元)
|
||
|
||
// 支付配置
|
||
paymentMethods: {
|
||
wechat: {
|
||
enabled: true,
|
||
appId: "wx432c93e275548671",
|
||
merchantId: "1318592501",
|
||
apiKey: "***"
|
||
},
|
||
alipay: {
|
||
enabled: true,
|
||
partnerId: "2088511801157159",
|
||
securityKey: "***"
|
||
}
|
||
},
|
||
|
||
// 营销配置
|
||
partyGroupQrCode: "https://...",
|
||
bannerText: "每天早上6-9点,Soul派对房不见不散",
|
||
|
||
// 时间戳
|
||
updatedAt: ISODate("2025-01-14T12:00:00Z")
|
||
}
|
||
\`\`\`
|
||
|
||
---
|
||
|
||
## 四、数据迁移方案
|
||
|
||
### 4.1 LocalStorage to MongoDB
|
||
|
||
**步骤1**: 导出LocalStorage数据
|
||
\`\`\`javascript
|
||
// 导出脚本 scripts/export-localstorage.js
|
||
const fs = require('fs')
|
||
|
||
const users = JSON.parse(localStorage.getItem('users') || '[]')
|
||
const orders = JSON.parse(localStorage.getItem('all_purchases') || '[]')
|
||
const settings = JSON.parse(localStorage.getItem('app_settings') || '{}')
|
||
|
||
const exportData = { users, orders, settings }
|
||
fs.writeFileSync('data-export.json', JSON.stringify(exportData, null, 2))
|
||
\`\`\`
|
||
|
||
**步骤2**: 导入MongoDB
|
||
\`\`\`javascript
|
||
// 导入脚本 scripts/import-mongodb.js
|
||
const { MongoClient } = require('mongodb')
|
||
const fs = require('fs')
|
||
|
||
async function importData() {
|
||
const client = await MongoClient.connect(process.env.MONGODB_URI)
|
||
const db = client.db('soul-experiment')
|
||
|
||
const data = JSON.parse(fs.readFileSync('data-export.json', 'utf8'))
|
||
|
||
// 导入用户
|
||
await db.collection('users').insertMany(data.users)
|
||
|
||
// 导入订单
|
||
await db.collection('orders').insertMany(data.orders)
|
||
|
||
// 导入配置
|
||
await db.collection('settings').insertOne({
|
||
_id: 'global_settings',
|
||
...data.settings
|
||
})
|
||
|
||
client.close()
|
||
console.log('数据导入完成')
|
||
}
|
||
|
||
importData()
|
||
\`\`\`
|
||
|
||
---
|
||
|
||
## 五、数据库操作封装
|
||
|
||
### 5.1 用户操作
|
||
|
||
\`\`\`typescript
|
||
// lib/db/users.ts
|
||
import { MongoClient, ObjectId } from 'mongodb'
|
||
|
||
export async function createUser(userData: Partial<User>) {
|
||
const db = await getDatabase()
|
||
const result = await db.collection('users').insertOne({
|
||
...userData,
|
||
referralCode: generateReferralCode(),
|
||
earnings: 0,
|
||
pendingEarnings: 0,
|
||
withdrawnEarnings: 0,
|
||
referralCount: 0,
|
||
purchasedSections: [],
|
||
hasFullBook: false,
|
||
createdAt: new Date(),
|
||
updatedAt: new Date()
|
||
})
|
||
return result.insertedId
|
||
}
|
||
|
||
export async function findUserByPhone(phone: string) {
|
||
const db = await getDatabase()
|
||
return await db.collection('users').findOne({ phone })
|
||
}
|
||
|
||
export async function updateUserEarnings(
|
||
userId: ObjectId,
|
||
amount: number
|
||
) {
|
||
const db = await getDatabase()
|
||
await db.collection('users').updateOne(
|
||
{ _id: userId },
|
||
{
|
||
$inc: {
|
||
earnings: amount,
|
||
pendingEarnings: amount
|
||
},
|
||
$set: { updatedAt: new Date() }
|
||
}
|
||
)
|
||
}
|
||
\`\`\`
|
||
|
||
### 5.2 订单操作
|
||
|
||
\`\`\`typescript
|
||
// lib/db/orders.ts
|
||
export async function createOrder(orderData: Partial<Order>) {
|
||
const db = await getDatabase()
|
||
const orderId = `ORDER_${Date.now()}_${randomString()}`
|
||
|
||
const result = await db.collection('orders').insertOne({
|
||
orderId,
|
||
...orderData,
|
||
status: 'pending',
|
||
createdAt: new Date(),
|
||
expireAt: new Date(Date.now() + 30 * 60 * 1000), // 30分钟
|
||
updatedAt: new Date()
|
||
})
|
||
|
||
return { orderId, _id: result.insertedId }
|
||
}
|
||
|
||
export async function completeOrder(orderId: string) {
|
||
const db = await getDatabase()
|
||
const order = await db.collection('orders').findOne({ orderId })
|
||
|
||
if (!order) throw new Error('订单不存在')
|
||
|
||
// 更新订单状态
|
||
await db.collection('orders').updateOne(
|
||
{ orderId },
|
||
{
|
||
$set: {
|
||
status: 'completed',
|
||
paidAt: new Date(),
|
||
updatedAt: new Date()
|
||
}
|
||
}
|
||
)
|
||
|
||
// 解锁内容
|
||
if (order.type === 'section') {
|
||
await db.collection('users').updateOne(
|
||
{ _id: order.userId },
|
||
{ $addToSet: { purchasedSections: order.sectionId } }
|
||
)
|
||
} else if (order.type === 'fullbook') {
|
||
await db.collection('users').updateOne(
|
||
{ _id: order.userId },
|
||
{ $set: { hasFullBook: true } }
|
||
)
|
||
}
|
||
|
||
// 分配佣金
|
||
if (order.referralCode) {
|
||
const referrer = await db.collection('users').findOne({
|
||
referralCode: order.referralCode
|
||
})
|
||
|
||
if (referrer) {
|
||
const commission = order.amount * 0.9 // 90%佣金
|
||
await updateUserEarnings(referrer._id, commission)
|
||
|
||
// 记录佣金
|
||
await db.collection('orders').updateOne(
|
||
{ orderId },
|
||
{ $set: {
|
||
referrerUserId: referrer._id,
|
||
referrerEarnings: commission
|
||
}}
|
||
)
|
||
}
|
||
}
|
||
}
|
||
\`\`\`
|
||
|
||
---
|
||
|
||
## 六、数据备份策略
|
||
|
||
### 6.1 自动备份
|
||
|
||
\`\`\`bash
|
||
# 每日凌晨3点自动备份
|
||
0 3 * * * mongodump --uri="mongodb://localhost:27017/soul-experiment" --out="/backup/$(date +\%Y\%m\%d)"
|
||
\`\`\`
|
||
|
||
### 6.2 恢复数据
|
||
|
||
\`\`\`bash
|
||
# 恢复指定日期的备份
|
||
mongorestore --uri="mongodb://localhost:27017/soul-experiment" --dir="/backup/20250114"
|
||
\`\`\`
|
||
|
||
---
|
||
|
||
## 七、性能优化
|
||
|
||
### 7.1 索引优化
|
||
|
||
\`\`\`javascript
|
||
// 创建复合索引
|
||
db.orders.createIndex({ userId: 1, createdAt: -1 })
|
||
db.orders.createIndex({ status: 1, expireAt: 1 })
|
||
db.users.createIndex({ referralCode: 1 }, { unique: true })
|
||
|
||
// 查看索引使用情况
|
||
db.orders.find({ userId: ObjectId("...") }).explain("executionStats")
|
||
\`\`\`
|
||
|
||
### 7.2 查询优化
|
||
|
||
\`\`\`javascript
|
||
// 使用投影减少数据传输
|
||
db.users.find(
|
||
{ phone: "15880802661" },
|
||
{ nickname: 1, referralCode: 1, earnings: 1 }
|
||
)
|
||
|
||
// 使用聚合管道优化复杂查询
|
||
db.orders.aggregate([
|
||
{ $match: { status: "completed" } },
|
||
{ $lookup: {
|
||
from: "users",
|
||
localField: "userId",
|
||
foreignField: "_id",
|
||
as: "user"
|
||
}},
|
||
{ $unwind: "$user" },
|
||
{ $project: {
|
||
orderId: 1,
|
||
amount: 1,
|
||
"user.nickname": 1
|
||
}}
|
||
])
|
||
\`\`\`
|
||
|
||
---
|
||
|
||
**总结**: 数据库设计是系统的基石,合理的结构设计能让后续开发事半功倍。当前使用LocalStorage做MVP验证,未来切换MongoDB后,整个系统的可靠性和扩展性都会大幅提升。
|
||
|
||
---
|
||
|
||
**更新时间**: 2025年1月14日
|
||
**负责人**: 卡若
|
||
**数据库版本**: MongoDB 6.0+
|