feat: 添加 Vercel Postgres 数据库支持
- 安装 @vercel/postgres 和 pg 依赖 - 创建 PostgresAdapter 适配器,兼容 D1 接口 - 创建 PostgresStorage 类,实现完整的 IStorage 接口 - 添加 Postgres 数据库初始化脚本和 schema - 更 next.config.js 排除 Postgres 模块的客户端打包 - 添加 VERCEL_DEPLOYMENT.md 部署指南 - 支持 Vercel serverless 环境部署 注意事项:观影室功能在 Vercel 上不可用(需要 WebSocket 支持)
This commit is contained in:
88
VERCEL_DEPLOYMENT.md
Normal file
88
VERCEL_DEPLOYMENT.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Vercel 部署指南
|
||||||
|
|
||||||
|
本项目已支持使用 **Vercel Postgres** 作为数据库后端,可在 Vercel 平台上稳定部署。
|
||||||
|
|
||||||
|
## 环境变量配置
|
||||||
|
|
||||||
|
在 Vercel 项目设置中添加以下环境变量:
|
||||||
|
|
||||||
|
### 必需的环境变量
|
||||||
|
|
||||||
|
| 变量名 | 说明 | 示例值 |
|
||||||
|
|--------|------|--------|
|
||||||
|
| `NEXT_PUBLIC_STORAGE_TYPE` | 存储类型 | `postgres` |
|
||||||
|
| `POSTGRES_URL` | Vercel Postgres 连接字符串 | `postgres://...` |
|
||||||
|
| `USERNAME` | 管理员用户名 | `admin` |
|
||||||
|
| `PASSWORD` | 管理员密码 | `your_password` |
|
||||||
|
|
||||||
|
### Vercel Postgres 连接字符串
|
||||||
|
|
||||||
|
Vercel Postgres 会自动提供以下环境变量:
|
||||||
|
- `POSTGRES_URL` - 完整连接字符串
|
||||||
|
- `POSTGRES_PRISMA_URL` - Prisma 兼容连接字符串
|
||||||
|
- `POSTGRES_URL_NON_POOLING` - 无连接池连接字符串
|
||||||
|
- `POSTGRES_USER` - 数据库用户名
|
||||||
|
- `POSTGRES_HOST` - 数据库主机
|
||||||
|
- `POSTGRES_PASSWORD` - 数据库密码
|
||||||
|
- `POSTGRES_DATABASE` - 数据库名称
|
||||||
|
|
||||||
|
通常只需要使用 `POSTGRES_URL` 即可。
|
||||||
|
|
||||||
|
## 部署步骤
|
||||||
|
|
||||||
|
### 1. 创建 Vercel Postgres 数据库
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装 Vercel CLI
|
||||||
|
npm i -g vercel
|
||||||
|
|
||||||
|
# 登录 Vercel
|
||||||
|
vercel login
|
||||||
|
|
||||||
|
# 创建 Postgres 数据库
|
||||||
|
vercel postgres create
|
||||||
|
|
||||||
|
# 选择数据库并连接到项目
|
||||||
|
vercel postgres connect
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 初始化数据库表结构
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 运行数据库初始化脚本
|
||||||
|
pnpm init:postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 部署到 Vercel
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 部署项目
|
||||||
|
vercel --prod
|
||||||
|
```
|
||||||
|
|
||||||
|
## 功能限制
|
||||||
|
|
||||||
|
由于 Vercel 的 serverless 环境限制,以下功能不可用:
|
||||||
|
|
||||||
|
- **观影室** (Watch Room) - 需要 WebSocket 支持,Vercel serverless 不支持长时间运行的连接
|
||||||
|
|
||||||
|
## 存储类型对比
|
||||||
|
|
||||||
|
| 存储类型 | 部署平台 | 数据持久化 | 说明 |
|
||||||
|
|---------|---------|-----------|------|
|
||||||
|
| `localstorage` | 任意 | ❌ 浏览器本地 | 仅用于测试 |
|
||||||
|
| `d1` | Cloudflare | ✅ | Cloudflare D1 数据库 |
|
||||||
|
| `postgres` | Vercel | ✅ | Vercel Postgres 数据库 |
|
||||||
|
| `redis` | 自建服务器 | ✅ | Redis 数据库 |
|
||||||
|
| `upstash` | Vercel | ✅ | Upstash Redis |
|
||||||
|
| `kvrocks` | 自建服务器 | ✅ | Kvrocks 数据库 |
|
||||||
|
|
||||||
|
## 数据迁移
|
||||||
|
|
||||||
|
如果需要从其他存储类型迁移数据到 Vercel Postgres,请使用管理后台的数据迁移功能。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **数据库配额**:Vercel Postgres 免费版有 256 MB 存储限制
|
||||||
|
2. **连接池**:Vercel Postgres 自动管理连接池,无需手动配置
|
||||||
|
3. **冷启动**:首次请求可能需要几秒钟冷启动时间
|
||||||
170
migrations/postgres/001_initial_schema.sql
Normal file
170
migrations/postgres/001_initial_schema.sql
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- MoonTV Plus - Vercel Postgres 数据库结构
|
||||||
|
-- 版本: 1.0.0
|
||||||
|
-- 创建时间: 2026-02-07
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 1. 用户表
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
username TEXT PRIMARY KEY,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK(role IN ('owner', 'admin', 'user')),
|
||||||
|
banned INTEGER DEFAULT 0,
|
||||||
|
tags TEXT, -- JSON array: ["vip", "premium"]
|
||||||
|
oidc_sub TEXT UNIQUE,
|
||||||
|
enabled_apis TEXT, -- JSON array: ["api1", "api2"]
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
playrecord_migrated INTEGER DEFAULT 0,
|
||||||
|
favorite_migrated INTEGER DEFAULT 0,
|
||||||
|
skip_migrated INTEGER DEFAULT 0,
|
||||||
|
last_movie_request_time BIGINT,
|
||||||
|
email TEXT,
|
||||||
|
email_notifications INTEGER DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_oidc_sub ON users(oidc_sub) WHERE oidc_sub IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at);
|
||||||
|
|
||||||
|
-- 2. 播放记录表
|
||||||
|
CREATE TABLE IF NOT EXISTS play_records (
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL, -- format: "source+id" (e.g., "tmdb+12345")
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
source_name TEXT NOT NULL,
|
||||||
|
cover TEXT,
|
||||||
|
year TEXT,
|
||||||
|
episode_index INTEGER NOT NULL,
|
||||||
|
total_episodes INTEGER NOT NULL,
|
||||||
|
play_time BIGINT NOT NULL, -- 播放进度(秒)
|
||||||
|
total_time BIGINT NOT NULL, -- 总时长(秒)
|
||||||
|
save_time BIGINT NOT NULL, -- 保存时间戳
|
||||||
|
search_title TEXT,
|
||||||
|
PRIMARY KEY (username, key),
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_play_records_save_time ON play_records(username, save_time DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_play_records_source ON play_records(username, source_name);
|
||||||
|
|
||||||
|
-- 3. 收藏表
|
||||||
|
CREATE TABLE IF NOT EXISTS favorites (
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL, -- format: "source+id"
|
||||||
|
source_name TEXT NOT NULL,
|
||||||
|
total_episodes INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
year TEXT,
|
||||||
|
cover TEXT,
|
||||||
|
save_time BIGINT NOT NULL,
|
||||||
|
search_title TEXT,
|
||||||
|
origin TEXT CHECK(origin IN ('vod', 'live')),
|
||||||
|
is_completed INTEGER DEFAULT 0,
|
||||||
|
vod_remarks TEXT,
|
||||||
|
PRIMARY KEY (username, key),
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_favorites_save_time ON favorites(username, save_time DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_favorites_source ON favorites(username, source_name);
|
||||||
|
|
||||||
|
-- 4. 搜索历史表
|
||||||
|
CREATE TABLE IF NOT EXISTS search_history (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
keyword TEXT NOT NULL,
|
||||||
|
timestamp BIGINT NOT NULL,
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE,
|
||||||
|
UNIQUE(username, keyword)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_search_history_user_time ON search_history(username, timestamp DESC);
|
||||||
|
|
||||||
|
-- 5. 跳过配置表(片头片尾)
|
||||||
|
CREATE TABLE IF NOT EXISTS skip_configs (
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL, -- format: "source+id"
|
||||||
|
enable INTEGER NOT NULL DEFAULT 1,
|
||||||
|
intro_time INTEGER NOT NULL DEFAULT 0, -- 片头时长(秒)
|
||||||
|
outro_time INTEGER NOT NULL DEFAULT 0, -- 片尾时长(秒)
|
||||||
|
PRIMARY KEY (username, key),
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 6. 弹幕过滤配置表
|
||||||
|
CREATE TABLE IF NOT EXISTS danmaku_filter_configs (
|
||||||
|
username TEXT PRIMARY KEY,
|
||||||
|
rules TEXT NOT NULL, -- JSON array: [{"keyword": "xxx", "type": "normal", "enabled": true}]
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 7. 通知表
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('favorite_update', 'system', 'announcement', 'movie_request', 'request_fulfilled')),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
timestamp BIGINT NOT NULL,
|
||||||
|
read INTEGER DEFAULT 0,
|
||||||
|
metadata TEXT, -- JSON object
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_user_time ON notifications(username, timestamp DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_user_read ON notifications(username, read, timestamp DESC);
|
||||||
|
|
||||||
|
-- 8. 求片请求表
|
||||||
|
CREATE TABLE IF NOT EXISTS movie_requests (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
tmdb_id INTEGER,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
year TEXT,
|
||||||
|
media_type TEXT NOT NULL CHECK(media_type IN ('movie', 'tv')),
|
||||||
|
season INTEGER,
|
||||||
|
poster TEXT,
|
||||||
|
overview TEXT,
|
||||||
|
requested_by TEXT NOT NULL, -- JSON array: ["user1", "user2"]
|
||||||
|
request_count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
status TEXT NOT NULL CHECK(status IN ('pending', 'fulfilled')) DEFAULT 'pending',
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL,
|
||||||
|
fulfilled_at BIGINT,
|
||||||
|
fulfilled_source TEXT,
|
||||||
|
fulfilled_id TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_movie_requests_status ON movie_requests(status, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_movie_requests_tmdb ON movie_requests(tmdb_id) WHERE tmdb_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- 9. 用户求片关联表(用于快速查询用户的求片记录)
|
||||||
|
CREATE TABLE IF NOT EXISTS user_movie_requests (
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
request_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (username, request_id),
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (request_id) REFERENCES movie_requests(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_movie_requests_user ON user_movie_requests(username);
|
||||||
|
|
||||||
|
-- 10. 全局配置表(键值对存储)
|
||||||
|
CREATE TABLE IF NOT EXISTS global_config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 11. 管理员配置表(单例)
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_config (
|
||||||
|
id INTEGER PRIMARY KEY CHECK(id = 1), -- 确保只有一条记录
|
||||||
|
config TEXT NOT NULL, -- JSON object
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 12. 收藏更新检查时间表
|
||||||
|
CREATE TABLE IF NOT EXISTS favorite_check_times (
|
||||||
|
username TEXT PRIMARY KEY,
|
||||||
|
last_check_time BIGINT NOT NULL,
|
||||||
|
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
|
||||||
|
);
|
||||||
@@ -71,11 +71,13 @@ const nextConfig = {
|
|||||||
crypto: false,
|
crypto: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Exclude better-sqlite3 and D1 modules from client-side bundle
|
// Exclude better-sqlite3, D1, and Postgres modules from client-side bundle
|
||||||
if (!isServer) {
|
if (!isServer) {
|
||||||
config.externals = config.externals || [];
|
config.externals = config.externals || [];
|
||||||
config.externals.push({
|
config.externals.push({
|
||||||
'better-sqlite3': 'commonjs better-sqlite3',
|
'better-sqlite3': 'commonjs better-sqlite3',
|
||||||
|
'@vercel/postgres': 'commonjs @vercel/postgres',
|
||||||
|
'pg': 'commonjs pg',
|
||||||
});
|
});
|
||||||
|
|
||||||
config.resolve.alias = {
|
config.resolve.alias = {
|
||||||
@@ -83,6 +85,8 @@ const nextConfig = {
|
|||||||
'better-sqlite3': false,
|
'better-sqlite3': false,
|
||||||
'@/lib/d1.db': false,
|
'@/lib/d1.db': false,
|
||||||
'@/lib/d1-adapter': false,
|
'@/lib/d1-adapter': false,
|
||||||
|
'@/lib/postgres.db': false,
|
||||||
|
'@/lib/postgres-adapter': false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"prepare": "husky install",
|
"prepare": "husky install",
|
||||||
"watch-room:server": "node server/watch-room-standalone-server.js --port 3001 --auth YOUR_SECRET_KEY",
|
"watch-room:server": "node server/watch-room-standalone-server.js --port 3001 --auth YOUR_SECRET_KEY",
|
||||||
"init:sqlite": "node scripts/init-sqlite.js",
|
"init:sqlite": "node scripts/init-sqlite.js",
|
||||||
|
"init:postgres": "node scripts/init-postgres.js",
|
||||||
"db:reset": "rm -f .data/moontv.db .data/moontv.db-shm .data/moontv.db-wal && node scripts/init-sqlite.js"
|
"db:reset": "rm -f .data/moontv.db .data/moontv.db-shm .data/moontv.db-wal && node scripts/init-sqlite.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -91,6 +92,7 @@
|
|||||||
"@types/node": "24.0.3",
|
"@types/node": "24.0.3",
|
||||||
"@types/node-fetch": "^2.6.13",
|
"@types/node-fetch": "^2.6.13",
|
||||||
"@types/nodemailer": "^7.0.5",
|
"@types/nodemailer": "^7.0.5",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@types/react-window": "^2.0.0",
|
"@types/react-window": "^2.0.0",
|
||||||
|
|||||||
617
pnpm-lock.yaml
generated
617
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
62
scripts/init-postgres.js
Normal file
62
scripts/init-postgres.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Vercel Postgres 数据库初始化脚本
|
||||||
|
*
|
||||||
|
* 创建数据库表结构并初始化默认管理员用户
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { sql } = require('@vercel/postgres');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// SHA-256 加密密码
|
||||||
|
function hashPassword(password) {
|
||||||
|
return crypto.createHash('sha256').update(password).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📦 Initializing Vercel Postgres database...');
|
||||||
|
|
||||||
|
// 读取迁移脚本
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const sqlPath = path.join(__dirname, '../migrations/postgres/001_initial_schema.sql');
|
||||||
|
if (!fs.existsSync(sqlPath)) {
|
||||||
|
console.error('❌ Migration file not found:', sqlPath);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const schemaSql = fs.readFileSync(sqlPath, 'utf8');
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
// 执行 schema 创建
|
||||||
|
console.log('🔧 Creating database schema...');
|
||||||
|
await sql.unsafe(schemaSql);
|
||||||
|
console.log('✅ Database schema created successfully!');
|
||||||
|
|
||||||
|
// 创建默认管理员用户
|
||||||
|
const username = process.env.USERNAME || 'admin';
|
||||||
|
const password = process.env.PASSWORD || '123456789';
|
||||||
|
const passwordHash = hashPassword(password);
|
||||||
|
|
||||||
|
console.log('👤 Creating default admin user...');
|
||||||
|
await sql`
|
||||||
|
INSERT INTO users (username, password_hash, role, created_at, playrecord_migrated, favorite_migrated, skip_migrated)
|
||||||
|
VALUES (${username}, ${passwordHash}, 'owner', ${Date.now()}, 1, 1, 1)
|
||||||
|
ON CONFLICT (username) DO NOTHING
|
||||||
|
`;
|
||||||
|
console.log(`✅ Default admin user created: ${username}`);
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('🎉 Vercel Postgres database initialized successfully!');
|
||||||
|
console.log('');
|
||||||
|
console.log('Next steps:');
|
||||||
|
console.log('1. Set NEXT_PUBLIC_STORAGE_TYPE=postgres in .env');
|
||||||
|
console.log('2. Set POSTGRES_URL environment variable');
|
||||||
|
console.log('3. Run: npm run dev');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Initialization failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
@@ -7,7 +7,7 @@ import { RedisStorage } from './redis.db';
|
|||||||
import { DanmakuFilterConfig,Favorite, IStorage, PlayRecord, SkipConfig } from './types';
|
import { DanmakuFilterConfig,Favorite, IStorage, PlayRecord, SkipConfig } from './types';
|
||||||
import { UpstashRedisStorage } from './upstash.db';
|
import { UpstashRedisStorage } from './upstash.db';
|
||||||
|
|
||||||
// storage type 常量: 'localstorage' | 'redis' | 'upstash' | 'kvrocks' | 'd1',默认 'localstorage'
|
// storage type 常量: 'localstorage' | 'redis' | 'upstash' | 'kvrocks' | 'd1' | 'postgres',默认 'localstorage'
|
||||||
const STORAGE_TYPE =
|
const STORAGE_TYPE =
|
||||||
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
|
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
|
||||||
| 'localstorage'
|
| 'localstorage'
|
||||||
@@ -15,6 +15,7 @@ const STORAGE_TYPE =
|
|||||||
| 'upstash'
|
| 'upstash'
|
||||||
| 'kvrocks'
|
| 'kvrocks'
|
||||||
| 'd1'
|
| 'd1'
|
||||||
|
| 'postgres'
|
||||||
| undefined) || 'localstorage';
|
| undefined) || 'localstorage';
|
||||||
|
|
||||||
// 创建存储实例
|
// 创建存储实例
|
||||||
@@ -31,16 +32,38 @@ function createStorage(): IStorage {
|
|||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
throw new Error('D1Storage can only be used on the server side');
|
throw new Error('D1Storage can only be used on the server side');
|
||||||
}
|
}
|
||||||
const adapter = getD1Adapter();
|
const d1Adapter = getD1Adapter();
|
||||||
// 动态导入 D1Storage 以避免客户端打包
|
// 动态导入 D1Storage 以避免客户端打包
|
||||||
const { D1Storage } = require('./d1.db');
|
const { D1Storage } = require('./d1.db');
|
||||||
return new D1Storage(adapter);
|
return new D1Storage(d1Adapter);
|
||||||
|
case 'postgres':
|
||||||
|
// PostgresStorage 只能在服务端使用,客户端会报错
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
throw new Error('PostgresStorage can only be used on the server side');
|
||||||
|
}
|
||||||
|
const postgresAdapter = getPostgresAdapter();
|
||||||
|
// 动态导入 PostgresStorage 以避免客户端打包
|
||||||
|
const { PostgresStorage } = require('./postgres.db');
|
||||||
|
return new PostgresStorage(postgresAdapter);
|
||||||
case 'localstorage':
|
case 'localstorage':
|
||||||
default:
|
default:
|
||||||
return null as unknown as IStorage;
|
return null as unknown as IStorage;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Postgres 适配器
|
||||||
|
* 使用 Vercel Postgres (@vercel/postgres)
|
||||||
|
*/
|
||||||
|
function getPostgresAdapter(): any {
|
||||||
|
// 动态导入适配器以避免客户端打包
|
||||||
|
const { PostgresAdapter } = require('./postgres-adapter');
|
||||||
|
|
||||||
|
console.log('Using Vercel Postgres database');
|
||||||
|
|
||||||
|
return new PostgresAdapter();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 D1 适配器
|
* 获取 D1 适配器
|
||||||
* 开发环境:使用 better-sqlite3
|
* 开发环境:使用 better-sqlite3
|
||||||
|
|||||||
151
src/lib/postgres-adapter.ts
Normal file
151
src/lib/postgres-adapter.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vercel Postgres (Neon/Postgres) 适配器
|
||||||
|
*
|
||||||
|
* 将 Vercel Postgres API 转换为与 D1 兼容的接口
|
||||||
|
*
|
||||||
|
* 注意:此模块仅在服务端使用,通过 webpack 配置排除客户端打包
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { sql } from '@vercel/postgres';
|
||||||
|
import { DatabaseAdapter, D1PreparedStatement, D1Result } from './d1-adapter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vercel Postgres 适配器
|
||||||
|
*
|
||||||
|
* 使用 @vercel/postgres 包装为 D1 兼容接口
|
||||||
|
*/
|
||||||
|
export class PostgresAdapter implements DatabaseAdapter {
|
||||||
|
private queryParams: { query: string; values: any[] } | null = null;
|
||||||
|
|
||||||
|
prepare(query: string): D1PreparedStatement {
|
||||||
|
return new PostgresPreparedStatement(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
batch(statements: D1PreparedStatement[]): Promise<D1Result[]> {
|
||||||
|
// Postgres 使用事务模拟 batch
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
Promise.all(statements.map((stmt) => (stmt as PostgresPreparedStatement).execute()))
|
||||||
|
.then((results) => resolve(results))
|
||||||
|
.catch((err) => reject(err));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
exec(query: string): void {
|
||||||
|
// Vercel Postgres 不支持直接 exec,需要使用 sql 模板
|
||||||
|
throw new Error('exec() is not supported for Vercel Postgres. Use prepare() instead.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vercel Postgres PreparedStatement 包装器
|
||||||
|
* 将 Vercel Postgres API 转换为 D1 兼容 API
|
||||||
|
*/
|
||||||
|
class PostgresPreparedStatement implements D1PreparedStatement {
|
||||||
|
private params: any[] = [];
|
||||||
|
private paramIndex = 1;
|
||||||
|
|
||||||
|
constructor(private query: string) {}
|
||||||
|
|
||||||
|
bind(...values: any[]): D1PreparedStatement {
|
||||||
|
this.params = values;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 SQLite 风格的 ? 占位符替换为 Postgres 风格的 $1, $2, ...
|
||||||
|
*/
|
||||||
|
private convertQuery(query: string): string {
|
||||||
|
let index = 1;
|
||||||
|
return query.replace(/\?/g, () => `$${index++}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 SQL 查询中的表名和列名转换为双引号包裹(Postgres 要求)
|
||||||
|
* 注意:需要排除已经有引号的内容
|
||||||
|
*/
|
||||||
|
private quoteIdentifiers(query: string): string {
|
||||||
|
// 这个方法主要用于处理列值,表名在 schema 中已经创建好
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行查询并返回第一行
|
||||||
|
*/
|
||||||
|
async first<T = any>(colName?: string): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const convertedQuery = this.convertQuery(this.query);
|
||||||
|
|
||||||
|
// 使用 Vercel Postgres 的 unsafe 方法执行参数化查询
|
||||||
|
const result = await sql.unsafe(convertedQuery, this.params);
|
||||||
|
|
||||||
|
if (!result || result.rows.length === 0) return null;
|
||||||
|
|
||||||
|
const row = result.rows[0];
|
||||||
|
|
||||||
|
if (colName) return row[colName] ?? null;
|
||||||
|
|
||||||
|
return row as T;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Postgres first() error:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行查询并返回结果
|
||||||
|
*/
|
||||||
|
async run<T = any>(): Promise<D1Result<T>> {
|
||||||
|
try {
|
||||||
|
const convertedQuery = this.convertQuery(this.query);
|
||||||
|
|
||||||
|
const result = await sql.unsafe(convertedQuery, this.params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
meta: {
|
||||||
|
changes: result.rowCount || 0,
|
||||||
|
last_row_id: null, // Postgres 不直接返回 lastInsertId
|
||||||
|
},
|
||||||
|
results: result.rows,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Postgres run() error:', err);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行查询并返回所有行
|
||||||
|
*/
|
||||||
|
async all<T = any>(): Promise<D1Result<T>> {
|
||||||
|
try {
|
||||||
|
const convertedQuery = this.convertQuery(this.query);
|
||||||
|
|
||||||
|
const result = await sql.unsafe(convertedQuery, this.params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
results: result.rows || [],
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Postgres all() error:', err);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err.message,
|
||||||
|
results: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内部执行方法(用于 batch 操作)
|
||||||
|
*/
|
||||||
|
async execute(): Promise<D1Result> {
|
||||||
|
return this.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
1561
src/lib/postgres.db.ts
Normal file
1561
src/lib/postgres.db.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user