一个OJ系统的诞生(四)模型层Problem/TestCase/User数据结构设计

本文是一个OJ系统的但是的第四篇。前面我们讲了工具层Config+Logger和数据库层Connection Pool,现在是模型层Model Layer。这一层只有三个文件,每一层只有一个struct,一共47行代码。这47行代码定义了整个系统的"形状"

1:模块位置

bash 复制代码
project-cpp-oj-vibecoding/
├── src/
│   ├── main.cc
│   ├── server/
│   ├── handler/
│   ├── service/
│   ├── model/                     ← ★ 今天的主角:模型层 ★
│   │   ├── user.hpp               ← 用户模型(11 行)
│   │   ├── problem.hpp            ← 题目模型(24 行)
│   │   └── test_case.hpp          ← 测试用例模型(12 行)
│   ├── db/
│   └── utils/

1:什么是模型层

模型层是整个系统里最简单的一层------它只有 3 个 struct,没有任何函数,没有任何逻辑,纯粹就是"打包数据"。

可以把模型层的struct想象成一个快递盒

模型 相当于 里面装的
User 用户档案袋 用户 ID、用户名、密码、角色
Problem 题目文件夹 题目 ID、标题、描述、难度、测试用例
TestCase 测试用例卡 输入数据、期望输出、是否是示例

2:模型层的作用

模型层是连接数据库(原始SQL)和业务逻辑(Service)的桥梁

bash 复制代码
数据库(MySQL)     模型层(C++ struct)   业务逻辑(Service)
                                    
users 表里的行 ──→  User 结构体 ──→  注册时处理用户数据
problems 表里的行 ──→  Problem 结构体 ──→  展示题目列表
testcases 表里的行 ──→  TestCase 结构体 ──→  判题时比对输出

2:项目数据全景图

在深入代码之前,先看看这 3 个模型在整个 OJ 系统中是怎么流转的:

bash 复制代码
                          User
                          ┌──────┐
  注册 ──────────────→    │  id  │  ←─ 提交代码
  登录 ──────────────→    │ name │      ↓
  查询个人信息 ←───────    │ role │  Submission (提交记录)
                          └──────┘    ┌──────────┐
                                      │ user_id  │
                                      │ problem_id│
         Problem                      │ code     │
         ┌──────────────┐             │ status   │
  题目列表 →  │  id          │             │ AC/WA/TLE│
  题目详情 →  │  title       │             └──────────┘
             │  description │
             │  difficulty  │                  ↑
             │  sample_cases├──── TestCase ─────┘
             │  pass_rate   │    ┌──────────┐
             └──────────────┘    │  input   │
                                │ expected│
                                │ is_sample│
                                └──────────┘
  • 一道题**(Problem)包含多个测试用例(TestCase)------ `Problem` 里有个 `vector<TestCase> sample_cases`
  • 一个用户(User)提交**多次代码**------ `submissions` 表里存了 `user_id` 和 `problem_id`(外键关联)
  • 通过率(pass_rate)是 `accepted / total_submissions` 算出来的

3:test_case.hpp------测试用例模型

先从最小的模型开始看。TestCase是所有模型里面最简单的,他只有5个字段

cpp 复制代码
// ============================================================
// 文件名: test_case.hpp
// 作用: 定义测试用例数据结构
// 测试用例 = 给用户程序的一组"输入" + 期望的"输出"
//
// 比如一道题"两数相加":
//   输入(input): "1 2\n"
//   期望输出(expected): "3\n"
//
// 判题器会:
//   1. 把 input 作为 stdin 喂给用户程序
//   2. 捕获用户程序的 stdout 输出
//   3. 比对 stdout 和 expected 是否一致
//   一致 → AC(通过),不一致 → WA(答案错误)
// ============================================================

#pragma once

#include <string>

struct TestCase {
    int id = 0;              // 测试用例 ID(主键,自动增长)
                              // 由 MySQL AUTO_INCREMENT 生成
                              // 唯一标识这个测试用例

    int problem_id = 0;      // 所属题目的 ID
                              // 关联到 problems 表的 id
                              // 表示"这个测试用例属于哪道题"

    std::string input;       // 测试用例的输入数据
                              // 就是给用户程序的"标准输入"(stdin)
                              // 例子:对于"两数相加"题,可能是 "1 2\n"
                              // 对于"字符串反转"题,可能是 "hello\n"

    std::string expected;    // 期望输出
                              // 用户程序应该输出的"标准输出"(stdout)
                              // 判题时:拿用户程序的实际输出和这个比对
                              // 完全一致 → 该用例通过

    bool is_sample = false;  // 是否是"示例用例"
                              // true  → 示例用例(展示给用户看,帮助理解题目)
                              // false → 隐藏用例(真正判题用的,用户看不到)
                              //
                              // 为什么分两种?
                              // 示例用例:告诉用户"输入什么格式、输出什么格式"
                              // 隐藏用例:真正的判题依据,防止用户"针对用例写代码"
                              // 比如示例用例是 1+2=3,隐藏用例是 100+200=300

    int sort_order = 0;      // 排序顺序
                              // 测试用例的执行顺序
                              // 数字越小越先执行
                              // 判题时按 sort_order 从小到大依次运行
};

为什么TestCase是最基本的积木

因为题目(Problem)是由测试用例组成的,一道题肯包含:

cpp 复制代码
题目:两数相加
├── 示例用例 1(is_sample = true)
│   ├── input: "1 2\n"
│   └── expected: "3\n"
├── 隐藏用例 1(is_sample = false)
│   ├── input: "100 200\n"
│   └── expected: "300\n"
├── 隐藏用例 2(is_sample = false)
│   ├── input: "-5 10\n"
│   └── expected: "5\n"
└── 隐藏用例 3(is_sample = false)
    ├── input: "0 0\n"
    └── expected: "0\n"

示例用例给用户看,隐藏用例用来真正判题。这就是 `is_sample` 字段的意义。

4:user.hpp------用户模型

cpp 复制代码
// ============================================================
// 文件名: user.hpp
// 作用: 定义用户数据结构
// 对应 MySQL 数据库中的 users 表
// ============================================================

#pragma once

#include <string>

struct User {
    int id = 0;              // 用户 ID(主键,自动增长)
                              // 每个注册用户获得一个唯一的 ID
                              // 比如第一个注册的用户 id=1,第二个 id=2

    std::string username;    // 用户名
                              // 用户注册时填的名字
                              // 数据库里设置了 UNIQUE(不能重复)
                              // 所以不能有两个人都叫 "admin"

    std::string password;    // 密码(bcrypt 哈希加密后的字符串)
                              // ★ 注意:存的不是明文密码!
                              // 比如你设密码为 "123456"
                              // 存到数据库的是 "$2b$12$rRLy3uV8Hoq1aKm..."
                              // 这叫"哈希加密"------只能加密不能解密
                              // 登录验证时:把输入的密码哈希一下,对比两个哈希值

    std::string role = "user";  // 用户角色,默认 "user"
                                // 可选值:
                                //   "user"  --- 普通用户:可以看题、提交代码
                                //   "admin" --- 管理员:还可以增删改题目
                                // 预设的 root 账号就是 "admin"
                                // 普通用户注册默认就是 "user"

    std::string created_at;  // 注册时间
                              // 格式如 "2026-06-24 14:00:00"
                              // 由 MySQL 的 DEFAULT CURRENT_TIMESTAMP 自动生成
                              // 存的是用户注册那一刻的时间
};

关于密码哈希

cpp 复制代码
//  绝对不要这样存密码
User u;
u.password = "123456";  // 如果数据库被黑了,所有密码都暴露了!
                        // 而且用户往往在多个网站用同一个密码
                        // 一个泄露 = 全网沦陷

//  正确做法:存哈希值
u.password = "$2b$12$rRLy3uV8Hoq1aKm...";  // bcrypt 哈希
// 哈希是"单向"的:
//   "123456" → 哈希 → "$2b$12$...(乱码)"    可以
//   "$2b$12$..." → 反解 → "123456"           不行!
//
// 登录时:把你输入的密码哈希一下,对比两个哈希值

5:problem.hpp------题目模型

Problem 是 3 个模型中最复杂的一个,因为它包含了 TestCase,还附带了一些统计数据。

cpp 复制代码
// ============================================================
// 文件名: problem.hpp
// 作用: 定义题目数据结构
// 对应 MySQL 数据库中的 problems 表
// 是 OJ 系统最核心的数据------没有题目还判什么?
// ============================================================

#pragma once

#include <string>       // std::string
#include <vector>       // std::vector --- 动态数组
#include <optional>     // std::optional --- C++17 新特性
                        // 表示"可能有值,也可能没有"
                        // 就像"可选的"箱子------要么装着东西,要么空着
#include "test_case.hpp"    // TestCase 结构体,因为 Problem 里要用

struct Problem {
    // ──── 基本信息(对应 problems 表的列) ────
    
    int id = 0;              // 题目 ID(主键,自动增长)
                              // 每道题的"身份证号"
                              // 用户访问 /problem?id=1 就是看 id=1 的题目

    std::string title;       // 题目标题
                              // 比如 "两数相加"、"反转字符串"、"判断回文数"
                              // 显示在题目列表和题目详情页的顶部

    std::string description; // 题目描述
                              // 告诉用户这道题要干什么
                              // 比如:
                              //   "给定两个整数 a 和 b,请计算它们的和。"
                              // 支持多行文本,可能包含格式说明

    std::string input_desc;  // 输入格式说明
                              // 告诉用户输入长什么样
                              // 比如:
                              //   "第一行包含一个整数 T,表示测试数据组数。
                              //    接下来 T 行,每行包含两个整数 a 和 b。"

    std::string output_desc; // 输出格式说明
                              // 告诉用户应该输出什么
                              // 比如:
                              //   "对于每组测试数据,输出 a + b 的值。""

    std::string difficulty = "easy";  // 难度标签
                                      // 可选值:easy(简单)、medium(中等)、hard(困难)
                                      // 显示在题目列表中,帮助用户选题
                                      // 默认 easy,创建题目时可以改

    int time_limit = 2;      // 时间限制(秒),默认 2 秒
                              // 用户程序运行超过这个时间 → TLE(超时)
                              // 可以在 config.json 中设置默认值
                              // 每道题可以单独设置不同的时间限制

    int memory_limit = 256;  // 内存限制(MB),默认 256MB
                              // 用户程序占用内存超过这个值 → MLE(超内存)
                              // 可以在 config.json 中设置默认值

    std::string created_at;  // 创建时间
    std::string updated_at;  // 最后修改时间
                              // 由 MySQL 的 ON UPDATE CURRENT_TIMESTAMP 自动更新

    // ──── 关联数据(从其他表查询得来) ────
    
    std::vector<TestCase> sample_cases;
    // ★ 关键:题目包含多个示例测试用例
    // std::vector<TestCase> 表示"一个装着 TestCase 的动态数组"
    // 为什么用 vector?因为一道题可以有多个示例用例
    //
    // 比如题目"两数相加"有 2 个示例用例:
    //   sample_cases[0]: input="1 2", expected="3"
    //   sample_cases[1]: input="10 20", expected="30"
    //
    // 这些示例用例会展示给用户看,帮助他们理解题目要求

    // ──── 统计数据(从 submissions 表统计得来) ────
    
    double pass_rate = 0.0; // 通过率
                              // 计算公式:accepted / total_submissions
                              // 比如:总共 100 次提交,30 次通过
                              //       pass_rate = 30.0 / 100.0 = 0.3 = 30%
                              // 显示在题目列表中,帮助用户判断题目难度

    int total_submissions = 0;  // 总提交次数
                                // 这道题被提交了多少次
                                // 不管 AC 还是 WA,只要提交了就算

    int accepted = 0;           // 通过次数
                                // 这道题有多少次提交是 AC 的
                                // 注意:同一个用户提交多次都 AC,每次都算
};

Problem的两个隐藏来源

注意Problem结构体里有些字段不是直接从problems表里面查出来的

problems 表直接存储:

id, title, description, input_desc, output_desc,

difficulty, time_limit, memory_limit

从 testcases 表查询得来:

sample_cases → SELECT * FROM testcases

WHERE problem_id = ? AND is_sample = true

ORDER BY sort_order

从 submissions 表统计得来:

pass_rate, total_submissions, accepted

→ SELECT COUNT(*) as total,

SUM(CASE WHEN status='AC' THEN 1 ELSE 0 END) as accepted

FROM submissions WHERE problem_id = ?

这体现了模型层的设计思想:模型是数据整合后的最终形态,而不是数据库表的一对一映射。Problem 结构体把来自 3 张表的数据整合成了一个方便使用的对象。

6:三个模型之间的关系图

这三个struct之间没有继承关系,他们只是包含关系

cpp 复制代码
┌─────────────────────────────────────────────────────────┐
│  Problem (题目)                                          │
│                                                         │
│  ┌────────────────────────────────────────────────┐     │
│  │  基本信息(来自 problems 表)                    │     │
│  │  id / title / description / difficulty ...     │     │
│  └────────────────────────────────────────────────┘     │
│                                                         │
│  ┌────────────────────────────────────────────────┐     │
│  │  示例用例(来自 testcases 表,is_sample=true)  │     │
│  │  vector<TestCase> sample_cases                 │     │
│  │  ├── TestCase{ input:"1 2", expected:"3" }     │     │
│  │  └── TestCase{ input:"10 20", expected:"30" }  │     │
│  └────────────────────────────────────────────────┘     │
│                                                         │
│  ┌────────────────────────────────────────────────┐     │
│  │  统计数据(来自 submissions 表的聚合查询)       │     │
│  │  pass_rate / total_submissions / accepted     │     │
│  └────────────────────────────────────────────────┘     │
└─────────────────────────────────────────────────────────┘

数据流全景

cpp 复制代码
用户注册
    │
    ▼
表单数据 {"username":"alice","password":"xxx"}
    │
    ▼
User 结构体 ------ Service 层填充 ------ 存入 MySQL users 表
    │                     
    ▼
从数据库查出来再装回 User 结构体 ------ 返回给前端

────────────────────────────────────────────────

管理员创建题目
    │
    ▼
表单数据 {"title":"两数相加","description":"..."}
    │
    ▼
Problem 结构体 + TestCase 结构体 ------ Service 层 ------ 存入 MySQL

────────────────────────────────────────────────

用户查看题目详情
    │
    ▼
Service 层查数据库 → 组装 Problem 结构体
    ├── 从 problems 表查基本信息
    ├── 从 testcases 表查示例用例(is_sample=true)
    └── 把数据装进 Problem 结构体,返回给 Handler

────────────────────────────────────────────────

判题引擎判题
    │
    ▼
从 submissions 表查这道题的统计数据
    └── 更新 Problem 结构体的 pass_rate / total_submissions / accepted

7:模型在实际代码中怎么用

1:在Service层中------从数据库差数据,装进模型

cpp 复制代码
// src/service/problem_service.cc(简化版)
#include "model/problem.hpp"
#include "model/test_case.hpp"
#include "db/connection_pool.hpp"

// ============================================================
// 获取题目列表
// 从数据库查数据 → 装进 vector<Problem> → 返回
// ============================================================
std::vector<Problem> ProblemService::get_problem_list() {
    std::vector<Problem> problems;  // 准备一个"空箱子",用来装所有题目
    auto& pool = ConnectionPool::instance();
    MYSQL* conn = pool.get();        // 从连接池拿一个连接

    // 执行 SQL:查所有题目
    mysql_query(conn, "SELECT id, title, difficulty FROM problems");
    MYSQL_RES* result = mysql_store_result(conn);

    // 逐行处理查询结果
    MYSQL_ROW row;
    while ((row = mysql_fetch_row(result)) != nullptr) {
        Problem p;                    // ★ 创建 Problem 结构体
        p.id = std::stoi(row[0]);     // 第 0 列 → id
        p.title = row[1];             // 第 1 列 → title
        p.difficulty = row[2];        // 第 2 列 → difficulty
        // 这里的 time_limit、memory_limit 等字段保持默认值
        // 因为列表页不需要展示这些细节
        problems.push_back(p);        // ★ 把装好的结构体放进数组
    }

    mysql_free_result(result);
    pool.release(conn);               // 归还连接
    return problems;                  // 返回装满题目结构体的数组
}

// ============================================================
// 获取题目详情(包含示例用例)
// ============================================================
Problem ProblemService::get_problem_detail(int problem_id) {
    Problem p;                        // ★ 创建一个空的 Problem 结构体
    auto& pool = ConnectionPool::instance();
    MYSQL* conn = pool.get();

    // 第 1 步:查 problems 表的基本信息
    string q = "SELECT id, title, description, input_desc, output_desc, "
               "difficulty, time_limit, memory_limit "
               "FROM problems WHERE id = " + to_string(problem_id);
    mysql_query(conn, q.c_str());
    MYSQL_RES* result = mysql_store_result(conn);
    MYSQL_ROW row = mysql_fetch_row(result);

    if (!row) {
        // 题目不存在
        mysql_free_result(result);
        pool.release(conn);
        return p;  // 返回一个 id=0 的空 Problem
    }

    p.id = stoi(row[0]);
    p.title = row[1];
    p.description = row[2];
    p.input_desc = row[3];
    p.output_desc = row[4];
    p.difficulty = row[5];
    p.time_limit = stoi(row[6]);
    p.memory_limit = stoi(row[7]);
    mysql_free_result(result);

    // 第 2 步:查这道题的示例用例
    // ★ 这里 TestCase 结构体就派上用场了
    q = "SELECT id, input, expected, sort_order "
        "FROM testcases "
        "WHERE problem_id = " + to_string(problem_id) + " AND is_sample = true "
        "ORDER BY sort_order";
    mysql_query(conn, q.c_str());
    result = mysql_store_result(conn);

    while ((row = mysql_fetch_row(result)) != nullptr) {
        TestCase tc;                   // ★ 创建 TestCase 结构体
        tc.id = stoi(row[0]);
        tc.input = row[1];
        tc.expected = row[2];
        tc.sort_order = stoi(row[3]);
        tc.is_sample = true;
        p.sample_cases.push_back(tc);  // ★ 把测试用例添加到题目的 sample_cases 数组
    }

    // 第 3 步:统计通过率
    q = "SELECT COUNT(*) as total, "
        "SUM(CASE WHEN status='AC' THEN 1 ELSE 0 END) as accepted "
        "FROM submissions WHERE problem_id = " + to_string(problem_id);
    mysql_query(conn, q.c_str());
    result = mysql_store_result(conn);
    row = mysql_fetch_row(result);
    if (row) {
        p.total_submissions = stoi(row[0]);
        p.accepted = stoi(row[1]);
        if (p.total_submissions > 0) {
            p.pass_rate = (double)p.accepted / p.total_submissions;
        }
    }

    mysql_free_result(result);
    pool.release(conn);
    return p;  // ★ 返回装满了数据的 Problem 结构体
}

2:在Handler层中------把模型转成JSON返回给前端

cpp 复制代码
// src/handler/problem_handler.cc(简化版)
// Handler 拿到 Service 返回的 Problem 结构体
// 把它转换成 JSON 格式,通过 HTTP 返回给浏览器

void handle_get_problem(const Request& req, Response& res) {
    int problem_id = stoi(req.path_params.at("id"));
    
    // 调用 Service 层获取 Problem 结构体
    Problem p = ProblemService::get_problem_detail(problem_id);
    
    if (p.id == 0) {  // 题目不存在
        res.status = 404;
        res.set_content(R"({"error":"Problem not found"})", "application/json");
        return;
    }
    
    // ★ 把 C++ 的 Problem 结构体转成 JSON 对象
    json j;
    j["id"] = p.id;
    j["title"] = p.title;
    j["description"] = p.description;
    j["difficulty"] = p.difficulty;
    j["time_limit"] = p.time_limit;
    j["memory_limit"] = p.memory_limit;
    j["pass_rate"] = p.pass_rate;
    
    // 把示例用例也转成 JSON 数组
    j["sample_cases"] = json::array();
    for (const auto& tc : p.sample_cases) {
        json tc_json;
        tc_json["input"] = tc.input;
        tc_json["expected"] = tc.expected;
        j["sample_cases"].push_back(tc_json);
    }
    
    // 返回 JSON 给浏览器
    res.set_content(j.dump(), "application/json");
}

前端收到的JSON长这样

cpp 复制代码
{
    "id": 1,
    "title": "两数相加",
    "description": "给定两个整数 a 和 b,请计算它们的和。",
    "difficulty": "easy",
    "time_limit": 2,
    "memory_limit": 256,
    "pass_rate": 0.65,
    "sample_cases": [
        {"input": "1 2", "expected": "3"},
        {"input": "10 20", "expected": "30"}
    ]
}

完整的数据流转链条

cpp 复制代码
① 浏览器请求 /api/problems/1
        │
② Handler 收到 HTTP 请求
        │
        ▼
③ Handler 调用 Service 层
   ProblemService::get_problem_detail(1)
        │
        ▼
④ Service 从连接池拿连接 → 查数据库
        │
        ├── SELECT * FROM problems WHERE id = 1
        │       ↓
        │   把行数据装进 Problem 结构体
        │
        ├── SELECT * FROM testcases WHERE problem_id=1 AND is_sample=true
        │       ↓
        │   把每行装进 TestCase 结构体 → push_back 到 Problem.sample_cases
        │
        └── SELECT COUNT, SUM FROM submissions WHERE problem_id=1
                ↓
            算出 pass_rate → 存入 Problem.pass_rate
        │
        ▼
⑤ Service 返回装好的 Problem 结构体
        │
        ▼
⑥ Handler 把 Problem 转成 JSON
        │
        ▼
⑦ 返回 HTTP 响应给浏览器
   浏览器收到 JSON → 渲染成题目详情页

8:设计理由

1:极简主义

这 3 个 struct 一共 47 行代码,没有构造函数、没有成员函数、没有继承、没有虚函数。为什么?

如果把它写成 Java 风格

java 复制代码
//  过度设计的 User 类
public class User {
    private int id;
    private String username;
    private String password;
    private String role;
    private String createdAt;
    
    // 一堆 getter/setter
    public int getId() { return id; }
    public void setId(int id) { this.id = id; }
    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }
    // ... 继续 20 行 getter/setter ...
    
    // equals/hashCode/toString 又要写一堆
}

这个项目的选择:struct 是纯数据容器,不需要封装行为。数据怎么组装、怎么验证是 Service 层的事。

2:结构体组合而不是继承

有些框架会这样设计模型:

null 复制代码
//  继承式设计(本项目没采用)
class BaseModel {       // 基类
    int id;
    String createdAt;
}

class Problem extends BaseModel {  // 继承
    String title;
    // ...
}

class User extends BaseModel {     // 继承
    String username;
    // ...
}

这个项目的选择:三个独立的 struct,不继承任何东西。 因为它们只是"数据的形状",不需要共享行为。

3:默认值

每个字段都有默认值:

null 复制代码
int id = 0;              // 默认 0,表示"尚未分配"
std::string role = "user";  // 默认普通用户
bool is_sample = false;     // 默认隐藏用例
int time_limit = 2;         // 默认 2 秒
int memory_limit = 256;     // 默认 256MB

好处:创建结构体时不需要手动给每个字段赋值,没赋值的自动用默认值,减少遗漏。

9:三个模型和数据库的对应关系

C++ 结构体 MySQL 表

─────────────────────────────────────────────────

User { users 表

int id; id INT AUTO_INCREMENT

string username; username VARCHAR(64) UNIQUE

string password; password VARCHAR(256)

string role; role ENUM('user','admin')

string created_at; created_at DATETIME

}

Problem { problems 表

int id; id INT AUTO_INCREMENT

string title; title VARCHAR(256)

string description; description TEXT

string input_desc; input_desc TEXT

string output_desc; output_desc TEXT

string difficulty; difficulty ENUM('easy','medium','hard')

int time_limit; time_limit INT

int memory_limit; memory_limit INT

string created_at; created_at DATETIME

string updated_at; updated_at DATETIME

// ★ 以下字段不存在于 problems 表

vector<TestCase> sample_cases; ← 来自 testcases 表

double pass_rate; ← 来自 submissions 表的统计

int total_submissions; ← 来自 submissions 表的统计

int accepted; ← 来自 submissions 表的统计

}

TestCase { testcases 表

int id; id INT AUTO_INCREMENT

int problem_id; problem_id INT

string input; input TEXT

string expected; expected TEXT

bool is_sample; is_sample BOOLEAN

int sort_order; sort_order INT

}

10:总结

概念 在模型层中的体现
struct(结构体) 3 个纯数据容器,没有任何函数
std::vector Problem 用 vector<TestCase> 装多个测试用例
std::optional C++17 新特性,表示 "可能有值"(虽然这个项目里没实际使用)
默认值 所有字段都有默认值,避免未初始化
数据整合 Problem 结构体整合了来自 3 张表的数据
模型 - 视图分离 模型只管存数据,不管怎么展示
JSON 序列化 Handler 层把 struct 转成 JSON 返回给前端