From 2b5b2f1d972b765a5b73dc7e13be86c606158ebf Mon Sep 17 00:00:00 2001 From: "ethan.chen" Date: Thu, 8 Jan 2026 14:26:27 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=81=E7=A7=BB=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E4=BB=8E=20SQLite=20=E5=88=B0=20PostgreSQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新 deno.json 添加 postgres 依赖 - 重构 db/index.ts 使用 PostgreSQL 连接和适配器 - 更新所有路由文件支持异步数据库操作 - 将 SQLite 语法转换为 PostgreSQL 语法 - 添加数据库迁移文档和 schema 文件 --- MIGRATION.md | 137 +++++++++++++++++++++++++++++++++ db/index.ts | 94 +++++++++++++++++++++-- db/schema.sql | 29 +++++++ deno.json | 3 +- deploy.sh | 16 ++-- routes/media.ts | 198 ++++++++++++++++++++++++++++-------------------- routes/user.ts | 52 +++++++------ 7 files changed, 409 insertions(+), 120 deletions(-) create mode 100644 MIGRATION.md create mode 100644 db/schema.sql diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..69cad86 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,137 @@ +# SQLite 到 PostgreSQL 迁移指南 + +本文档说明如何将项目从 SQLite 迁移到 PostgreSQL。 + +## 主要变更 + +### 1. 依赖更新 +- 添加了 `postgres` 库用于 PostgreSQL 连接 +- 移除了 `node:sqlite` 依赖 + +### 2. 数据库连接 +数据库连接现在通过环境变量配置,支持以下方式: + +**方式一:使用 DATABASE_URL** +```bash +export DATABASE_URL="postgresql://user:password@localhost:5432/dbname" +``` + +**方式二:使用独立的环境变量** +```bash +export DB_USER=postgres +export DB_PASSWORD=postgres +export DB_HOST=localhost +export DB_PORT=5432 +export DB_NAME=media +``` + +### 3. SQL 语法变更 + +#### 占位符 +- **SQLite**: 使用 `?` 作为占位符 +- **PostgreSQL**: 使用 `$1, $2, $3...` 作为占位符 +- **解决方案**: 代码中已创建适配器自动转换 + +#### 日期时间函数 +- **SQLite**: `datetime('now')` +- **PostgreSQL**: `NOW()` 或 `CURRENT_TIMESTAMP` +- **已更新**: 所有 `datetime('now')` 已替换为 `NOW()` + +#### 获取插入的 ID +- **SQLite**: `result.lastInsertRowid` +- **PostgreSQL**: 使用 `RETURNING id` 子句 +- **已更新**: INSERT 语句已添加 `RETURNING id` + +### 4. 数据库 Schema + +需要创建以下表结构: + +#### media 表 +```sql +CREATE TABLE media ( + id SERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + type VARCHAR(50) NOT NULL, + rating INTEGER, + notes TEXT, + platform VARCHAR(100), + date DATE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +``` + +#### users 表 +```sql +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + username VARCHAR(100) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +## 迁移步骤 + +### 1. 安装 PostgreSQL +确保已安装并运行 PostgreSQL 数据库。 + +### 2. 创建数据库 +```bash +createdb media +# 或使用 psql +psql -U postgres +CREATE DATABASE media; +``` + +### 3. 创建表结构 +使用上面提供的 SQL 语句创建表。 + +### 4. 迁移数据(可选) +如果需要从 SQLite 迁移现有数据,可以使用以下方法: + +```bash +# 导出 SQLite 数据 +sqlite3 media.db .dump > data.sql + +# 手动转换并导入到 PostgreSQL +# 注意:需要修改 SQL 语法以兼容 PostgreSQL +``` + +### 5. 配置环境变量 +设置数据库连接信息(见上面的环境变量配置)。 + +### 6. 测试连接 +运行应用并测试数据库连接是否正常。 + +## 注意事项 + +1. **数据类型差异**: + - SQLite 的 `INTEGER` 对应 PostgreSQL 的 `INTEGER` 或 `SERIAL` + - SQLite 的 `TEXT` 对应 PostgreSQL 的 `VARCHAR` 或 `TEXT` + - SQLite 的 `REAL` 对应 PostgreSQL 的 `REAL` 或 `DOUBLE PRECISION` + +2. **事务处理**: + - PostgreSQL 支持更完善的事务和并发控制 + - 代码中的适配器已处理基本的异步操作 + +3. **性能优化**: + - PostgreSQL 支持索引优化 + - 建议为常用查询字段添加索引: + ```sql + CREATE INDEX idx_media_type ON media(type); + CREATE INDEX idx_media_rating ON media(rating); + CREATE INDEX idx_media_date ON media(date); + ``` + +4. **连接池**: + - `postgres` 库默认使用连接池 + - 可以通过配置选项调整连接池大小 + +## 回滚方案 + +如果需要回滚到 SQLite: +1. 恢复 `db/index.ts` 中的 SQLite 连接代码 +2. 恢复所有 SQL 查询中的 `datetime('now')` +3. 移除 INSERT 语句中的 `RETURNING id` +4. 更新 `deno.json` 移除 postgres 依赖 diff --git a/db/index.ts b/db/index.ts index 2225144..0d1556c 100644 --- a/db/index.ts +++ b/db/index.ts @@ -4,13 +4,93 @@ * @LastEditTime: 2025-06-13 14:23:03 * @FilePath: /my-score/honoback/db/index.ts */ -import { DatabaseSync } from "node:sqlite" -import { join, dirname } from 'node:path' -import { fileURLToPath } from 'node:url' +import postgres from "postgres"; -const __dirname = dirname(fileURLToPath(import.meta.url)) +// 从环境变量获取数据库连接信息 +const DATABASE_URL = + Deno.env.get("DATABASE_URL") || + `postgresql://${Deno.env.get("DB_USER") || "postgres"}:${ + Deno.env.get("DB_PASSWORD") || "postgres" + }@${Deno.env.get("DB_HOST") || "localhost"}:${ + Deno.env.get("DB_PORT") || "5432" + }/${Deno.env.get("DB_NAME") || "media"}`; -// 初始化数据库连接 -const db = new DatabaseSync(join(__dirname, 'media.db')) +// 初始化 PostgreSQL 连接 +const sql = postgres(DATABASE_URL); -export { db } +// 将 SQLite 的 ? 占位符转换为 PostgreSQL 的 $1, $2, $3... +function convertQuery(query: string, params: any[]): [string, any[]] { + let pgQuery = query; + let paramIndex = 1; + const pgParams: any[] = []; + + // 替换 ? 为 $1, $2, $3... + pgQuery = pgQuery.replace(/\?/g, () => { + if (paramIndex <= params.length) { + pgParams.push(params[paramIndex - 1]); + } + return `$${paramIndex++}`; + }); + + return [pgQuery, pgParams]; +} + +// 创建一个兼容的数据库接口,模拟 SQLite 的 prepare 方法 +class DatabaseAdapter { + private sql: ReturnType; + + constructor(sql: ReturnType) { + this.sql = sql; + } + + prepare(query: string) { + return { + // all() 方法用于查询多条记录 + all: async (...params: any[]) => { + const [pgQuery, pgParams] = convertQuery(query, params); + const result = await this.sql.unsafe(pgQuery, pgParams); + return result; + }, + // get() 方法用于查询单条记录 + get: async (...params: any[]) => { + const [pgQuery, pgParams] = convertQuery(query, params); + const result = await this.sql.unsafe(pgQuery, pgParams); + return result[0] || null; + }, + // run() 方法用于执行 INSERT/UPDATE/DELETE + run: async (...params: any[]) => { + const [pgQuery, pgParams] = convertQuery(query, params); + const result = await this.sql.unsafe(pgQuery, pgParams); + + // 如果是 INSERT 语句,返回类似 SQLite 的 lastInsertRowid + if (query.trim().toUpperCase().startsWith("INSERT")) { + // PostgreSQL 使用 RETURNING 子句获取插入的 ID + if (query.includes("RETURNING")) { + return { + lastInsertRowid: result[0]?.id || null, + changes: result.length, + }; + } else { + // 如果没有 RETURNING,需要查询最后插入的 ID + // 注意:这需要表有 id 列且是 SERIAL 类型 + const lastIdResult = await this.sql.unsafe( + "SELECT lastval() as id" + ); + return { + lastInsertRowid: lastIdResult[0]?.id || null, + changes: result.length || 1, + }; + } + } + + return { + changes: result.length || 0, + }; + }, + }; + } +} + +const db = new DatabaseAdapter(sql); + +export { db, sql }; diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..d2a08ee --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,29 @@ +-- PostgreSQL 数据库 Schema +-- 用于创建 media 和 users 表 + +-- 创建 media 表 +CREATE TABLE IF NOT EXISTS media ( + id SERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + type VARCHAR(50) NOT NULL, + rating INTEGER, + notes TEXT, + platform VARCHAR(100), + date DATE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- 创建 users 表 +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(100) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT NOW() +); + +-- 创建索引以优化查询性能 +CREATE INDEX IF NOT EXISTS idx_media_type ON media(type); +CREATE INDEX IF NOT EXISTS idx_media_rating ON media(rating); +CREATE INDEX IF NOT EXISTS idx_media_date ON media(date); +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); diff --git a/deno.json b/deno.json index 0f5660f..d79907f 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,7 @@ { "imports": { - "hono": "jsr:@hono/hono@^4.7.11" + "hono": "jsr:@hono/hono@^4.7.11", + "postgres": "npm:postgres@^3.4.3" }, "tasks": { "start": "deno run --allow-net --allow-read --allow-write --allow-env --watch main.ts" diff --git a/deploy.sh b/deploy.sh index ba508a4..2fab67b 100644 --- a/deploy.sh +++ b/deploy.sh @@ -2,7 +2,7 @@ ### # @Date: 2025-06-13 16:11:38 # @LastEditors: 陈子健 - # @LastEditTime: 2025-06-23 16:56:58 + # @LastEditTime: 2025-06-23 17:24:22 # @FilePath: /my-score/honoback/deploy.sh ### @@ -19,11 +19,15 @@ deno cache main.ts echo "Creating remote directory..." ssh $USER@$SERVER "mkdir -p $REMOTE_DIR" -# 同步文件到服务器 -echo "Syncing files to server..." -rsync -avz --exclude 'db/media.db' \ - --exclude '.git' \ - ./ $USER@$SERVER:$REMOTE_DIR/ + +# 1. 远程备份数据库 +ssh $USER@$SERVER "cp $REMOTE_DIR/db/media.db $REMOTE_DIR/db/media.db.bak 2>/dev/null || true" + +# 2. 同步代码到服务器(只同步白名单内容,且排除 media.db) +rsync -avz --exclude='.git/' --exclude='node_modules/' --exclude='db/media.db' \ + --include='*.ts' --include='*.json' --include='*.sh' --include='*.service' \ + --include='db/' --include='db/*.ts' --include='routes/' --include='routes/*.ts' \ + --exclude='*' ./ $USER@$SERVER:$REMOTE_DIR # 在服务器上安装依赖并重启服务 echo "Installing and starting systemd service..." diff --git a/routes/media.ts b/routes/media.ts index ef7ccbd..246aaf8 100644 --- a/routes/media.ts +++ b/routes/media.ts @@ -1,143 +1,177 @@ -import { Hono } from 'hono' -import { db } from '../db/index.ts' -import type { JwtVariables } from 'hono/jwt' -import { authMiddleware } from '../auth.ts' +import { Hono } from "hono"; +import { db } from "../db/index.ts"; +import type { JwtVariables } from "hono/jwt"; +import { authMiddleware } from "../auth.ts"; -const media = new Hono<{ Variables: JwtVariables }>() +const media = new Hono<{ Variables: JwtVariables }>(); -media.use('/*', authMiddleware) +// 查询满分(10分)作品,不需要JWT验证 +media.get("/perfect", async (c) => { + try { + const perfectList = await db + .prepare("SELECT * FROM media WHERE rating = 10") + .all(); + return c.json({ + code: 0, + data: perfectList, + message: "Success", + }); + } catch (error: any) { + return c.json({ code: 1, data: {}, message: error.message }, 500); + } +}); + +media.use("/*", authMiddleware); // 获取所有媒体记录 -media.get('/list', (c) => { +media.get("/list", async (c) => { try { - const mediaList = db.prepare('SELECT * FROM media').all() + const mediaList = await db.prepare("SELECT * FROM media").all(); return c.json({ code: 0, data: mediaList, - message: 'Success' - }) + message: "Success", + }); } catch (error: any) { - return c.json({ code: 1, data: {}, message: error.message }, 500) + return c.json({ code: 1, data: {}, message: error.message }, 500); } -}) +}); // 创建新的媒体记录 -media.post('/create', async (c) => { +media.post("/create", async (c) => { try { - const data = await c.req.json() - const { title, type, rating, notes, platform, date } = data + const data = await c.req.json(); + const { title, type, rating, notes, platform, date } = data; - const result = db.prepare(` + const result = await db + .prepare( + ` INSERT INTO media (title, type, rating, notes, platform, date, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) - `).run(title, type, rating, notes, platform, date) + VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW()) + RETURNING id + ` + ) + .run(title, type, rating, notes, platform, date); - const newMedia = db.prepare('SELECT * FROM media WHERE id = ?').get(result.lastInsertRowid) + const newMedia = await db + .prepare("SELECT * FROM media WHERE id = ?") + .get(result.lastInsertRowid); - return c.json({ - code: 0, - data: newMedia, - message: 'Created successfully' - }, 201) + return c.json( + { + code: 0, + data: newMedia, + message: "Created successfully", + }, + 201 + ); } catch (error: any) { - return c.json({ code: 2, data: {}, message: error.message }, 500) + return c.json({ code: 2, data: {}, message: error.message }, 500); } -}) +}); // 更新媒体记录 -media.put('/updateById/:id', async (c) => { +media.put("/updateById/:id", async (c) => { try { - const id = c.req.param('id') - const data = await c.req.json() - const { title, type, rating, notes, platform, date } = data + const id = c.req.param("id"); + const data = await c.req.json(); + const { title, type, rating, notes, platform, date } = data; - db.prepare(` + await db + .prepare( + ` UPDATE media - SET title = ?, type = ?, rating = ?, notes = ?, platform = ?, date = ?, updated_at = datetime('now') + SET title = ?, type = ?, rating = ?, notes = ?, platform = ?, date = ?, updated_at = NOW() WHERE id = ? - `).run(title, type, rating, notes, platform, date, id) + ` + ) + .run(title, type, rating, notes, platform, date, id); - const updatedMedia = db.prepare('SELECT * FROM media WHERE id = ?').get(id) + const updatedMedia = await db + .prepare("SELECT * FROM media WHERE id = ?") + .get(id); if (!updatedMedia) { - return c.json({ code: 1, data: {}, message: 'Media not found' }, 404) + return c.json({ code: 1, data: {}, message: "Media not found" }, 404); } return c.json({ code: 0, data: updatedMedia, - message: 'Updated successfully' - }) + message: "Updated successfully", + }); } catch (error: any) { - return c.json({ code: 2, data: {}, message: error.message }, 500) + return c.json({ code: 2, data: {}, message: error.message }, 500); } -}) +}); // 删除媒体记录 -media.delete('/deleteById/:id', async (c) => { +media.delete("/deleteById/:id", async (c) => { try { - const id = c.req.param('id') - db.prepare('DELETE FROM media WHERE id = ?').run(id) - return c.json({ code: 0, data: {}, message: 'Deleted successfully' }) + const id = c.req.param("id"); + await db.prepare("DELETE FROM media WHERE id = ?").run(id); + return c.json({ code: 0, data: {}, message: "Deleted successfully" }); } catch (error: any) { - return c.json({ code: 2, data: {}, message: error.message }, 500) + return c.json({ code: 2, data: {}, message: error.message }, 500); } -}) +}); // 分页查询媒体记录 -media.get('/page', async (c) => { +media.get("/page", async (c) => { try { - const type = c.req.query('type') - const currentPage = parseInt(c.req.query('currentPage') || '1') - const pageSize = parseInt(c.req.query('pageSize') || '10') - const title = c.req.query('title') || '' - const startDate = c.req.query('startDate') - const endDate = c.req.query('endDate') - const sortBy = c.req.query('sortBy') || 'date' - const sortType = c.req.query('sortType') || 'desc' + const type = c.req.query("type"); + const currentPage = parseInt(c.req.query("currentPage") || "1"); + const pageSize = parseInt(c.req.query("pageSize") || "10"); + const title = c.req.query("title") || ""; + const startDate = c.req.query("startDate"); + const endDate = c.req.query("endDate"); + const sortBy = c.req.query("sortBy") || "date"; + const sortType = c.req.query("sortType") || "desc"; if (!type) { - return c.json({ code: 1, data: {}, message: 'Type is required' }, 400) + return c.json({ code: 1, data: {}, message: "Type is required" }, 400); } - let query = 'SELECT * FROM media WHERE type = ?' - const params = [type] + let query = "SELECT * FROM media WHERE type = ?"; + const params = [type]; if (title) { - query += ' AND title LIKE ?' - params.push(`%${title}%`) + query += " AND title LIKE ?"; + params.push(`%${title}%`); } if (startDate) { - query += ' AND date >= ?' - params.push(startDate) + query += " AND date >= ?"; + params.push(startDate); } if (endDate) { - query += ' AND date <= ?' - params.push(endDate) + query += " AND date <= ?"; + params.push(endDate); } // 添加排序 - if (sortBy === 'date') { - query += ` ORDER BY date ${sortType.toUpperCase()}` - } else if (sortBy === 'score') { - query += ` ORDER BY rating ${sortType.toUpperCase()}` + if (sortBy === "date") { + query += ` ORDER BY date ${sortType.toUpperCase()}`; + } else if (sortBy === "score") { + query += ` ORDER BY rating ${sortType.toUpperCase()}`; } // 添加分页 - const offset = (currentPage - 1) * pageSize - query += ' LIMIT ? OFFSET ?' - params.push(pageSize.toString(), offset.toString()) + const offset = (currentPage - 1) * pageSize; + query += " LIMIT ? OFFSET ?"; + params.push(pageSize.toString(), offset.toString()); // 获取总数 const countQuery = query - .replace('SELECT *', 'SELECT COUNT(*) as total') - .replace(/ORDER BY.*$/, '') // 移除 ORDER BY 子句 - .replace(/LIMIT.*$/, '') // 移除 LIMIT 和 OFFSET 子句 - const totalResult = db.prepare(countQuery).get(...params.slice(0, -2)) - const total = totalResult?.total || 0 + .replace("SELECT *", "SELECT COUNT(*) as total") + .replace(/ORDER BY.*$/, "") // 移除 ORDER BY 子句 + .replace(/LIMIT.*$/, ""); // 移除 LIMIT 和 OFFSET 子句 + const totalResult = await db + .prepare(countQuery) + .get(...params.slice(0, -2)); + const total = totalResult?.total || 0; // 获取分页数据 - const mediaList = db.prepare(query).all(...params) + const mediaList = await db.prepare(query).all(...params); return c.json({ code: 0, @@ -145,13 +179,13 @@ media.get('/page', async (c) => { list: mediaList, total, currentPage, - pageSize + pageSize, }, - message: 'Success' - }) + message: "Success", + }); } catch (error: any) { - return c.json({ code: 1, data: {}, message: error.message }, 500) + return c.json({ code: 1, data: {}, message: error.message }, 500); } -}) +}); -export default media +export default media; diff --git a/routes/user.ts b/routes/user.ts index dde1bd0..3c7d8e9 100644 --- a/routes/user.ts +++ b/routes/user.ts @@ -4,36 +4,40 @@ * @LastEditTime: 2025-06-16 11:17:09 * @FilePath: /my-score/honoback/routes/user.ts */ -import { Hono } from 'hono' -import { sign } from 'hono/jwt' -import type { JwtVariables } from 'hono/jwt' -import { db } from '../db/index.ts' +import { Hono } from "hono"; +import { sign } from "hono/jwt"; +import type { JwtVariables } from "hono/jwt"; +import { db } from "../db/index.ts"; -const user = new Hono<{ Variables: JwtVariables }>() +const user = new Hono<{ Variables: JwtVariables }>(); -const AUTH_SECRET = 'it-is-a-secret' +const AUTH_SECRET = "it-is-a-secret"; // 登录路由 -user.post('/login', async (c) => { - const { username, password } = await c.req.json() - +user.post("/login", async (c) => { + const { username, password } = await c.req.json(); + // 从数据库验证用户 - const user = db.prepare('SELECT * FROM users WHERE username = ? AND password = ?') - .get(username, password) - - if (user) { - const token = await sign({ username: user.username }, AUTH_SECRET) - return c.json({ + const userRecord = await db + .prepare("SELECT * FROM users WHERE username = ? AND password = ?") + .get(username, password); + + if (userRecord) { + const token = await sign({ username: userRecord.username }, AUTH_SECRET); + return c.json({ code: 200, data: { token }, - message: '登录成功' - }) + message: "登录成功", + }); } - - return c.json({ - code: 401, - message: '用户名或密码错误' - }, 401) -}) -export default user + return c.json( + { + code: 401, + message: "用户名或密码错误", + }, + 401 + ); +}); + +export default user;