Post

TestpaperAuto 开发实录

项目背景

针对期末复习阶段往年试卷答案缺失、OCR 识别质量差的痛点,开发一站式试卷识别与答案生成解决方案

核心功能

  • 整合高精度 OCR 与 GPT 模型,实现试卷的智能识别与答案生成
  • 基于 Token 的计费系统,支持用户充值和额度管理
  • 完整的用户系统,包含注册、登录、找回密码等功能
  • 个人中心展示识别历史记录,支持答案的再次查看

目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
├─public
│  └─icon
│          square-pen-light.ico
│          square-pen.ico
│
└─src
    ├─app
    │  │  init.ts
    │  │  layout.tsx
    │  │  page.tsx
    │  │
    │  ├─(routes)
    │  │  │  layout.tsx
    │  │  │
    │  │  ├─(auth)
    │  │  │  │  layout.tsx
    │  │  │  │
    │  │  │  ├─forgetpwd
    │  │  │  │      page.tsx
    │  │  │  │
    │  │  │  ├─login
    │  │  │  │      page.tsx
    │  │  │  │
    │  │  │  └─register
    │  │  │          page.tsx
    │  │  │
    │  │  ├─(protected)
    │  │  │  └─dashboard
    │  │  │      ├─history
    │  │  │      │      page.tsx
    │  │  │      │
    │  │  │      └─profile
    │  │  │              page.tsx
    │  │  │
    │  │  └─(public)
    │  │      ├─help
    │  │      │      page.tsx
    │  │      │
    │  │      └─play
    │  │              page.tsx
    │  │
    │  └─api
    │      ├─answer
    │      │      route.ts
    │      │
    │      ├─auth
    │      │  ├─login
    │      │  │      route.ts
    │      │  │
    │      │  ├─logout
    │      │  │      route.ts
    │      │  │
    │      │  ├─register
    │      │  │      route.ts
    │      │  │
    │      │  └─validate
    │      │          route.ts
    │      │
    │      └─ocr
    │              route.ts
    │
    ├─components
    │  ├─layout
    │  │      Footer.tsx
    │  │      Navbar.tsx
    │  │
    │  └─ui
    │          Alert.tsx
    │          AuthInput.tsx
    │          Spinner.tsx
    │
    ├─context
    │      ThemeContext.js
    │
    ├─hooks
    │      use-alert.ts
    │      use-auth.ts
    │
    ├─lib
    │  ├─config
    │  │      auth.ts
    │  │      routes.ts
    │  │
    │  ├─constants
    │  │      config.ts
    │  │
    │  └─types
    │          file.ts
    │          IAlert.ts
    │          index.ts
    │          IUser.ts
    │          jwt-payload.ts
    │
    ├─server
    │  ├─db
    │  │  │  index.ts
    │  │  │
    │  │  ├─config
    │  │  │      connection.ts
    │  │  │
    │  │  └─models
    │  │          fileModel.ts
    │  │          index.ts
    │  │          recordModel.ts
    │  │          userModel.ts
    │  │
    │  ├─middleware
    │  │      api-handler.ts
    │  │      jwt.ts
    │  │      validate.ts
    │  │
    │  └─repositories
    │          users-repo.ts
    │
    ├─store
    │  │  index.ts
    │  │
    │  └─slices
    │          alert-slice.ts
    │          auth-slice.ts
    │
    └─styles
            globals.css

项目架构

以 Login 为例:

sequenceDiagram
    Login Page->>Custom Hook: useAuth()
    Custom Hook->>Zustand store: setLoading(true)
    Custom Hook->>API: fetch('/api/auth/login')
    API->>Repository: usersRepository.findByEmail(email)
    Repository->>MongoDB: User.findOne({email})
    MongoDB->>Repository: User
    Repository->>API: User
    API->>Custom Hook: response
    Custom Hook->>Zustand store: setUser(result.data.user)
    Zustand store->>Page: router.push(returnUrl)
    Custom Hook->>Zustand store: setLoading(false)

技术亮点

前端架构

  • 采用 Next.js App Router 构建全栈应用,实现页面零配置 SSR
  • 基于路由组规范实现 (auth)、(protected)、(public) 三层访问权限控制
  • 使用 zustand 管理全局状态,实现主题切换、用户认证等功能
  • 封装 useAlert、useAuth 等自定义 Hook,统一管理 API

Zustand,通过 create 创建 store,类型是 state & actions,e.g. user & setUser

为什么自定义 Hook?

在很多页面都可能会访问当前用户状态,在组件内部,我们的关注点是做什么,因此我们提取逻辑到自定义 Hook

验证模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export const RegisterSchema = z.object({
    email: z.string()
        .email('Invalid email address')
        .min(1, 'Email is required'),
    username: z.string()
        .min(1, 'Username must be at least 1 characters')
        .max(50, 'Username must be less than 50 characters')
        .regex(/^[a-zA-Z0-9_-]+$/, 'Username can only contain letters, numbers, underscores and hyphens'),
    password: z.string()
        .min(6, 'Password must be at least 6 characters')
        .max(100, 'Password must be less than 100 characters')
        .regex(
            /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d\w\W]{6,}$/,
            'Password must contain at least one uppercase letter, one lowercase letter, and one number'
        )
})

主题切换

参考:

问题和解决:

  • 使用 localStorage 保存主题,会因为 localStorage 在服务器端不可用,导致hydration 错误 要解决此问题,需要使用 Client 端和 Server 均可访问的数据存储。比如 cookie

    cookies 是一个异步函数,允许您在服务器组件中读取 HTTP 传入请求的 cookies,并在服务器操作或路由处理程序中读写出站请求的 cookies。

  • 通过 useEffect,会因为默认主题和本地主题不同而发生闪屏 全局入口 layout.tsx,获取 cookie 的值,然后再进行渲染

    1
    2
    
    const cookieStore = await cookies();
    const theme = cookieStore.get('theme')?.value ?? 'dark';
    

后端设计

  • 实现基于 JWT 的双 Token(access/refresh) 认证机制,提升安全性
  • 使用 zod 进行请求参数校验,确保数据完整性
  • 设计 API 中间件链,统一处理异常、认证和参数验证
  • 采用 MongoDB 存储用户数据和识别记录,优化数据库连接复用

介绍双 Token 机制

先介绍 JWT。JSON Web Token,是一个标准,将信息以 JSON 对象的形式安全传输。可以使用密钥对 JWT 进行签名

应用场景:单点登录(Single sign-on,SSO),开销小,能轻松跨域(cookie 可以跨域,sessionStorage 和 localStorage 受到同源策略限制)

单点登录的优点:

  • 不存储用户密码,降低访问第三方网站的风险
  • 减少相同身份重复输入的时间
  • 降低 IT 成本

JWT 3 个部分

header,payload,signature,分别经过 Base64Url 编码形成 JWT 对应的部分

1
2
3
4
{
  "alg": "HS256",
  "typ": "JWT"
}
1
2
3
4
5
{
  "ISSUER": "TestpaperAuto",
  "AUDIENCE": "College student",
  "exp": 2024-10-31T05:22:34.234Z
}
1
2
3
4
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

签名用于验证消息在此过程中未更改,并且,对于使用私钥签名的令牌,它还可以验证 JWT 的发送者是否是它所说的人

如果令牌在 Authorization 标头中发送,则跨域资源共享 (CORS) 不会成为问题,因为它不使用 Cookie

再拓展一下 cookie,sessionStorage 和 localStorage 的区别:

 cookiesessionStoragelocalStorage
作用域通过设置 Domain 和 Path须同一窗口/标签页同源
大小4KB5MB5MB
服务器通信发送给服务器仅在浏览器存储仅在浏览器存储
数据有效时间设置失效时间,默认 session仅在当前的浏览器窗口关闭前有效始终有效,除非删除缓存或者手动设置为空
设置方式服务端写入浏览器写入浏览器写入
安全性不安全  
场景需要发送到服务器的数据如 SessionID/token
- 广告追踪时记录用户的广告点击信息和来源渠道
- 新闻网站记录用户已读文章,防止重复推荐
临时会话数据
- 缓存当前会话的未发送消息草稿
- 多步骤表单保存用户在每个步骤中填写的数据
本地永久性数据
- 用户个性化配置,如主题颜色
- 文章编辑器定期自动保存用户正在编辑的文章内容

再聊聊跨域

同源:主机、协议、端口相同

同源策略限制了一些跨域访问:

  1. Ajax 请求限制

    Async JavaScript and xml,不允许使用 XMLHttpRequest 或 Fetch API 发起跨域请求,不能读取跨域的响应数据

  2. DOM 操作限制 不能获取跨域 iframe 中的 DOM,不能操作跨域窗口的 DOM 元素,不能访问跨域窗口的 JavaScript 对象

  3. 访问本地存储限制

    cookie,sessionStorage,localStorage 都不能访问

发起跨域 Ajax 请求时的具体过程:

简单请求:

1
2
3
4
5
6
7
8
9
// 请求方法是以下之一:
- GET
- HEAD 
- POST

// Content-Type 是以下之一:
- text/plain
- multipart/form-data
- application/x-www-form-urlencoded

简单请求的处理流程:

1
2
3
4
5
6
7
8
9
10
11
12
// 1. 发起请求
fetch('https://api.example.com/data', {
  method: 'GET'
});

// 2. 浏览器自动在请求头中添加 Origin
Origin: https://example.com

// 3. 服务器响应,需要包含:
Access-Control-Allow-Origin: https://example.com  
// 或 
Access-Control-Allow-Origin: *

预检请求的情况(Preflight Request) 不满足简单请求条件时(如 PUT、DELETE 方法,或包含自定义请求头),会先发送预检请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 1. 首先发送 OPTIONS 预检请求
OPTIONS /data HTTP/1.1
Origin: https://example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header

// 2. 服务器需要返回许可:
Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Methods: PUT, POST, GET
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Max-Age: 86400  // 预检请求缓存时间

// 3. 预检通过后,才发送实际请求
PUT /data HTTP/1.1
Origin: https://example.com
X-Custom-Header: value

如果服务器响应未包含正确的 CORS 头:

1
2
3
4
5
// 浏览器会阻止请求,控制台报错:
Access to fetch at 'https://api.example.com/data' from origin 
'https://example.com' has been blocked by CORS policy: 
No 'Access-Control-Allow-Origin' header is present on the 
requested resource.

携带身份凭证的请求:

1
2
3
4
5
6
7
8
9
// 发起请求时设置:
fetch('https://api.example.com/data', {
  credentials: 'include'  // 携带 cookies 等凭证
});

// 服务器必须设置:
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: https://example.com  
// 注意: 这里不能用 * 通配符

常见的解决 CORS 的方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 1. 服务端配置 Access-Control-Allow-Origin
app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', '*');
  res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE');
  res.header('Access-Control-Allow-Headers', 'Content-Type');
  next();
});

// 2. 使用代理服务器转发请求
// nginx 配置示例:
location /api {
  proxy_pass http://api.example.com;
}

// 3. JSONP(仅支持 GET 请求)
function jsonp(url, callback) {
  const script = document.createElement('script');
  script.src = `${url}?callback=${callback}`;
  document.body.appendChild(script);
}

双 Token 机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// AccessToken - 访问令牌
{
  "type": "access",
  "userId": "12345",
  "permissions": ["read", "write"],
  "exp": 1800  // 较短的过期时间,如 30 分钟
}

// RefreshToken - 刷新令牌
{
  "type": "refresh", 
  "userId": "12345",
  "exp": 604800  // 较长的过期时间,如 7 天
}

登录后生成 accessToken 和 refreshToken,在响应中设置对应的 cookie。每次访问 app 会获取和验证 accessToken,并返回用户非敏感信息; 如果 accessToken 失效,则获取和验证 refreshToken,通过 cookie 设置新的 accessToken,并返回响应。登出时清除 accessToken 和 refreshToken

使用 zod 进行请求参数校验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export const RegisterSchema = z.object({
    email: z.string()
        .email('Invalid email address')
        .min(1, 'Email is required'),
    username: z.string()
        .min(1, 'Username must be at least 1 characters')
        .max(50, 'Username must be less than 50 characters')
        .regex(/^[a-zA-Z0-9_-]+$/, 'Username can only contain letters, numbers, underscores and hyphens'),
    password: z.string()
        .min(6, 'Password must be at least 6 characters')
        .max(100, 'Password must be less than 100 characters')
        .regex(
            /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d\w\W]{6,}$/,
            'Password must contain at least one uppercase letter, one lowercase letter, and one number'
        )
})

API 中间件链

统一处理异常、认证和参数验证

为什么选择 MongoDB?

  1. 数据结构需求
    • 数据模式经常变化,可以随时添加新字段,不需要改表结构
    • 存在非结构化或半结构化数据
    • 快速开发迭代
  2. 性能需求
    • 需要高并发读写
    • 查询模式主要是文档级操作
  3. 开发效率
    • MongoDB 与 JavaScript/Node.js 天然契合

优化数据库连接复用

根组件加载后执行数据库初始化:

1
2
3
4
5
6
7
8
9
10
11
12
export async function init() {
    try {
        await dbConnect();
        console.log('Database initialized successfully');
    } catch (error) {
        console.error('Failed to initialize database:', error);
        // 生产环境出错直接退出应用
        if (process.env.NODE_ENV === 'production') {
            process.exit(1);
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
// Connection.ts
import mongoose from 'mongoose';

declare global {
    let mongoose: {
        conn: typeof mongoose | null;
        promise: Promise<typeof mongoose> | null;
    };
}

// 在全局对象上初始化mongoose属性
if (!global.mongoose) {
    global.mongoose = {
        conn: null,
        promise: null
    };
}

const MONGODB_URI = process.env.MONGODB_URI!;

const MAX_POOL_SIZE = 10;

async function dbConnect() {
    // 如果已经存在连接,直接返回
    if (global.mongoose.conn) {
        console.log('Using existing connection');
        return global.mongoose.conn;
    }

    // 如果正在建立连接,返回promise
    if (global.mongoose.promise) {
        console.log('Using existing connection promise');
        return global.mongoose.promise;
    }

    // 创建新连接
    global.mongoose.promise = mongoose.connect(MONGODB_URI, {
        maxPoolSize: MAX_POOL_SIZE,
        minPoolSize: 5,
        connectTimeoutMS: 10000,
        socketTimeoutMS: 45000,
    });

    try {
        global.mongoose.conn = await global.mongoose.promise;

        // 监听连接事件
        mongoose.connection.on('connected', () => {
            console.log('MongoDB connected');
        });

        mongoose.connection.on('error', (err) => {
            console.log('MongoDB connection error:', err);
            global.mongoose.conn = null;
            global.mongoose.promise = null;
        });

        mongoose.connection.on('disconnected', () => {
            console.log('MongoDB disconnected');
            global.mongoose.conn = null;
            global.mongoose.promise = null;
        });

        // 处理进程退出
        const cleanup = async () => {
            try {
                await mongoose.connection.close();
                global.mongoose.conn = null;
                global.mongoose.promise = null;
                process.exit(0);
            } catch (err) {
                console.error('Error during cleanup:', err);
                process.exit(1);
            }
        };

        process.on('SIGINT', cleanup);
        process.on('SIGTERM', cleanup);

        console.log('New database connection established');
        return global.mongoose.conn;

    } catch (error) {
        global.mongoose.conn = null;
        global.mongoose.promise = null;
        console.error('MongoDB connection error:', error);
        throw error;
    }
}

// 导出清理函数供外部使用
export const closeConnection = async () => {
    if (global.mongoose.conn) {
        await mongoose.connection.close();
        global.mongoose.conn = null;
        global.mongoose.promise = null;
    }
};

export default dbConnect;
This post is licensed under CC BY 4.0 by the author.