# 重构技术选型(Vercel + Supabase 版 · AI 原生 · 可迁移) > 目标:在**尽量不产生费用(免费额度内)**的前提下,用 AI 最擅长的技术栈(Next.js + Supabase + OpenAI 兼容 API),快速重写现有系统,并通过清晰的“抽象层”设计,让未来迁移到自建 Postgres / 其他存储 / 自建 new-api 时改动最小。 --- ## 1. 总体目标与约束 - **零付费优先**:全部依赖选用有免费额度的托管服务(Vercel、Supabase、GitHub 等),开发和个人 Demo 阶段正常使用不触顶。 - **快速开发**:本地 `pnpm dev` + Vercel 自动部署,减少运维与环境折腾;高比例样板代码交给 AI 生成。 - **高可移植性**: - 数据层:使用 Drizzle ORM 管理 schema/迁移,不绑定 Supabase RPC。 - 鉴权层:统一 `getCurrentUser()`/`requireRole()`,底层可以是 Supabase Auth,也可以换 Lucia/Auth.js。 - 文件层:通过 `StorageAdapter` 抽象,底层可以是 Supabase Storage、R2、S3、MinIO 等。 - AI 层:统一 OpenAI 兼容协议 + `AI_BASE_URL`,底层可以是 OpenAI、你自建的 new-api、Ollama 等。 - **界面简洁好看**:Tailwind + shadcn/ui + Radix,统一视觉与交互;优先桌面 + 常见移动端尺寸。 --- ## 2. 架构总览 - **前端 + BFF 单体**: - Next.js 15 App Router(React + TypeScript) - Node runtime(默认不强制 edge),方便使用常见 Node 包 - Route Handlers (`app/api/**/route.ts`) 作为 BFF 层,承载 REST/JSON API、上传签名、AI 代理等 - **数据与存储**: - Supabase Postgres(免费层),作为主业务数据库 - Drizzle ORM + drizzle-kit 管理 schema/迁移/类型 - Supabase Storage 作为对象存储(课程资源、电子书、图片等) - **鉴权与权限**: - Supabase Auth:邮箱/密码、Magic Link 或学号登录 - 后端统一从请求中解析会话,封装为 `getCurrentUser()`,并叠加角色/专业信息 - RBAC:`manage::` 权限码,后端 `requireRole/requirePerm`,前端按钮级 `v-hasPermi` 等价逻辑(React 实现) - **AI 能力**: - 使用 OpenAI 兼容协议(你已有的免费端点 + key) - 服务端统一 `aiClient`,支持流式输出和简单工具调用 - 通过 `AI_BASE_URL` & `AI_API_KEY` 控制——未来生产可切换到自建 new-api - **CI/CD 与观测**: - Vercel:GitHub 仓库一键接入,push 即构建部署,PR 自动预览 - GitHub Actions:可选,仅用来跑 `lint/test/typecheck` - 观测:早期用 `console` + Vercel 日志;如有需要再接入 Sentry/Axiom(都有免费层) --- ## 3. 技术栈清单 ### 3.1 前端 & BFF - Next.js 15(App Router)+ React + TypeScript - 节点运行时:Node(仅在个别接口需要时使用 Edge runtime) - UI:Tailwind CSS + shadcn/ui + Radix + lucide-react 图标 - 数据获取:@tanstack/react-query - 表单与校验:react-hook-form + zod - 状态管理:优先局部 state + React Query,不引入全局复杂方案 ### 3.2 数据层 - 数据库:Supabase Postgres(免费层) - ORM:drizzle-orm + drizzle-kit - 连接方式: - Drizzle 通过 Postgres 连接串(`DATABASE_URL`)直连 Supabase PG - 不使用 Supabase 特有 RPC,SQL 尽量保持标准,方便迁移到 Neon/Render/自建 PG ### 3.3 鉴权层 - Supabase Auth: - 前端使用 `@supabase/supabase-js`(只用 anon key) - 后端可在需要时使用 service role key(谨慎使用,仅限后台管理/迁移脚本) - 封装: - `lib/auth/server.ts`:解析请求中的 `access_token`/cookie,返回 `currentUser` - `lib/auth/permissions.ts`:实现 `requireRole/requirePerm` 等函数 - 未来切换 Lucia/Auth.js 时,仅替换以上封装,业务代码不变 ### 3.4 文件存储层 - 起步:Supabase Storage - 资源桶:课程资源、电子书、图片等按模块分桶或分前缀管理 - 上传:前端直传(预签名 URL)或经 BFF 中转(小文件) - 抽象: - `StorageAdapter` 接口(见 §6.3) - 将来切换到 Cloudflare R2/S3/MinIO,只需实现新的 Adapter ### 3.5 AI 层 - 协议:OpenAI 兼容(/v1/chat/completions) - SDK: - 推荐 `ai` + `@ai-sdk/openai`(Vercel AI SDK)简化流式与工具调用 - 或直接使用 `openai` 官方/兼容 SDK - 配置: - `AI_BASE_URL` 指向你的 new-api - `AI_API_KEY` 为你的测试 key - `AI_MODEL` 为默认模型名,可在不同模块按需覆盖 ### 3.6 CI/CD 与工具链 - 包管理:pnpm(workspace 支持) - 代码规范:Biome(lint + format) - Git hooks:Husky + lint-staged - 测试:Vitest + @testing-library/react + Playwright(后期按需补) - CI/CD:Vercel + GitHub Actions(可选) --- ## 4. 仓库结构(建议) ```txt / # 仓库根 apps/web/ # Next.js 应用(SSR + BFF + AI) app/ # App Router:页面 & Route Handlers (auth)/ # 登录/注册/重置密码 manage/ # 各业务模块页面 api/ # REST/JSON API + AI 接口 components/ # 通用 UI 组件(shadcn 扩展) features/ # 按业务模块拆目录(resource/notice/room/...) lib/ # db/auth/storage/ai/config/log 等适配层 styles/ # Tailwind 样式 tests/ # 前端/集成测试 packages/db/ # Drizzle schema + migrations + seed + DB adapter packages/config/ # tsconfig/biome/vitest 等共享配置 ``` --- ## 5. 环境变量约定 ```env # Database (Supabase Postgres) DATABASE_URL=postgresql://user:password@host:5432/db_name # Supabase (Auth + Storage) NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=... # 仅前端使用 SUPABASE_SERVICE_ROLE_KEY=... # 后端脚本/管理用途(谨慎使用) # AI (OpenAI 兼容) AI_BASE_URL=https://your-new-api/v1 # 开发阶段指向你的免费端点 AI_API_KEY=sk-xxx AI_MODEL=gpt-4o-mini # 其他(可选) LOG_LEVEL=info ``` > 约定:业务代码不直接访问 `process.env`,统一通过 `lib/config.ts` 读取并校验,便于集中管理与迁移。 --- ## 6. 抽象层与可迁移设计(重点强化) ### 6.1 数据访问层(DB + Repository) - 目标: - 所有业务代码通过“仓储/Service 方法 + Drizzle”访问数据,而不是直接写 SQL 字符串或调用 Supabase RPC。 - 将数据库从 Supabase 换到 Neon/Render/自建 PG 时,绝大多数业务逻辑不需要修改。 - Drizzle 连接示例: ```ts // packages/db/src/client.ts import { drizzle } from 'drizzle-orm/node-postgres'; import { Pool } from 'pg'; const pool = new Pool({ connectionString: process.env.DATABASE_URL }); export const db = drizzle(pool); ``` - 业务 Repository 示例: ```ts // apps/web/lib/repositories/reservationRepo.ts import { db } from '@book-mis/db/client'; import { reservation } from '@book-mis/db/schema'; import { eq } from 'drizzle-orm'; export async function listReservationsByUser(userId: string) { return db.select().from(reservation).where(eq(reservation.userId, userId)); } ``` > 将来迁移到自建 PG:只需修改 `DATABASE_URL` 与 `db` 连接配置,`reservationRepo` 不动。 ### 6.2 鉴权与权限层 - 目标: - 前端只调用 `useAuth()`,后端只调用 `getCurrentUser()`/`requireRole()`,不直接依赖 Supabase SDK。 - 将来从 Supabase Auth 切到 Lucia/Auth.js 时,只需要替换适配实现。 - 抽象: ```ts // apps/web/lib/auth/types.ts export type AppUser = { id: string; email: string; roles: string[]; // ['student', 'librarian', ...] majorId?: string | null; }; export interface AuthProvider { getCurrentUser(req: Request): Promise; requireUser(req: Request): Promise; // 无则抛 401 requirePerm(req: Request, perm: string): Promise; // 无则抛 403 } ``` - Supabase 实现只是 `AuthProvider` 的一个版本;将来切 Lucia,则提供 Lucia 版实现即可。 ### 6.3 文件存储层(StorageAdapter) - 目标: - 业务代码只关心“上传/获取 url/删除”,不关心具体云厂商。 - 从 Supabase Storage 切到 R2/S3/MinIO,只需更换一个实现类。 - 抽象接口: ```ts // apps/web/lib/storage/types.ts export interface StorageAdapter { upload(file: Blob | File, key?: string): Promise<{ key: string; url: string }>; getUrl(key: string): Promise; remove(key: string): Promise; } ``` - Supabase 实现示例: ```ts // apps/web/lib/storage/supabaseStorage.ts import { createClient } from '@supabase/supabase-js'; import type { StorageAdapter } from './types'; const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY! // 或 anon key + RLS 控制 ); export const supabaseStorage: StorageAdapter = { async upload(file, key) { const path = key ?? `${Date.now()}-${crypto.randomUUID()}`; const { error } = await supabase.storage.from('resources').upload(path, file); if (error) throw error; const { data } = supabase.storage.from('resources').getPublicUrl(path); return { key: path, url: data.publicUrl }; }, async getUrl(key) { const { data } = supabase.storage.from('resources').getPublicUrl(key); return data.publicUrl; }, async remove(key) { await supabase.storage.from('resources').remove([key]); }, }; ``` ### 6.4 AI 层(OpenAI 兼容 + 可换后端) - 目标: - 所有 AI 调用通过统一 `aiClient`,仅依赖 OpenAI 兼容协议。 - 将来切换到自建 new-api / 本地 Ollama,只需要修改环境变量或底层实现。 - 抽象与实现: ```ts // apps/web/lib/aiClient.ts import { createOpenAI } from '@ai-sdk/openai'; import { streamText } from 'ai'; export const AI_MODEL = process.env.AI_MODEL || 'gpt-4o-mini'; const openai = createOpenAI({ baseURL: process.env.AI_BASE_URL, apiKey: process.env.AI_API_KEY, }); export async function streamChat(messages: any[]) { const result = await streamText({ model: openai(AI_MODEL), messages, }); return result.toAIStreamResponse(); } ``` - API 路由使用: ```ts // apps/web/app/api/ai/chat/route.ts import { streamChat } from '@/lib/aiClient'; export async function POST(req: Request) { const { messages } = await req.json(); return streamChat(messages); } ``` ### 6.5 配置与环境(Config) - 目标: - 集中管理所有配置,避免散落 `process.env` 调用;便于不同环境切换。 - 示例: ```ts // apps/web/lib/config.ts import { z } from 'zod'; const envSchema = z.object({ DATABASE_URL: z.string().url(), NEXT_PUBLIC_SUPABASE_URL: z.string().url(), NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string(), SUPABASE_SERVICE_ROLE_KEY: z.string().optional(), AI_BASE_URL: z.string().url().optional(), AI_API_KEY: z.string().optional(), AI_MODEL: z.string().optional(), }); export const env = envSchema.parse(process.env); ``` ### 6.6 日志与监控层 - 目标: - 通过 Logger 抽象封装日志,需接入 Sentry/Axiom 时无痛切换。 - 简单接口: ```ts // apps/web/lib/log.ts export interface Logger { info(msg: string, meta?: any): void; error(msg: string, meta?: any): void; } export const logger: Logger = { info: (msg, meta) => console.log(msg, meta), error: (msg, meta) => console.error(msg, meta), }; ``` > 将来接入 Sentry 时,只需在这里替换实现即可。 ### 6.7 审计日志层(AuditLogger) - 目标: - 所有关键业务操作(创建/更新/删除/审核/状态变更)都通过审计日志抽象记录。 - 起步阶段存储在主数据库 `audit_logs` 表,后期可迁移到专业审计服务(如 Axiom、Logtail、自建 ELK)。 - 支持 30 天归档策略,自动清理或迁移到冷存储。 - 抽象接口: ```ts // lib/audit/types.ts export type AuditLogParams = { userId?: string; // 操作人 ID(可选,系统自动操作时为空) username?: string; // 操作人用户名(冗余,便于查询) module: string; // 模块(notice, facility, resource, etc.) action: string; // 操作(create, update, delete, approve, reject) resourceType?: string; // 资源类型(notice, booking, etc.) resourceId?: string; // 资源 ID method?: string; // HTTP 方法(POST, PUT, DELETE) path?: string; // 请求路径 ip?: string; // IP 地址 userAgent?: string; // User-Agent status: 'success' | 'failed'; // 操作结果 statusCode?: number; // HTTP 状态码 errorMessage?: string; // 错误信息(失败时) oldValue?: Record; // 变更前的值(JSON) newValue?: Record; // 变更后的值(JSON) }; export type AuditLogFilters = { userId?: string; module?: string; action?: string; status?: 'success' | 'failed'; startDate?: Date; endDate?: Date; limit?: number; offset?: number; }; export interface AuditLogger { /** 记录审计日志 */ log(params: AuditLogParams): Promise; /** 查询审计日志 */ query(filters: AuditLogFilters): Promise; /** 归档旧日志(30天前) */ archive(beforeDate: Date): Promise; } export type AuditLog = { id: string; userId?: string; username?: string; module: string; action: string; resourceType?: string; resourceId?: string; method?: string; path?: string; ip?: string; userAgent?: string; status: string; statusCode?: number; errorMessage?: string; oldValue?: Record; newValue?: Record; createdAt: Date; archivedAt?: Date; }; ``` - Supabase / Postgres 实现: ```ts // lib/audit/postgresAuditLogger.ts import { db } from '@/packages/db'; import { auditLogs } from '@/packages/db/schema'; import { and, eq, gte, lte, desc } from 'drizzle-orm'; import type { AuditLogger, AuditLogParams, AuditLogFilters } from './types'; export const postgresAuditLogger: AuditLogger = { async log(params) { await db.insert(auditLogs).values({ userId: params.userId, username: params.username, module: params.module, action: params.action, resourceType: params.resourceType, resourceId: params.resourceId, method: params.method, path: params.path, ip: params.ip, userAgent: params.userAgent, status: params.status, statusCode: params.statusCode, errorMessage: params.errorMessage, oldValue: params.oldValue, newValue: params.newValue, createdAt: new Date(), }); }, async query(filters) { const conditions = []; if (filters.userId) conditions.push(eq(auditLogs.userId, filters.userId)); if (filters.module) conditions.push(eq(auditLogs.module, filters.module)); if (filters.action) conditions.push(eq(auditLogs.action, filters.action)); if (filters.status) conditions.push(eq(auditLogs.status, filters.status)); if (filters.startDate) conditions.push(gte(auditLogs.createdAt, filters.startDate)); if (filters.endDate) conditions.push(lte(auditLogs.createdAt, filters.endDate)); return db .select() .from(auditLogs) .where(and(...conditions)) .orderBy(desc(auditLogs.createdAt)) .limit(filters.limit ?? 100) .offset(filters.offset ?? 0); }, async archive(beforeDate) { const result = await db .update(auditLogs) .set({ archivedAt: new Date() }) .where( and( lte(auditLogs.createdAt, beforeDate), eq(auditLogs.archivedAt, null) ) ); return result.rowsAffected ?? 0; }, }; ``` - 在业务代码中使用: ```ts // lib/services/noticeService.ts import { auditLogger } from '@/lib/audit'; import { getCurrentUser } from '@/lib/auth/server'; export async function createNotice(req: Request, data: CreateNoticeInput) { const user = await getCurrentUser(req); try { // 创建公告逻辑 const notice = await noticeRepo.create(data); // 记录审计日志(成功) await auditLogger.log({ userId: user.id, username: user.username, module: 'notice', action: 'create', resourceType: 'notice', resourceId: notice.id, status: 'success', newValue: notice, }); return notice; } catch (error) { // 记录审计日志(失败) await auditLogger.log({ userId: user.id, username: user.username, module: 'notice', action: 'create', status: 'failed', errorMessage: error.message, }); throw error; } } ``` - 定期归档任务(Cron Job): ```ts // lib/cron/archiveAuditLogs.ts import { auditLogger } from '@/lib/audit'; export async function archiveOldAuditLogs() { const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); const archived = await auditLogger.archive(thirtyDaysAgo); console.log(`Archived ${archived} audit logs older than 30 days`); return archived; } ``` > 将来迁移到 Axiom/Logtail:只需实现新的 `axiomAuditLogger`,替换 `lib/audit/index.ts` 中的导出即可,业务代码不变。 ### 6.8 外部服务 & HTTP 客户端 - 目标: - 不在业务代码中直接 `fetch('https://xx')`;所有外部调用通过各自 Client 模块。 - 示例: ```ts // apps/web/lib/http/externalBookApi.ts export async function searchExternalBooks(keyword: string) { const res = await fetch(`https://api.example.com/books?q=${encodeURIComponent(keyword)}`); if (!res.ok) throw new Error('external api failed'); return res.json(); } ``` > 将来更换外部服务或加签名/限流,只修改该模块。 ### 6.9 领域服务层(Service) - 目标: - Controller/Route Handler 不直接写业务细节,而是调用 Service - 便于单元测试与 AI 辅助重构 - 示例: ```ts // apps/web/lib/services/reservationService.ts import { listReservationsByUser } from '@/lib/repositories/reservationRepo'; export async function getUserReservations(userId: string) { return listReservationsByUser(userId); } ``` ### 6.10 限流 & 幂等(可选抽象) - 可增加 `RateLimiter` 接口,起步用内存/数据库计数,后期可以替换为 Redis/Upstash。 --- ## 7. 需求模块与架构映射(MVP 粒度) - 课程资源分享: - 表:tb_major / tb_course / tb_course_resource(迁移到 Postgres schema) - 上传:Supabase Storage;状态流转 0 待审 / 1 通过 / 2 驳回 / 3 下架 - 榜单:按下载/点赞统计,可先用查询统计,后期再引入缓存 - 功能房预约: - 表:room / room_slot / reservation - 并发:唯一约束 (room_id, slot_id) + 事务,防止重复预约 - 状态:0 待审 / 1 通过 / 2 驳回 / 3 取消 - 通知公告: - 表:notice(支持置顶、定时发布) - 权限:学生工作者发布/管理自己的公告,管理员可管理全部 - 数字图书馆: - 表:book / ebook_file / borrow_log - 检索:先用简单字段检索,后期可接 full-text search - 失物招领: - 表:lost_found - 图片直传 Storage,简单审核与自动下架策略 --- ## 8. 开发与部署流程(简版) 1. 初始化 monorepo 与 Next.js 应用,接入 Tailwind + shadcn/ui 2. 配置 Supabase 项目与 `DATABASE_URL`;用 Drizzle 建立基础表(users/roles/**) 3. 接入 Supabase Auth,完成登录页与 `getCurrentUser()` 封装 4. 搭建 StorageAdapter(Supabase 版),实现最小上传/获取 URL 5. 搭建 aiClient 与 `/api/ai/chat` 路由,使用你的免费 OpenAI 兼容端点 6. 首个业务模块:功能房预约(列表/新建/审批/并发约束) 7. 接入 Vercel,配置环境变量,push 即部署 --- ## 9. 风险与缓解 - **免费额度超限风险**: - 尽量使用开发/个人用法,避免高并发压测 - 对 AI 接口做简单的 QPS/日额度限制 - **Supabase 锁定风险**: - 数据访问全部通过 Drizzle,不写 Supabase RPC - 鉴权与存储通过 Adapter 封装,可替换实现 - **AI 成本与隐私**: - 开发测试用你的免费端点和 key - 提示词避免敏感信息;必要时对内容脱敏 --- ## 10. 迁移蓝图(Supabase → 自建 Postgres / 其他云) - 数据库: 1. 使用 Drizzle schema 生成迁移 SQL,在新 PG 上执行 2. 导出 Supabase 数据(dump)导入新库 3. 修改 `DATABASE_URL` 指向新库;观察业务是否正常 - 鉴权: - 逐步引入 Lucia/Auth.js,在新库中建 `user/session/key` 表 - 将 `getCurrentUser()` 切换为 Lucia 版本 - 存储: - 实现 R2/S3/MinIO 版 `StorageAdapter` - 对历史文件跑批迁移(Supabase → 新存储),回填 key - AI: - 将 `AI_BASE_URL` 切到自建 new-api(保持 OpenAI 兼容协议即可) - 可按模块拆分不同模型/后端,提高弹性 --- **结论**: - 最终方案:Next.js 15 + Vercel + Supabase(Postgres + Auth + Storage)+ OpenAI 兼容 AI(你的 new-api)。 - 通过在**数据/鉴权/文件/AI/配置/日志**几个层面引入清晰的 Adapter/Service 抽象,实现“现在用托管服务快速 MVP,将来随时可以换供应商”的目标。