Skip to content

Bun + Elysia + Prisma + tRPC

🚀 技术栈选择

  • Bun: 超快的 JavaScript 运行时 (比 Node.js 快 3-4 倍)
  • Elysia: 专为 Bun 设计的 Web 框架 (260k+ req/s)
  • Prisma: 类型安全的 ORM,自动事务管理
  • tRPC: 端到端类型安全的 API (可选)
  • MySQL 8: 地理位置查询、全文搜索

📦 项目初始化

1. 创建后端项目

bash
# 在项目根目录创建 backend 文件夹
mkdir backend
cd backend

# 初始化 Bun 项目
bun init -y

# 安装依赖
bun add elysia @elysiajs/cors @elysiajs/swagger
bun add @prisma/client
bun add -d prisma

2. 初始化 Prisma

bash
bunx prisma init

🗄️ 数据库配置

1. 配置环境变量 (.env)

env
# MySQL 连接字符串
DATABASE_URL="mysql://root:@localhost:3306/shiyu"

# 服务器端口
PORT=3000

# CORS 允许的源
ALLOWED_ORIGINS="exp://192.168.1.100:8081,http://localhost:8081"

2. 配置 Prisma Schema (prisma/schema.prisma)

prisma
generator client {
  provider = "prisma-client-js"
}

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

// 诗词表
model Poem {
  id            Int      @id @default(autoincrement())
  title         String   @db.VarChar(100)
  content       String   @db.Text
  author        String   @db.VarChar(50)
  dynasty       String   @db.VarChar(20)
  latitude      Decimal  @db.Decimal(10, 8)
  longitude     Decimal  @db.Decimal(11, 8)
  favoriteCount Int      @default(0) @map("favorite_count")
  checkInCount  Int      @default(0) @map("check_in_count")
  createdAt     DateTime @default(now()) @map("created_at")
  
  favorites     UserFavorite[]
  checkIns      CheckIn[]
  
  @@index([latitude, longitude])
  @@map("poems")
}

// 用户表
model User {
  id             Int      @id @default(autoincrement())
  username       String   @unique @db.VarChar(50)
  email          String?  @unique @db.VarChar(100)
  password       String   @db.VarChar(255)
  totalFavorites Int      @default(0) @map("total_favorites")
  points         Int      @default(0)
  createdAt      DateTime @default(now()) @map("created_at")
  
  favorites      UserFavorite[]
  checkIns       CheckIn[]
  
  @@map("users")
}

// 收藏表
model UserFavorite {
  id        Int      @id @default(autoincrement())
  userId    Int      @map("user_id")
  poemId    Int      @map("poem_id")
  createdAt DateTime @default(now()) @map("created_at")
  
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  poem      Poem     @relation(fields: [poemId], references: [id], onDelete: Cascade)
  
  @@unique([userId, poemId])
  @@index([userId])
  @@index([poemId])
  @@map("user_favorites")
}

// 打卡表
model CheckIn {
  id        Int      @id @default(autoincrement())
  userId    Int      @map("user_id")
  poemId    Int      @map("poem_id")
  latitude  Decimal  @db.Decimal(10, 8)
  longitude Decimal  @db.Decimal(11, 8)
  photo     String?  @db.VarChar(500)
  createdAt DateTime @default(now()) @map("created_at")
  
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  poem      Poem     @relation(fields: [poemId], references: [id], onDelete: Cascade)
  
  @@index([userId])
  @@index([poemId])
  @@map("check_ins")
}

3. 从现有数据库生成模型

bash
# 如果已有 MySQL 数据库和表
bunx prisma db pull

# 生成 Prisma Client
bunx prisma generate

4. 或创建新数据库

bash
# 创建迁移并应用
bunx prisma migrate dev --name init

# 生成 Prisma Client
bunx prisma generate

🏗️ 项目结构

backend/
├── src/
│   ├── index.ts           # 主入口
│   ├── db/
│   │   └── prisma.ts      # Prisma Client 实例
│   ├── routes/
│   │   ├── poems.ts       # 诗词相关路由
│   │   ├── users.ts       # 用户相关路由
│   │   └── favorites.ts   # 收藏相关路由
│   ├── services/
│   │   ├── poem.service.ts
│   │   └── user.service.ts
│   └── types/
│       └── index.ts       # 类型定义
├── prisma/
│   └── schema.prisma      # 数据库模型
├── .env                   # 环境变量
├── package.json
└── tsconfig.json

💻 核心代码实现

1. Prisma Client (src/db/prisma.ts)

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

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined
}

export const prisma = globalForPrisma.prisma ?? new PrismaClient({
  log: process.env.NODE_ENV === 'development' 
    ? ['query', 'error', 'warn'] 
    : ['error'],
})

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma
}

2. 诗词服务 (src/services/poem.service.ts)

typescript
import { prisma } from '../db/prisma'

export class PoemService {
  // 获取附近诗词
  async getNearbyPoems(lat: number, lng: number, radius: number = 5000) {
    const poems = await prisma.$queryRaw<any[]>`
      SELECT 
        id, title, content, author, dynasty,
        latitude, longitude, favorite_count, check_in_count,
        ST_Distance_Sphere(
          POINT(longitude, latitude),
          POINT(${lng}, ${lat})
        ) as distance
      FROM poems
      WHERE ST_Distance_Sphere(
        POINT(longitude, latitude),
        POINT(${lng}, ${lat})
      ) <= ${radius}
      ORDER BY distance
      LIMIT 50
    `
    
    return poems
  }
  
  // 搜索诗词
  async searchPoems(keyword: string) {
    return await prisma.poem.findMany({
      where: {
        OR: [
          { title: { contains: keyword } },
          { content: { contains: keyword } },
          { author: { contains: keyword } }
        ]
      },
      take: 50,
      orderBy: { favoriteCount: 'desc' }
    })
  }
  
  // 获取诗词详情
  async getPoemById(id: number) {
    return await prisma.poem.findUnique({
      where: { id },
      include: {
        favorites: {
          take: 10,
          include: { user: { select: { username: true } } }
        },
        checkIns: {
          take: 10,
          include: { user: { select: { username: true } } }
        }
      }
    })
  }
  
  // 按朝代获取诗词
  async getPoemsByDynasty(dynasty: string, page: number = 1, limit: number = 20) {
    const skip = (page - 1) * limit
    
    const [poems, total] = await Promise.all([
      prisma.poem.findMany({
        where: { dynasty },
        skip,
        take: limit,
        orderBy: { favoriteCount: 'desc' }
      }),
      prisma.poem.count({ where: { dynasty } })
    ])
    
    return { poems, total, page, totalPages: Math.ceil(total / limit) }
  }
}

3. 用户服务 (src/services/user.service.ts)

typescript
import { prisma } from '../db/prisma'

export class UserService {
  // 收藏诗词 (自动事务)
  async addFavorite(userId: number, poemId: number) {
    return await prisma.$transaction(async (tx) => {
      // 检查是否已收藏
      const existing = await tx.userFavorite.findUnique({
        where: {
          userId_poemId: { userId, poemId }
        }
      })
      
      if (existing) {
        throw new Error('Already favorited')
      }
      
      // 创建收藏
      const favorite = await tx.userFavorite.create({
        data: { userId, poemId },
        include: { poem: true }
      })
      
      // 更新计数
      await tx.poem.update({
        where: { id: poemId },
        data: { favoriteCount: { increment: 1 } }
      })
      
      await tx.user.update({
        where: { id: userId },
        data: { totalFavorites: { increment: 1 } }
      })
      
      return favorite
    })
  }
  
  // 取消收藏 (自动事务)
  async removeFavorite(userId: number, poemId: number) {
    return await prisma.$transaction(async (tx) => {
      const favorite = await tx.userFavorite.findUnique({
        where: {
          userId_poemId: { userId, poemId }
        }
      })
      
      if (!favorite) {
        throw new Error('Favorite not found')
      }
      
      await tx.userFavorite.delete({
        where: { id: favorite.id }
      })
      
      await tx.poem.update({
        where: { id: poemId },
        data: { favoriteCount: { decrement: 1 } }
      })
      
      await tx.user.update({
        where: { id: userId },
        data: { totalFavorites: { decrement: 1 } }
      })
      
      return { success: true }
    })
  }
  
  // 打卡 (自动事务)
  async checkIn(userId: number, poemId: number, lat: number, lng: number, photo?: string) {
    return await prisma.$transaction(async (tx) => {
      const checkIn = await tx.checkIn.create({
        data: {
          userId,
          poemId,
          latitude: lat,
          longitude: lng,
          photo
        },
        include: { poem: true }
      })
      
      // 更新打卡次数
      await tx.poem.update({
        where: { id: poemId },
        data: { checkInCount: { increment: 1 } }
      })
      
      // 增加用户积分
      await tx.user.update({
        where: { id: userId },
        data: { points: { increment: 10 } }
      })
      
      return checkIn
    })
  }
  
  // 获取用户收藏列表
  async getUserFavorites(userId: number, page: number = 1, limit: number = 20) {
    const skip = (page - 1) * limit
    
    const [favorites, total] = await Promise.all([
      prisma.userFavorite.findMany({
        where: { userId },
        skip,
        take: limit,
        include: { poem: true },
        orderBy: { createdAt: 'desc' }
      }),
      prisma.userFavorite.count({ where: { userId } })
    ])
    
    return { favorites, total, page, totalPages: Math.ceil(total / limit) }
  }
}

4. 主服务器 (src/index.ts)

typescript
import { Elysia } from 'elysia'
import { cors } from '@elysiajs/cors'
import { swagger } from '@elysiajs/swagger'
import { PoemService } from './services/poem.service'
import { UserService } from './services/user.service'

const poemService = new PoemService()
const userService = new UserService()

const app = new Elysia()
  // CORS 配置
  .use(cors({
    origin: process.env.ALLOWED_ORIGINS?.split(',') || true,
    credentials: true
  }))
  
  // Swagger 文档
  .use(swagger({
    documentation: {
      info: {
        title: '诗遇 API',
        version: '1.0.0',
        description: '诗遇后端 API 文档'
      }
    }
  }))
  
  // 健康检查
  .get('/health', () => ({ status: 'ok', timestamp: new Date().toISOString() }))
  
  // ============ 诗词相关 API ============
  
  // 获取附近诗词
  .get('/api/poems/nearby', async ({ query }) => {
    const { lat, lng, radius = 5000 } = query
    
    if (!lat || !lng) {
      return { success: false, error: 'Missing lat or lng' }
    }
    
    const poems = await poemService.getNearbyPoems(
      parseFloat(lat as string),
      parseFloat(lng as string),
      parseInt(radius as string)
    )
    
    return { success: true, data: poems }
  })
  
  // 搜索诗词
  .get('/api/poems/search', async ({ query }) => {
    const { keyword } = query
    
    if (!keyword) {
      return { success: false, error: 'Missing keyword' }
    }
    
    const poems = await poemService.searchPoems(keyword as string)
    return { success: true, data: poems }
  })
  
  // 获取诗词详情
  .get('/api/poems/:id', async ({ params }) => {
    const poem = await poemService.getPoemById(parseInt(params.id))
    
    if (!poem) {
      return { success: false, error: 'Poem not found' }
    }
    
    return { success: true, data: poem }
  })
  
  // 按朝代获取诗词
  .get('/api/poems/dynasty/:dynasty', async ({ params, query }) => {
    const { dynasty } = params
    const page = parseInt(query.page as string) || 1
    const limit = parseInt(query.limit as string) || 20
    
    const result = await poemService.getPoemsByDynasty(dynasty, page, limit)
    return { success: true, data: result }
  })
  
  // ============ 用户相关 API ============
  
  // 收藏诗词
  .post('/api/favorites', async ({ body }) => {
    try {
      const { userId, poemId } = body as { userId: number; poemId: number }
      const favorite = await userService.addFavorite(userId, poemId)
      return { success: true, data: favorite }
    } catch (error: any) {
      return { success: false, error: error.message }
    }
  })
  
  // 取消收藏
  .delete('/api/favorites', async ({ body }) => {
    try {
      const { userId, poemId } = body as { userId: number; poemId: number }
      await userService.removeFavorite(userId, poemId)
      return { success: true }
    } catch (error: any) {
      return { success: false, error: error.message }
    }
  })
  
  // 打卡
  .post('/api/checkins', async ({ body }) => {
    try {
      const { userId, poemId, lat, lng, photo } = body as {
        userId: number
        poemId: number
        lat: number
        lng: number
        photo?: string
      }
      
      const checkIn = await userService.checkIn(userId, poemId, lat, lng, photo)
      return { success: true, data: checkIn }
    } catch (error: any) {
      return { success: false, error: error.message }
    }
  })
  
  // 获取用户收藏列表
  .get('/api/users/:userId/favorites', async ({ params, query }) => {
    const userId = parseInt(params.userId)
    const page = parseInt(query.page as string) || 1
    const limit = parseInt(query.limit as string) || 20
    
    const result = await userService.getUserFavorites(userId, page, limit)
    return { success: true, data: result }
  })
  
  // 启动服务器
  .listen(process.env.PORT || 3000)

console.log(`🚀 诗遇 API 运行在 http://localhost:${app.server?.port}`)
console.log(`📖 API 文档: http://localhost:${app.server?.port}/swagger`)

🔧 常用命令

Prisma 命令

bash
# 生成 Prisma Client (修改 schema 后必须执行)
bunx prisma generate

# 查看数据库 (可视化界面)
bunx prisma studio

# 从数据库拉取 schema
bunx prisma db pull

# 推送 schema 到数据库 (开发环境)
bunx prisma db push

# 创建迁移 (生产环境)
bunx prisma migrate dev --name migration_name

# 应用迁移
bunx prisma migrate deploy

# 重置数据库
bunx prisma migrate reset

# 格式化 schema
bunx prisma format

运行服务器

bash
# 开发模式 (热重载)
bun --watch src/index.ts

# 生产模式
bun src/index.ts

添加到 package.json

json
{
  "scripts": {
    "dev": "bun --watch src/index.ts",
    "start": "bun src/index.ts",
    "db:generate": "bunx prisma generate",
    "db:push": "bunx prisma db push",
    "db:studio": "bunx prisma studio",
    "db:migrate": "bunx prisma migrate dev"
  }
}

📱 React Native 客户端调用

typescript
// api/poems.ts
const API_BASE = 'http://192.168.1.100:3000/api'

export async function getNearbyPoems(lat: number, lng: number, radius: number = 5000) {
  const response = await fetch(
    `${API_BASE}/poems/nearby?lat=${lat}&lng=${lng}&radius=${radius}`
  )
  return await response.json()
}

export async function searchPoems(keyword: string) {
  const response = await fetch(
    `${API_BASE}/poems/search?keyword=${encodeURIComponent(keyword)}`
  )
  return await response.json()
}

export async function addFavorite(userId: number, poemId: number) {
  const response = await fetch(`${API_BASE}/favorites`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ userId, poemId })
  })
  return await response.json()
}

// 使用
const { data: poems } = await getNearbyPoems(39.9042, 116.4074)

🚀 性能优化

1. 数据库索引

sql
-- 地理位置索引
CREATE INDEX idx_location ON poems(latitude, longitude);

-- 复合索引
CREATE INDEX idx_dynasty_author ON poems(dynasty, author);

-- 全文索引 (中文搜索)
ALTER TABLE poems ADD FULLTEXT INDEX idx_content (title, content, author) WITH PARSER ngram;

2. Prisma 查询优化

typescript
// 使用 select 减少数据传输
const poems = await prisma.poem.findMany({
  select: {
    id: true,
    title: true,
    author: true,
    // 不查询 content (减少数据量)
  }
})

// 使用 include 预加载关联数据 (避免 N+1 查询)
const poem = await prisma.poem.findUnique({
  where: { id: 1 },
  include: {
    favorites: { take: 10 },
    checkIns: { take: 10 }
  }
})

3. 连接池配置

env
# .env
DATABASE_URL="mysql://root:@localhost:3306/shiyu?connection_limit=10&pool_timeout=20"

🔐 安全建议

  1. 使用环境变量: 不要把密码写在代码里
  2. 添加认证: JWT 或 Session
  3. 输入验证: 使用 Elysia 的内置验证
  4. SQL 注入防护: Prisma 自动防护
  5. CORS 配置: 只允许特定域名

📊 监控和日志

typescript
// 添加请求日志
app.onRequest(({ request }) => {
  console.log(`${new Date().toISOString()} ${request.method} ${request.url}`)
})

// 添加错误处理
app.onError(({ error, code }) => {
  console.error(`Error ${code}:`, error)
  return { success: false, error: error.message }
})

🎯 下一步

  1. ✅ 配置数据库连接
  2. ✅ 生成 Prisma Client
  3. ✅ 实现核心 API
  4. ⬜ 添加用户认证 (JWT)
  5. ⬜ 添加图片上传 (打卡照片)
  6. ⬜ 添加 WebSocket (实时通知)
  7. ⬜ 部署到服务器

相关文档: