Skip to content

项目初始化

创建项目

bash
npx nuxi@latest init flutter-api-server
cd flutter-api-server

为什么用 nuxi init

它会生成 Nuxt 4 标准目录结构(含 app/ 目录),而不是旧版 Nuxt 3 的根目录结构。如果你手动建项目,需要自行配置目录结构。

安装依赖

bash
# 数据库
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 + postgresORM + PostgreSQL 驱动Drizzle 轻量且类型安全,postgres.jspg 快 3 倍
drizzle-kit数据库迁移工具生成可审查的 SQL 文件,不是黑盒
bcryptjs密码哈希纯 JS 实现,无需编译原生模块,Docker 构建不出错
jsonwebtokenJWT 签发/验证生态最成熟,API 简洁
zod输入校验TypeScript 类型推导,运行时 + 编译时双重保障
ioredisRedis 客户端支持 Pipeline、Lua 脚本、Cluster,比 redis 包功能更全
nanoid生成唯一 ID生成订单号等短 ID,比 UUID 短且 URL 安全

为什么用 bcryptjs 而不是 bcrypt

bcrypt 是原生 C++ 模块,在 Docker Alpine 镜像中需要编译工具链。bcryptjs 纯 JS 实现,零依赖安装,性能差异在用户认证场景下可忽略(哈希本身就是耗时操作)。

为什么不需要 @types/ioredis

ioredis 自带 TypeScript 类型定义,无需额外安装。

配置 Nuxt

ts
// 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

text
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?

ts
'/admin/**': { ssr: false }

三个原因:

  1. 管理后台不需要 SEO:只有管理员使用,搜索引擎无需抓取
  2. 减少服务器压力:SSR 需要服务端渲染每个页面,管理后台页面复杂(表格、图表、表单),SSR 开销大
  3. 避免认证复杂度:SSR 时服务端需要验证用户身份才能渲染页面,增加复杂度。关闭 SSR 后,页面在客户端加载,前端中间件检查即可

为什么 API 默认关闭 CORS?

ts
'/api/admin/**': { cors: false }
  • API 服务只给 Flutter 客户端和管理后台使用,不需要允许第三方网站跨域调用
  • 关闭 CORS 是安全默认值,防止 CSRF 攻击
  • 如果未来需要开放公共 API,单独对特定路由开启

配置环境变量

env
# .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 字节,确保足够强度。

配置数据库

ts
// 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 环境中,模块级代码会在服务端执行

text
客户端导航                    服务端首次渲染
──────────                   ──────────────
不会执行 server/ 代码        会执行 server/database/db.ts
                             → postgres() 创建连接
                             → 如果连接池配置不当,可能耗尽连接

关键要点

  1. postgres.js 默认连接池大小是 10。在 SSR 场景下,每个请求复用连接池,不会每次请求创建新连接——这是正确的。

  2. 不要在组件中使用 dbserver/database/db.ts 虽然可以通过自动导入在组件中访问,但数据库操作只能在 server/ 目录中使用。在组件中应该用 useFetch 调用 API。

  3. 开发模式热更新不会断开连接postgres.js 的连接池是模块级别的,HMR 时不会重新创建,避免了连接泄漏。

  4. 生产环境建议配置连接池参数

ts
const client = postgres(config.dbUrl, {
  max: 20,              // 最大连接数
  idle_timeout: 20,     // 空闲连接超时(秒)
  connect_timeout: 10,  // 连接超时(秒)
})
ts
// 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 设计要点

  • serial vs integerserial 是自增主键,自动管理序列。普通 integer 不会自增
  • references(() => users.id):Drizzle 的外键引用语法,用函数返回确保表定义顺序无关
  • decimal 用于金额precision: 10, scale: 2 表示最多 10 位数字、2 位小数,即最大 99999999.99
  • defaultNow():数据库层面设置默认值,不依赖应用层——即使直接写 SQL 插入也有时间戳

生成数据库迁移

ts
// 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.tsprocess.env 而不是 useRuntimeConfig()

这个文件由 drizzle-kit CLI 直接运行,不在 Nuxt 上下文中,无法使用 useRuntimeConfig()。它读取 .env 中的 NUXT_DB_URL 是因为 dotenv 机制。

bash
# 生成迁移
npx drizzle-kit generate

# 执行迁移
npx drizzle-kit migrate

generate vs migrate 的区别

  • generate:对比 schema 和上一次迁移,生成新的 SQL 文件(在 drizzle/ 目录),不触达数据库
  • migrate:将未执行的迁移 SQL 应用到数据库,会修改数据库

最佳实践

开发时先 generate 检查 SQL 是否正确,确认后再 migrate

配置 ESLint

bash
npm install -D eslint @nuxt/eslint
ts
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxt/ui', '@nuxt/eslint'],
})

为什么用 @nuxt/eslint 而不是手动配置?

@nuxt/eslint 会自动配置 Nuxt 特定规则(如禁止在 server/ 中使用客户端 API),并生成 .eslintignore。手动配置容易遗漏。

验证项目

bash
npm run dev

访问 http://localhost:3000 确认项目正常运行。

常见启动问题

现象原因解决
NUXT_DB_URL is not set.env 文件不存在或变量名错误确认 .env 在项目根目录,变量名以 NUXT_ 开头
connect ECONNREFUSEDPostgreSQL 未启动docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=postgres postgres:16-alpine
Module not found: drizzle-orm依赖未安装npm install
页面空白无报错app/ 目录下无页面正常,刚初始化还没有页面

基于 Nuxt 4 官方文档整理编写