项目初始化
创建项目
npx nuxi@latest init flutter-api-server
cd flutter-api-server为什么用 nuxi init?
它会生成 Nuxt 4 标准目录结构(含 app/ 目录),而不是旧版 Nuxt 3 的根目录结构。如果你手动建项目,需要自行配置目录结构。
安装依赖
# 数据库
npm install drizzle-orm postgres
npm install -D drizzle-kit
# 认证
npm install bcryptjs jsonwebtoken
# 校验
npm install zod
# 短信 SDK
npm install tencentcloud-sdk-nodejs-sms @alicloud/sms
# 支付 SDK
npm install wechatpay-node-v3 alipay-sdk
# Redis
npm install ioredis
# 工具
npm install nanoid dayjs
# 开发依赖
npm install -D @types/bcryptjs @types/jsonwebtoken依赖说明
| 包名 | 用途 | 为什么选它 |
|---|---|---|
drizzle-orm + postgres | ORM + PostgreSQL 驱动 | Drizzle 轻量且类型安全,postgres.js 比 pg 快 3 倍 |
drizzle-kit | 数据库迁移工具 | 生成可审查的 SQL 文件,不是黑盒 |
bcryptjs | 密码哈希 | 纯 JS 实现,无需编译原生模块,Docker 构建不出错 |
jsonwebtoken | JWT 签发/验证 | 生态最成熟,API 简洁 |
zod | 输入校验 | TypeScript 类型推导,运行时 + 编译时双重保障 |
ioredis | Redis 客户端 | 支持 Pipeline、Lua 脚本、Cluster,比 redis 包功能更全 |
nanoid | 生成唯一 ID | 生成订单号等短 ID,比 UUID 短且 URL 安全 |
为什么用 bcryptjs 而不是 bcrypt?
bcrypt 是原生 C++ 模块,在 Docker Alpine 镜像中需要编译工具链。bcryptjs 纯 JS 实现,零依赖安装,性能差异在用户认证场景下可忽略(哈希本身就是耗时操作)。
为什么不需要 @types/ioredis?
ioredis 自带 TypeScript 类型定义,无需额外安装。
配置 Nuxt
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxt/ui'],
runtimeConfig: {
// 服务端私有
jwtSecret: '',
dbUrl: '',
redisUrl: '',
smsSecretId: '',
smsSecretKey: '',
wechatPayMchId: '',
wechatPayApiKey: '',
alipayAppId: '',
alipayPrivateKey: '',
// 公共
public: {
apiBase: '/api',
appName: 'Flutter API Server',
},
},
routeRules: {
'/admin/**': { ssr: false },
'/api/admin/**': { cors: false },
},
compatibilityDate: '2025-07-15',
})配置要点解析
runtimeConfig 的 NUXT_ 前缀规则
Nuxt 的环境变量映射规则:runtimeConfig.jwtSecret 对应环境变量 NUXT_JWT_SECRET。
runtimeConfig 属性名 环境变量名
jwtSecret → NUXT_JWT_SECRET
dbUrl → NUXT_DB_URL
public.apiBase → NUXT_PUBLIC_API_BASE常见错误
在 .env 中写 JWT_SECRET=xxx,Nuxt 不会读取。必须写 NUXT_JWT_SECRET=xxx。
为什么不直接用 process.env?
runtimeConfig 的好处是:(1) 自动区分服务端/客户端,public 下的会暴露给前端,其余只在服务端;(2) 类型安全,在代码中有自动补全;(3) 统一管理,所有配置集中在 nuxt.config.ts 声明。
为什么管理后台关闭 SSR?
'/admin/**': { ssr: false }三个原因:
- 管理后台不需要 SEO:只有管理员使用,搜索引擎无需抓取
- 减少服务器压力:SSR 需要服务端渲染每个页面,管理后台页面复杂(表格、图表、表单),SSR 开销大
- 避免认证复杂度:SSR 时服务端需要验证用户身份才能渲染页面,增加复杂度。关闭 SSR 后,页面在客户端加载,前端中间件检查即可
为什么 API 默认关闭 CORS?
'/api/admin/**': { cors: false }- API 服务只给 Flutter 客户端和管理后台使用,不需要允许第三方网站跨域调用
- 关闭 CORS 是安全默认值,防止 CSRF 攻击
- 如果未来需要开放公共 API,单独对特定路由开启
配置环境变量
# .env
NUXT_JWT_SECRET=your-jwt-secret-key
NUXT_DB_URL=postgresql://user:password@localhost:5432/flutter_api
NUXT_REDIS_URL=redis://localhost:6379
NUXT_SMS_SECRET_ID=your-tencent-secret-id
NUXT_SMS_SECRET_KEY=your-tencent-secret-key
NUXT_PUBLIC_API_BASE=/api.env 文件必须在 .gitignore 中
Nuxt 脚手架默认已添加,但请确认。一旦密钥提交到 Git,即使后续删除也有泄露风险。
JWT 密钥生成方法
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" — 至少 64 字节,确保足够强度。
配置数据库
// server/database/db.ts
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import * as schema from './schema'
const config = useRuntimeConfig()
const client = postgres(config.dbUrl)
export const db = drizzle(client, { schema })SSR 下的数据库连接注意事项
这段代码有一个容易忽视的陷阱——在 SSR 环境中,模块级代码会在服务端执行。
客户端导航 服务端首次渲染
────────── ──────────────
不会执行 server/ 代码 会执行 server/database/db.ts
→ postgres() 创建连接
→ 如果连接池配置不当,可能耗尽连接关键要点:
postgres.js默认连接池大小是 10。在 SSR 场景下,每个请求复用连接池,不会每次请求创建新连接——这是正确的。不要在组件中使用
db。server/database/db.ts虽然可以通过自动导入在组件中访问,但数据库操作只能在server/目录中使用。在组件中应该用useFetch调用 API。开发模式热更新不会断开连接。
postgres.js的连接池是模块级别的,HMR 时不会重新创建,避免了连接泄漏。生产环境建议配置连接池参数:
const client = postgres(config.dbUrl, {
max: 20, // 最大连接数
idle_timeout: 20, // 空闲连接超时(秒)
connect_timeout: 10, // 连接超时(秒)
})// server/database/schema.ts
import { pgTable, serial, varchar, integer, decimal, timestamp, boolean, smallint } from 'drizzle-orm/pg-core'
export const users = pgTable('users', {
id: serial('id').primaryKey(),
phone: varchar('phone', { length: 20 }).unique(),
email: varchar('email', { length: 100 }),
passwordHash: varchar('password_hash', { length: 255 }),
nickname: varchar('nickname', { length: 50 }),
avatar: varchar('avatar', { length: 255 }),
role: varchar('role', { length: 20 }).default('user'),
status: smallint('status').default(1),
vipExpiresAt: timestamp('vip_expires_at'),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
})
export const orders = pgTable('orders', {
id: serial('id').primaryKey(),
userId: integer('user_id').references(() => users.id),
orderNo: varchar('order_no', { length: 64 }).unique(),
planId: integer('plan_id'),
amount: decimal('amount', { precision: 10, scale: 2 }),
paymentMethod: varchar('payment_method', { length: 20 }),
paymentNo: varchar('payment_no', { length: 64 }),
status: smallint('status').default(0),
paidAt: timestamp('paid_at'),
createdAt: timestamp('created_at').defaultNow(),
})
export const plans = pgTable('plans', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 50 }),
durationDays: integer('duration_days'),
price: decimal('price', { precision: 10, scale: 2 }),
originalPrice: decimal('original_price', { precision: 10, scale: 2 }),
sortOrder: integer('sort_order').default(0),
status: smallint('status').default(1),
})
export const smsCodes = pgTable('sms_codes', {
id: serial('id').primaryKey(),
phone: varchar('phone', { length: 20 }),
code: varchar('code', { length: 6 }),
used: boolean('used').default(false),
expiresAt: timestamp('expires_at'),
createdAt: timestamp('created_at').defaultNow(),
})Schema 设计要点
serialvsinteger:serial是自增主键,自动管理序列。普通integer不会自增references(() => users.id):Drizzle 的外键引用语法,用函数返回确保表定义顺序无关decimal用于金额:precision: 10, scale: 2表示最多 10 位数字、2 位小数,即最大 99999999.99defaultNow():数据库层面设置默认值,不依赖应用层——即使直接写 SQL 插入也有时间戳
生成数据库迁移
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
schema: './server/database/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.NUXT_DB_URL!,
},
})为什么 drizzle.config.ts 用 process.env 而不是 useRuntimeConfig()?
这个文件由 drizzle-kit CLI 直接运行,不在 Nuxt 上下文中,无法使用 useRuntimeConfig()。它读取 .env 中的 NUXT_DB_URL 是因为 dotenv 机制。
# 生成迁移
npx drizzle-kit generate
# 执行迁移
npx drizzle-kit migrategenerate vs migrate 的区别
generate:对比 schema 和上一次迁移,生成新的 SQL 文件(在drizzle/目录),不触达数据库migrate:将未执行的迁移 SQL 应用到数据库,会修改数据库
最佳实践
开发时先 generate 检查 SQL 是否正确,确认后再 migrate。
配置 ESLint
npm install -D eslint @nuxt/eslint// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxt/ui', '@nuxt/eslint'],
})为什么用 @nuxt/eslint 而不是手动配置?
@nuxt/eslint 会自动配置 Nuxt 特定规则(如禁止在 server/ 中使用客户端 API),并生成 .eslintignore。手动配置容易遗漏。
验证项目
npm run dev访问 http://localhost:3000 确认项目正常运行。
常见启动问题
| 现象 | 原因 | 解决 |
|---|---|---|
NUXT_DB_URL is not set | .env 文件不存在或变量名错误 | 确认 .env 在项目根目录,变量名以 NUXT_ 开头 |
connect ECONNREFUSED | PostgreSQL 未启动 | docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=postgres postgres:16-alpine |
Module not found: drizzle-orm | 依赖未安装 | npm install |
| 页面空白无报错 | app/ 目录下无页面 | 正常,刚初始化还没有页面 |