ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

图书馆管理信息系统数据库课设全攻略:从ER建模到并发事务

图书馆管理信息系统数据库课设全攻略:从ER建模到并发事务 简介数据库课程设计的完整报告——图书馆管理信息系统基于Eclipse与SQL Server 2000开发覆盖从数据库规划、需求分析到物理设计、应用程序设计及测试运行的完整流程。文档以图书馆日常管理为业务背景针对人工记录效率低、易出错的问题给出书籍、读者、借阅、还书、罚款挂失等核心模块的解决方案。包体为1个doc文件容量约239KB内容包含任务陈述与目标、数据需求与事务需求、ER图与数据字典、关系表与索引视图、安全机制与触发器、功能模块与界面事务设计以及测试运行和总结参考文献等可直接作为课程设计报告写作范本。报告中还明确了读者类型与最大借阅数、30天借阅期及续借规则、超期每日0.1元罚款、遗失按原价赔偿等业务细节适合学习数据库原理与应用的学生用于理解图书馆场景下的表结构设计、事务处理和权限管理思路。目前已有364人学习浏览可作为课程设计参照模板及期末复习资料。1. 数据库课程设计里的“图书馆管理信息系统”到底在考核什么拿到《数据库课程设计-图书馆管理信息系统.doc》这个标题别急着把它当成一份复制粘贴就能交差的 Word 作业。图书馆管理信息系统是数据库课程设计里最稳的选题没有之一实体关系足够清晰借书、还书、逾期罚款这些业务天然要求事务和并发控制演示起来又非常直观。但它真正考核的不是你写了多少条 SQL而是你有没有把整门《数据库原理》串起来——从需求分析、ER 图到范式、表结构再到增删改查和异常处理。这篇文章就沿着这条链路拆开讲适合正在排课设的学生按步骤复现也适合带课设的助教拿来做评审清单。2. 先建模再建表从业务流程到表结构的落地路径2.1 角色与流程先定死读者、管理员和借还书闭环很多课设翻车的第一步是打开 MySQL 就直接 CREATE TABLE。表结构应该从业务流程里长出来而不是拍脑袋决定字段。图书馆系统至少有两类角色权限完全不一样角色核心操作涉及表读者注册、查询图书、借书、还书、查看逾期与罚款reader、book、borrow管理员图书入库/下架、办理借还、逾期罚款处理、统计报表book、borrow、fine业务流程也要先在 Word 里用一段话写死这段内容就是课设报告里“需求分析”那一章的底稿。借书流程是校验读者身份与可借额度查图书可借库存可借则插入一条借阅记录同时把可借库存减一。还书流程是根据借阅记录计算是否逾期逾期则生成罚款记录更新借阅状态最后把库存加一。注意这里每一步都不能拆开比如“插入借阅记录”和“库存减一”必须绑定成原子操作否则就会出现书借出去了、库存却没变的脏数据。2.2 ER 图转关系模式四张表的字段设计与范式检查流程定死之后实体关系图就是顺手的事。读者和图书之间是借阅关系借阅记录本身带还书日期和状态罚款挂在借阅记录下面一对一管理员独立成表不跟读者混在一起。从 ER 图转成关系模式最少四张表表名关键字段说明readerreader_id、reader_name、phone、reg_date读者主档bookbook_id、isbn、title、author、press、stock_total、stock_available库存总书与可借数分离borrowborrow_id、book_id、reader_id、borrow_date、due_date、return_date、status借阅流水finefine_id、borrow_id、amount、paid、fine_date罚款记录与 borrow 一对一字段类型这里有几个容易踩的细节。ISBN 用 VARCHAR(20) 而不是 CHAR(13) 或 CHAR(17)因为很多 Excel 导入的 ISBN 带连字符定长 CHAR 会在末尾补空格导致查询时要用 TRIM 才能匹配。出版年份用 YEAR 类型够用但如果你要兼容更早或更晚的数据SMALLINT 反而更保险。借阅表的 status 字段值得专门写一段设计说明逾期状态其实可以由due_date CURDATE() AND return_date IS NULL推导出来为什么还要单独存一个 status因为统计视图、索引和查询都要频繁按状态过滤存一个 TINYINT 字段可以用到联合索引避免每行都跑日期函数。范式检查是报告里必须写的一节。最常见的错误是把读者姓名和图书书名冗余进 borrow 表这样确实查询方便但它属于部分函数依赖违反第二范式。想象一下读者改了个名字你所有历史借阅记录里的姓名都得跟着改这就是 UPDATE 异常。正确做法是 borrow 表只存 book_id 和 reader_id 这两个外键要显示姓名或书名时 JOIN 回去。2.3 建表 SQL索引、外键和字符集的第一次定型数据库我一般选 MySQL 8.x字符集统一用 utf8mb4排序规则用 utf8mb4_unicode_ci。建库建表脚本如下可以直接抄但每段我都标注了为什么这么写CREATE DATABASE IF NOT EXISTS library_system DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci; USE library_system; CREATE TABLE reader ( reader_id INT PRIMARY KEY AUTO_INCREMENT, reader_name VARCHAR(20) NOT NULL, phone VARCHAR(20), reg_date DATE DEFAULT (CURRENT_DATE) ); CREATE TABLE book ( book_id INT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL, title VARCHAR(100) NOT NULL, author VARCHAR(50), press VARCHAR(50), publish_year YEAR, stock_total INT NOT NULL DEFAULT 1, stock_available INT NOT NULL DEFAULT 1, status TINYINT NOT NULL DEFAULT 1 COMMENT 1在架 2下架 3遗失, UNIQUE KEY uk_isbn (isbn) ); CREATE TABLE borrow ( borrow_id INT PRIMARY KEY AUTO_INCREMENT, book_id INT NOT NULL, reader_id INT NOT NULL, borrow_date DATE NOT NULL DEFAULT (CURRENT_DATE), due_date DATE NOT NULL, return_date DATE, status TINYINT NOT NULL DEFAULT 1 COMMENT 1借出中 2已归还 3逾期未还, CONSTRAINT fk_borrow_book FOREIGN KEY (book_id) REFERENCES book(book_id), CONSTRAINT fk_borrow_reader FOREIGN KEY (reader_id) REFERENCES reader(reader_id), KEY idx_reader_status (reader_id, status), KEY idx_book_status (book_id, status) ); CREATE TABLE fine ( fine_id INT PRIMARY KEY AUTO_INCREMENT, borrow_id INT NOT NULL, amount DECIMAL(6,2) NOT NULL DEFAULT 0.00, paid TINYINT NOT NULL DEFAULT 0, fine_date DATE NOT NULL DEFAULT (CURRENT_DATE), CONSTRAINT fk_fine_borrow FOREIGN KEY (borrow_id) REFERENCES borrow(borrow_id) );外键约束我建议保留不要因为嫌麻烦就删掉。它是数据库层面最后一道防线能拦住那些程序里没写到的非法 book_id 和 reader_id。索引这里有两个取舍book 表的 isbn 建了唯一键因为 ISBN 天然应该唯一而且按 ISBN 查书的频率远高于按自增主键查borrow 表则建了(reader_id, status)和(book_id, status)两个联合索引因为大部分查询是“某个读者名下的未还记录”或“某本书的借出记录”联合索引能让这两个高频查询走索引覆盖避免回表。等数据量到几千条之后你 EXPLAIN 一下就能看到差别。字符集要在建库那一刻就定死不然后面中文乱码的排查会非常痛苦。utf8mb4 兼容 emoji 和生僻字比 utf8 更保险排序规则选 unicode_ci 而不是 general_ci主要是为了多语言环境下排序更符合直觉。3. 借书、还书、统计排行让增删改查跑在事务上3.1 借书与还书事务为什么不能拆成两条独立 SQL课程设计文档里“系统实现”这一章最容易写成简单的单表增删改查。但图书馆系统的核心业务恰恰是多表联动这里必须用存储过程或事务把多条 SQL 包起来。借书这个动作逻辑上等价于“插入一条借阅记录 扣减可借库存”任何一步失败另一步都必须回滚。我一般直接用存储过程因为演示的时候 CALL 一句就能看到效果答辩老师也容易理解。下面这个版本做了简化保留了一个最关键的并发控制点DELIMITER // CREATE PROCEDURE borrow_book( IN p_reader_id INT, IN p_book_id INT, IN p_days INT ) BEGIN DECLARE v_avail INT; START TRANSACTION; -- 行锁防止两个会话同时读到库存为 1 都往下走 SELECT stock_available INTO v_avail FROM book WHERE book_id p_book_id FOR UPDATE; IF v_avail IS NULL OR v_avail 1 THEN ROLLBACK; SIGNAL SQLSTATE 45000 SET MESSAGE_TEXT no stock available; END IF; INSERT INTO borrow (book_id, reader_id, due_date) VALUES (p_book_id, p_reader_id, DATE_ADD(CURDATE(), INTERVAL p_days DAY)); UPDATE book SET stock_available stock_available - 1 WHERE book_id p_book_id; COMMIT; END// DELIMITER ;这段代码里最关键的不是 INSERT 和 UPDATE而是SELECT ... FOR UPDATE。它会给 book 表这一行加排他锁保证两个管理员同时给同一位读者借同一本库存只剩 1 的书时后一个事务会等前一个提交后才读到新库存然后正确地报出“无库存”。如果没有这行锁两个事务可能同时读到 stock_available1都执行插入和扣减最后库存变成 -1这就是典型的超卖。p_days参数控制借期一般传 30对应默认借阅期限。SIGNAL SQLSTATE 45000是 MySQL 里手工抛异常的标准写法作用相当于程序里的 throw RuntimeException用 ROLLBACK 保证库存检查失败时不留下任何半截数据。如果你用的是 MySQL 5.xDATE_ADD和INTERVAL的语法完全一样可以放心跑。3.2 逾期罚款与状态流转还书逻辑里的边界处理还书比借书多一个分支要判断是否逾期逾期了要写罚款记录而且罚款和还书必须同生共死——不能出现书已经还了罚款记录却没生成的场景。否则读者下次来借书管理员查不到他名下的欠款系统就漏了一笔应收款。DELIMITER // CREATE PROCEDURE return_book( IN p_borrow_id INT ) BEGIN DECLARE v_book_id INT; DECLARE v_due_date DATE; DECLARE v_over_days INT; START TRANSACTION; SELECT book_id, due_date INTO v_book_id, v_due_date FROM borrow WHERE borrow_id p_borrow_id FOR UPDATE; IF v_book_id IS NULL THEN ROLLBACK; SIGNAL SQLSTATE 45000 SET MESSAGE_TEXT borrow record not found; END IF; IF DATEDIFF(CURDATE(), v_due_date) 0 THEN SET v_over_days DATEDIFF(CURDATE(), v_due_date); INSERT INTO fine (borrow_id, amount, fine_date) VALUES (p_borrow_id, v_over_days * 0.50, CURDATE()); END IF; UPDATE borrow SET return_date CURDATE(), status 2 WHERE borrow_id p_borrow_id; UPDATE book SET stock_available stock_available 1 WHERE book_id v_book_id; COMMIT; END// DELIMITER ;这里用SELECT ... FOR UPDATE锁住的是 borrow 记录防止同一笔借阅被两个会话重复归还。重复归还是个很隐蔽的坑如果两个管理员同时打开同一笔记录操作没有行锁的话第二次执行会把 book 表的库存多加一次book 的 stock_available 就会超过 stock_total数据一查就露馅。逾期计算用的是DATEDIFF(CURDATE(), v_due_date)这是 MySQL 的写法计算的是“当前日期减去截止日期”的天数差。罚款金额按每天 0.50 元算DECIMAL(6,2)最多表示 9999.99 元对课设场景绝对够用。status 2表示已归还逾期这个状态没有单独写进表里因为 overdue 可以由return_date IS NULL AND due_date CURDATE()推导只在需要统计的时候查一下就行。3.3 统计查询与视图答辩时最好讲的三类查询课程设计报告里必须有一段“典型查询”这些查询要覆盖聚合、连表、分组排序而不是只在表上做单表 SELECT。下面三类查询是图书馆系统最常被答辩老师问到的每一个背后都有知识点。-- 1. 读者借阅排行谁借书最多 SELECT r.reader_name, COUNT(*) AS borrow_count FROM borrow b JOIN reader r ON b.reader_id r.reader_id GROUP BY r.reader_id, r.reader_name ORDER BY borrow_count DESC LIMIT 10; -- 2. 图书热度排行哪些书流通最频繁 SELECT bk.title, COUNT(*) AS borrow_count FROM borrow b JOIN book bk ON b.book_id bk.book_id GROUP BY bk.book_id, bk.title ORDER BY borrow_count DESC LIMIT 10; -- 3. 当前逾期未还清单视图封装复杂条件 CREATE VIEW v_overdue AS SELECT b.borrow_id, r.reader_name, bk.title, b.due_date, DATEDIFF(CURDATE(), b.due_date) AS overdue_days FROM borrow b JOIN reader r ON b.reader_id r.reader_id JOIN book bk ON b.book_id bk.book_id WHERE b.return_date IS NULL AND b.due_date CURDATE();前两个查询是 GROUP BY 加 ORDER BY 加 LIMIT 的经典组合。这里有一个特别容易翻车的新版 MySQL 坑默认sql_mode里有only_full_group_bySELECT 出来的非聚合列必须出现在 GROUP BY 里。所以我在分组时把reader_id, reader_name一起写了而不是只按reader_id分组却去 SELECTreader_name。如果你在别的机器上跑这段 SQL 报错先看SELECT sql_mode;是不是带了only_full_group_by。第三个视图把“逾期未还”这个逻辑封装成了一个表答辩演示的时候一句SELECT * FROM v_overdue;就能出效果。视图的好处是调用方不用关心底下三张表的 JOIN 和日期判断也方便在程序层直接把视图映射成只读实体。视图在文档里要单独占一节讲清楚它屏蔽了哪些复杂度。4. 程序层接入 MySQL连接池、DAO 与批量导入4.1 告别 DriverManager连接池参数怎么设很多课设代码里写的是DriverManager.getConnection(url, user, password)每次查询都新建一个物理连接。这在演示时看不出问题因为数据量才几百条连接开销被查询时间盖住了。但只要答辩老师问一句“并发几十个人同时借书怎么办”这个实现就露馅了——每次连接都要走 TCP 握手和 MySQL 认证数据库端的最大连接数很快被打满。常见的做法是用连接池Java 这边我用 HikariCP配置极简性能也够。它的初始化代码长这样HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://localhost:3306/library_system?useSSLfalsecharacterEncodingutf8); config.setUsername(root); config.setPassword(your_password); config.setMaximumPoolSize(10); config.setConnectionTimeout(3000); config.setMaxLifetime(1800000); HikariDataSource dataSource new HikariDataSource(config);连接池参数不是越大越好这是课设报告里值得写一段“参数设计依据”的地方参数建议值理由maximumPoolSize10课设场景并发个位数10 个连接足够开多了只是占着数据库资源minimumIdle5保底连接数避免突发请求时临时建连接connectionTimeout3000ms3 秒拿不到连接直接报错不让调用方无限等待maxLifetime1800000ms30 分钟强制换连接小于 MySQL 默认 8 小时 wait_timeout防止连接被服务端断开设置maxLifetime的 30 分钟上限是我踩过坑才加上的。MySQL 默认wait_timeout是 8 小时空闲超过这个时间的连接会被服务端主动断开。连接池如果拿着一个已经断掉的连接继续用第一次查询就会报“Connection is not available”或通信链路异常。把maxLifetime设得比服务端超时短是连接池的标准自救姿势。4.2 PreparedStatement 与 DAO 封装SQL 注入这道送分题程序层访问数据库最忌讳的就是字符串拼接 SQL。比如查询读者// 错误示范参数直接拼进 SQL String sql SELECT * FROM reader WHERE reader_name name ;这段代码只要传入 OR 11SQL 就变成了WHERE reader_name OR 11把整张表查了出来。课设答辩时老师问“什么是 SQL 注入”拿你这段代码当反例分就没了。正确写法是用 PreparedStatement让参数只作为占位符的数据不参与 SQL 语法解析String sql SELECT * FROM reader WHERE reader_name ?; try (PreparedStatement ps dataSource.getConnection() .prepareStatement(sql)) { ps.setString(1, name); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { // 处理结果集 } } }同一个 SQL 字符串在 MySQL 端只需要解析一次后面传入不同的参数直接复用执行计划这也是 PreparedStatement 比 Statement 快的第二层原因。你的 DAO 层应该长成“一个方法对应一条 SQL”的样子比如findReaderByName(String name)、insertBook(Book book)、updateStock(int bookId, int delta)。业务层只调 DAO 方法不直接拼 SQL这是分层最基本的约束。4.3 Excel 批量导入测试数据LOAD DATA 与 JDBC Batch课程设计演示需要几百条像样的图书数据手工 INSERT 不现实最省事的路径是从 Excel 整理数据、另存 CSV再用 LOAD DATA 一次性灌进去。Excel 里第一行写列名第二行开始是数据另存时编码选 UTF-8。导入命令LOAD DATA INFILE /tmp/books.csv INTO TABLE book CHARACTER SET utf8mb4 FIELDS TERMINATED BY , ENCLOSED BY IGNORE 1 LINES (isbn, title, author, press, publish_year, stock_total, stock_available);FIELDS TERMINATED BY ,告诉 MySQL 逗号是列分隔符ENCLOSED BY 处理 Excel 导出时给含逗号的字段自动加的双引号IGNORE 1 LINES跳过表头。注意LOAD DATA INFILE读取的是 MySQL 服务端本地的文件如果报ERROR 1290说明服务端开了secure_file_priv把文件放到它指定的目录比如/var/lib/mysql-files/下面再跑。如果不是外部文件而是 Java 程序里解析到的对象列表就换成 JDBC 批量插入。只要在连接串上加一个参数rewriteBatchedStatementstrue然后addBatch、executeBatch循环提交MySQL 会把多条 INSERT 合并成一条多值 INSERT几千条数据一次往返就完成。注意 batch 也不是越大越好一般每 500 条执行一次 executeBatch避免单条 SQL 过长超过 max_allowed_packet 的限制。5. 图书馆课设的避坑清单从乱码到死锁的四条血泪记录5.1 中文乱码插入“三国演义”变成“????”现象用图形客户端或 Java 程序插入中文书名查出来是问号或者乱码英文数据完全正常。原因这条坑的根源是字符集链路某一段不是 utf8mb4。数据库建库时用了默认字符集而 MySQL 8.x 之前默认是 latin1或者建库定了 utf8mb4但 JDBC 连接串没加characterEncodingutf8又或者 CSV 文件本身是 GBK 编码LOAD DATA 时按 utf8mb4 解析。解决按三段链路逐一排查。建库时显式写DEFAULT CHARSET utf8mb4JDBC 连接串加characterEncodingutf8CSV 文件用 Excel 另存时明确选择“CSV UTF-8”格式而不是默认的“CSV”或“CSV UTF-8 (逗号分隔)”。最后用SHOW VARIABLES LIKE character_set_server;确认服务端默认值再用SELECT * FROM book WHERE title 三国演义;反向验证数据是不是真的写坏了。如果已经写坏只能删掉重导没有后悔药。5.2 外键删不掉DELETE FROM book 报错 1451现象想清理测试数据执行DELETE FROM book WHERE book_id 1;MySQL 直接报Cannot delete or update a parent row: a foreign key constraint fails。原因borrow 表里有 book_id1 的借阅记录外键约束默认是ON DELETE RESTRICT有子表引用时不允许删父表。这不是数据库出 bug是约束在起作用但很多课设同学到这一步就慌着去删外键等于把最后一道防线拆了。解决分清“物理删除”和“逻辑删除”。借阅记录是历史存档本来就不该物理删除图书下架应该用UPDATE book SET status 2 WHERE book_id 1;做逻辑删除查询时默认过滤status ! 2。如果确实要物理清理测试数据按依赖顺序先删子表再删父表DELETE FROM fine WHERE borrow_id IN (SELECT borrow_id FROM borrow WHERE book_id 1); DELETE FROM borrow WHERE book_id 1; DELETE FROM book WHERE book_id 1;5.3 并发借同一本书数据库死锁的经典现场现象两个管理员工位同时给读者办理借书借的是同一本库存只剩 1 的书。本来应该有一个报“无库存”实际上却可能有一个窗口报Deadlock found when trying to get lock; try restarting transaction。原因死锁的本质是两个事务各自持有一把锁又都在等对方手里的那把锁。借书事务里既有SELECT ... FOR UPDATE锁 book 行又有 INSERT 写 borrow 历史表如果两个事务以不同顺序去碰这两类记录就可能形成循环等待。数据库的并发锁机制本身没问题是事务的锁顺序没设计好。解决固定所有事务访问资源的顺序。借书流程统一“先锁 book 行、再写 borrow 记录”不要让其中一个事务反着写。同时把事务尽量缩短锁的时间越短冲突概率越低。“尽量缩短”的落地姿势是在事务里只做数据库操作不写网络请求、不读文件这些动作放到事务外。对库存扣减还有一种更激进的原子写法UPDATE book SET stock_available stock_available - 1 WHERE book_id ? AND stock_available 0;单条 UPDATE 自带行锁检查affected rows是否为 1 就能代替先 SELECT 再判断的流程事务会短很多。5.4 DATEDIFF 与 GROUP BY 翻车同一个库不同版本行为不一致现象还书存储过程在自己的笔记本上跑得好好的拷到答辩机器上执行算出来的罚款金额变成负数另一个现象是统计排行时报Expression #1 of SELECT list is not in GROUP BY clause and contains nonaggregated column。原因两个问题背后都是“口径”问题。DATEDIFF(date1, date2)在不同数据库方言里的参数顺序不同——MySQL 是 date1 减 date2SQL Server 是DATEDIFF(datepart, start, end)。如果你在文档里同时提了 MySQL 和 SQL Server代码却只在 MySQL 上跑很容易写混。GROUP BY 的报错则是 MySQL 5.7.5 之后默认开启了only_full_group_by模式SELECT 的非聚合列必须出现在 GROUP BY 里老版本的宽松检查把这个问题掩盖掉了。解决在课设文档开头就声明“本系统基于 MySQL 8.x 开发测试”所有 SQL 以这个版本为准。DATEDIFF 统一写成DATEDIFF(CURDATE(), due_date)这种能看出“当前日期减截止日期”的形式不要写倒数。GROUP BY 查询把所有需要的列都放进分组或者用ANY_VALUE(reader_name)包裹非聚合列。这两个问题的共同教训是换环境跑课设前先跑一遍你的存储过程和统计 SQL不要在演示现场才发现行为不一致。6. 答辩演示前的自测验证数据一致性的一套组合拳演示翻车最惨的不是系统崩溃而是老师随口问一个问题你就接不上。在去答辩之前我建议你把这套验证脚本完整跑一遍每个点都对应一个可能被追问的知识点。-- 1. 逾期视图是否工作 SELECT * FROM v_overdue; -- 2. 借书后库存是否扣减 CALL borrow_book(1, 1, 30); SELECT book_id, stock_total, stock_available FROM book WHERE book_id 1; -- 3. 重复借同一本书第二次应该报 no stock available -- 开两个数据库会话同时执行 CALL borrow_book(1, 1, 30); -- 4. 权限验证只读账号不能写 CREATE USER IF NOT EXISTS readonlylocalhost IDENTIFIED BY readonly123; GRANT SELECT ON library_system.* TO readonlylocalhost;用只读账号执行一条 INSERT比如INSERT INTO reader(reader_name) VALUES(hacker);数据库应在权限层直接拒绝。这一步验证的是你文档里写的“不同角色权限分离”不是空话老师很吃这一套。借书演示则分两步先正常借一本看库存减一再打开两个会话同时借同一本库存为 1 的书观察一个成功、一个报错或死锁重试。这个过程把事务、行锁、并发控制三个概念一次演完。文档里还要有一节“异常处理”别只写成功路径。把第 5 章里的乱码、外键删除失败、死锁各写一段“现象 原因 解决方案”答辩时主动讲自己踩过哪些坑比被问到时支支吾吾强得多。老师问的问题说白了就是数据库面试题的范围事务隔离级别、索引为什么快、死锁怎么避免、范式怎么判断。你只要在产品里真能指出对应代码这门课的分数就稳了。我当年第一次答辩数据库设计抄了学长的文档表结构、存储过程都能背老师一句“两个人同时借最后一本书你的系统怎么保证不错”就把我问住了。后来自己把事务和行锁跑通才觉得这门课真正过了关。希望帮到你。本文还有配套的精品资源点击获取
返回列表