简介:这份资源是一套面向数据库课程设计与SQL初学者练习的图书管理系统完整代码文档,围绕图书馆日常业务中的借阅、归还与罚款等环节,给出从需求分析到SQL实现的整体方案,适合正在完成课程设计或希望系统梳理关系型数据库建模流程的学生参考。压缩包内仅含1个doc文件,约709KB,以设计报告形式组织内容,涵盖设计目标、数据库存储设计指导思想、任务角色划分与基础数据、业务数据、统计数据的需求说明。文档重点展开E-R图设计,分别描述类别、读者、书籍、借阅、归还与罚款等实体及其联系,并给出书籍类别、读者、书籍、借阅、还书、罚款等关系模式,配合数据字典说明各字段类型与约束,最后落到SQL查询语言及查询结果的实现。目前已有2317人学习,可帮助读者理解图书管理信息系统的完整设计脉络,掌握从概念结构到逻辑结构再到SQL落地的思路,并对照关系代数与查询描述查漏补缺。
1. 从一份“完整代码”文档说起:图书管理系统到底该长什么样
很多人第一次接触数据库课程设计,拿到的就是一份名为“SQL数据库图书管理系统(完整代码).doc”的文档。打开一看,里面塞满了建表语句、存储过程、触发器和一堆零散的查询示例,但真要把这套东西跑起来,却发现缺了半截——要么没有前端交互,要么数据字典对不上,要么连最基本的借阅流程都串不起来。这个标题背后真正要解决的问题,不是“有没有代码”,而是“一套能演示、能扩展、能讲清楚数据流转的图书管理系统,最小可用版本应该包含哪些东西”。
它适合两类人:一类是正在做课程设计或毕业设计的学生,需要一套结构清晰、逻辑自洽的参考实现;另一类是想从业务系统入手巩固 SQL 能力的开发者,希望用图书借阅这个经典场景把建表、约束、事务、视图和存储过程串起来。这篇文章不会给你一份“复制粘贴就能交差”的文档,而是把从零搭建这套系统的路径拆开,告诉你每一步为什么这么做、参数怎么定、哪里容易翻车。读完之后,你应该能自己写出那份“完整代码”,而不是依赖别人给的 .doc 文件。
2. 图书管理系统的数据库设计:从借阅规则倒推表结构
2.1 先理清业务实体,再动手写 CREATE TABLE
很多人在设计表结构时习惯先想“我要建几张表”,然后凭感觉列字段。更可靠的做法是先把业务规则写下来,再让规则推导出实体和关系。图书管理系统的核心规则其实就几条:一本书可以被多次借阅,但同一时刻只能在一个读者手上;一个读者可以借多本书,但有数量上限;借阅有期限,逾期会产生罚金;图书有库存状态,丢失或损坏要单独记录。
把这些规则翻译成实体,至少需要:图书、读者、借阅记录、管理员、罚款记录。其中“借阅记录”是典型的关联表,它把图书和读者多对多的关系拆成两条一对多。这里有一个容易忽略的点:图书的“可借数量”不应该直接存在图书表里作为一个静态字段,而应该由“总库存减去未归还的借阅记录数”动态得出,否则并发借书时会出现超借。常见做法是在图书表里保留 total_copies,在借阅表里用 status 标记是否归还,查询时用子查询或视图计算可用数量。
下面是一段经过简化的建表语句,重点看约束和默认值的设计:
-- 图书表:isbn 作为业务主键,但依然保留自增 id 作为物理主键 CREATE TABLE books ( id INT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL UNIQUE, title VARCHAR(200) NOT NULL, author VARCHAR(100) NOT NULL, publisher VARCHAR(100), total_copies INT NOT NULL DEFAULT 1, location VARCHAR(50), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_total_copies CHECK (total_copies >= 0) ); -- 读者表:card_no 是借书证号,status 控制挂失/注销 CREATE TABLE readers ( id INT PRIMARY KEY AUTO_INCREMENT, card_no VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, phone VARCHAR(20), max_borrow INT NOT NULL DEFAULT 5, status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0挂失 2注销', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 借阅记录表:核心是借出和归还两个时间点,以及状态 CREATE TABLE borrow_records ( id INT PRIMARY KEY AUTO_INCREMENT, book_id INT NOT NULL, reader_id INT NOT NULL, borrow_date DATE NOT NULL, due_date DATE NOT NULL, return_date DATE, status TINYINT NOT NULL DEFAULT 0 COMMENT '0借出 1已还 2逾期未还', renew_count INT NOT NULL DEFAULT 0, FOREIGN KEY (book_id) REFERENCES books(id), FOREIGN KEY (reader_id) REFERENCES readers(id), INDEX idx_reader_status (reader_id, status), INDEX idx_book_status (book_id, status) );这段代码里几个参数值得展开说。total_copies上的 CHECK 约束是为了防止库存被改成负数,虽然应用层也会校验,但数据库层兜底能避免脚本误操作。borrow_records上的两个复合索引idx_reader_status和idx_book_status是血泪经验:没有它们,当借阅记录到几万条时,查询“某读者当前借了几本”会全表扫描,页面响应直接卡死。renew_count用来限制续借次数,一般设为 1 或 2,超过就不允许再续,这个字段放在借阅记录里而不是读者表里,因为续借是针对单次借阅行为的。
2.2 用视图和存储过程封装“借书”和“还书”
表建好之后,如果让应用层直接写 INSERT 和 UPDATE,很容易出现逻辑分散、状态不一致的问题。比如借书时要同时检查读者状态、借阅上限、图书可借数量,然后插入记录;还书时要更新记录状态、计算逾期天数、可能插入罚款记录。这些操作适合用存储过程封装,保证事务原子性。
先看一个“借书”存储过程的实现:
DELIMITER // CREATE PROCEDURE borrow_book( IN p_card_no VARCHAR(20), IN p_isbn VARCHAR(20), OUT p_result VARCHAR(100) ) BEGIN DECLARE v_reader_id INT; DECLARE v_book_id INT; DECLARE v_max_borrow INT; DECLARE v_current_borrow INT; DECLARE v_available INT; DECLARE v_reader_status TINYINT; -- 异常处理:任何 SQL 错误都回滚并返回提示 DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; SET p_result = '系统错误,借书失败'; END; START TRANSACTION; -- 1. 查读者 SELECT id, max_borrow, status INTO v_reader_id, v_max_borrow, v_reader_status FROM readers WHERE card_no = p_card_no FOR UPDATE; IF v_reader_id IS NULL THEN SET p_result = '读者不存在'; ROLLBACK; ELSEIF v_reader_status != 1 THEN SET p_result = '读者状态异常,无法借书'; ROLLBACK; ELSE -- 2. 查当前借阅数量 SELECT COUNT(*) INTO v_current_borrow FROM borrow_records WHERE reader_id = v_reader_id AND status IN (0, 2); IF v_current_borrow >= v_max_borrow THEN SET p_result = '已达借阅上限'; ROLLBACK; ELSE -- 3. 查图书可借数量 SELECT id INTO v_book_id FROM books WHERE isbn = p_isbn FOR UPDATE; SELECT total_copies - ( SELECT COUNT(*) FROM borrow_records WHERE book_id = v_book_id AND status IN (0, 2) ) INTO v_available; IF v_available <= 0 THEN SET p_result = '图书已全部借出'; ROLLBACK; ELSE INSERT INTO borrow_records (book_id, reader_id, borrow_date, due_date, status) VALUES (v_book_id, v_reader_id, CURDATE(), DATE_ADD(CURDATE(), INTERVAL 30 DAY), 0); SET p_result = '借书成功'; COMMIT; END IF; END IF; END IF; END // DELIMITER ;这个存储过程里有几个关键设计。FOR UPDATE在查询读者和图书时加行锁,防止两个并发请求同时判断“可借数量为 1”然后都插入记录导致超借。due_date用DATE_ADD(CURDATE(), INTERVAL 30 DAY)生成,30 天是常见借阅期限,实际项目中可能根据读者类型不同而不同,可以再加一张“借阅规则表”来配置。status IN (0, 2)表示“借出”和“逾期未还”都算占用库存,只有status = 1已还才释放。异常处理器里先 ROLLBACK 再设置提示,避免事务悬挂。
还书过程稍微复杂一点,因为要算逾期:
CREATE PROCEDURE return_book( IN p_card_no VARCHAR(20), IN p_isbn VARCHAR(20), OUT p_result VARCHAR(100), OUT p_fine DECIMAL(10,2) ) BEGIN DECLARE v_record_id INT; DECLARE v_due_date DATE; DECLARE v_overdue_days INT DEFAULT 0; DECLARE v_fine_rate DECIMAL(5,2) DEFAULT 0.50; DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; SET p_result = '系统错误,还书失败'; END; START TRANSACTION; SELECT br.id, br.due_date INTO v_record_id, v_due_date FROM borrow_records br JOIN readers r ON br.reader_id = r.id JOIN books b ON br.book_id = b.id WHERE r.card_no = p_card_no AND b.isbn = p_isbn AND br.status IN (0, 2) ORDER BY br.borrow_date DESC LIMIT 1 FOR UPDATE; IF v_record_id IS NULL THEN SET p_result = '未找到对应的借阅记录'; ROLLBACK; ELSE SET v_overdue_days = DATEDIFF(CURDATE(), v_due_date); IF v_overdue_days > 0 THEN SET p_fine = v_overdue_days * v_fine_rate; UPDATE borrow_records SET status = 2, return_date = CURDATE() WHERE id = v_record_id; INSERT INTO fines (record_id, amount, created_at) VALUES (v_record_id, p_fine, NOW()); SET p_result = CONCAT('还书成功,逾期', v_overdue_days, '天,罚金', p_fine, '元'); ELSE SET p_fine = 0; UPDATE borrow_records SET status = 1, return_date = CURDATE() WHERE id = v_record_id; SET p_result = '还书成功'; END IF; COMMIT; END IF; END //这里罚金费率v_fine_rate设为 0.50 元/天,是一个常见值,实际可以根据规则调整。注意还书时把status更新为 2 而不是 1,表示“已还但曾逾期”,这样后续统计逾期率时还能区分出来。如果业务上不关心这个区分,统一用 1 也可以,但会丢失逾期历史。
2.3 用触发器维护库存冗余字段的取舍
有些实现方案会在books表里加一个available_copies字段,然后用触发器在借书和还书时自动增减。这样做的好处是查询可用数量时不用子查询,速度快;坏处是触发器逻辑一旦有 bug,库存数据会悄悄错乱,而且排查困难。我的建议是:如果借阅并发量不大(比如课程设计或内部工具),直接用视图或子查询计算可用数量,简单可靠;如果确实需要冗余字段,触发器里必须用SIGNAL SQLSTATE抛异常来阻止非法更新,并且定期跑对账 SQL 校验。
下面是一个用触发器维护冗余字段的示例,注意异常处理:
CREATE TRIGGER trg_borrow_insert AFTER INSERT ON borrow_records FOR EACH ROW BEGIN IF NEW.status = 0 THEN UPDATE books SET available_copies = available_copies - 1 WHERE id = NEW.book_id AND available_copies > 0; IF ROW_COUNT() = 0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '库存不足,触发器阻止借阅'; END IF; END IF; END;这个触发器的关键点是AND available_copies > 0和ROW_COUNT()检查。如果更新影响行数为 0,说明库存已经不够,用 SIGNAL 抛错让整个事务回滚。但即便如此,触发器方案在高并发下依然可能因为间隙锁竞争导致性能下降,所以选型时要权衡。
3. 从 SQL 到可交互系统:用 Python + Flask 把数据库跑起来
3.1 最小后端接口:借书、还书、查询三件事
光有数据库和存储过程,演示时只能靠命令行,不够直观。用 Python 的 Flask 框架加 PyMySQL 驱动,可以在几十行代码内搭出一个能点击的界面。核心接口就三个:查询图书列表、借书、还书。下面是一个最小可运行的后端示例:
from flask import Flask, request, jsonify import pymysql app = Flask(__name__) def get_conn(): return pymysql.connect( host='localhost', user='root', password='your_password', database='library', charset='utf8mb4', cursorclass=pymysql.cursors.DictCursor ) @app.route('/books', methods=['GET']) def list_books(): conn = get_conn() try: with conn.cursor() as cur: # 用视图或子查询计算可借数量 cur.execute(""" SELECT b.id, b.isbn, b.title, b.author, b.total_copies - IFNULL(( SELECT COUNT(*) FROM borrow_records br WHERE br.book_id = b.id AND br.status IN (0, 2) ), 0) AS available FROM books b ORDER BY b.id DESC """) books = cur.fetchall() return jsonify({'code': 0, 'data': books}) finally: conn.close() @app.route('/borrow', methods=['POST']) def borrow(): card_no = request.json.get('card_no') isbn = request.json.get('isbn') conn = get_conn() try: with conn.cursor() as cur: cur.callproc('borrow_book', (card_no, isbn, '')) # PyMySQL 调用存储过程后需要额外查询 OUT 参数 cur.execute("SELECT @_borrow_book_2 AS result") result = cur.fetchone() conn.commit() return jsonify({'code': 0, 'msg': result['result']}) except Exception as e: conn.rollback() return jsonify({'code': 1, 'msg': str(e)}) finally: conn.close() @app.route('/return', methods=['POST']) def return_book(): card_no = request.json.get('card_no') isbn = request.json.get('isbn') conn = get_conn() try: with conn.cursor() as cur: cur.callproc('return_book', (card_no, isbn, '', 0)) cur.execute("SELECT @_return_book_2 AS result, @_return_book_3 AS fine") row = cur.fetchone() conn.commit() return jsonify({'code': 0, 'msg': row['result'], 'fine': float(row['fine'])}) except Exception as e: conn.rollback() return jsonify({'code': 1, 'msg': str(e)}) finally: conn.close() if __name__ == '__main__': app.run(debug=True, port=5000)这段代码里,get_conn每次请求新建连接,适合演示,生产环境应该用连接池。callproc调用存储过程后,PyMySQL 不会自动返回 OUT 参数,需要用SELECT @_procedure_name_n的方式读取,其中 n 是 OUT 参数的位置索引(从 0 开始)。借书接口里conn.commit()是必须的,因为存储过程内部虽然有 COMMIT,但 PyMySQL 默认 autocommit 关闭,外部再提交一次不会有害。还书接口返回罚金金额,前端可以弹窗提示。
3.2 前端页面:用最少的 HTML 把流程串起来
前端不需要框架,一个 HTML 文件加原生 fetch 就能演示。关键是把借书和还书的表单做出来,并且实时刷新图书列表。
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>图书管理系统演示</title> </head> <body> <h2>图书列表</h2> <table border="1" id="bookTable"> <tr><th>ISBN</th><th>书名</th><th>作者</th><th>可借数量</th></tr> </table> <h2>借书</h2> <input id="borrowCard" placeholder="借书证号"> <input id="borrowIsbn" placeholder="ISBN"> <button onclick="doBorrow()">借书</button> <h2>还书</h2> <input id="returnCard" placeholder="借书证号"> <input id="returnIsbn" placeholder="ISBN"> <button onclick="doReturn()">还书</button> <script> async function loadBooks() { const res = await fetch('/books'); const json = await res.json(); const table = document.getElementById('bookTable'); table.innerHTML = '<tr><th>ISBN</th><th>书名</th><th>作者</th><th>可借数量</th></tr>'; json.data.forEach(b => { table.innerHTML += `<tr><td>${b.isbn}</td><td>${b.title}</td><td>${b.author}</td><td>${b.available}</td></tr>`; }); } async function doBorrow() { const card_no = document.getElementById('borrowCard').value; const isbn = document.getElementById('borrowIsbn').value; const res = await fetch('/borrow', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({card_no, isbn}) }); const json = await res.json(); alert(json.msg); loadBooks(); } async function doReturn() { const card_no = document.getElementById('returnCard').value; const isbn = document.getElementById('returnIsbn').value; const res = await fetch('/return', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({card_no, isbn}) }); const json = await res.json(); alert(json.msg + (json.fine ? ',罚金:' + json.fine + '元' : '')); loadBooks(); } loadBooks(); </script> </body> </html>这个页面没有样式,但足以验证整个借还流程。loadBooks在页面加载时调用,每次借还操作后重新拉取列表,保证可借数量实时更新。注意alert只是演示用,实际项目中应该用更友好的提示组件。
3.3 初始化数据:让系统一启动就有东西可看
空数据库没法演示,需要准备一批种子数据。建议单独写一个init_data.sql,包含管理员、读者、图书和几条借阅记录。这样每次重建数据库后执行一次即可。
-- 插入读者 INSERT INTO readers (card_no, name, phone, max_borrow) VALUES ('R001', '张三', '13800000001', 5), ('R002', '李四', '13800000002', 3); -- 插入图书 INSERT INTO books (isbn, title, author, publisher, total_copies, location) VALUES ('978-7-111-11111-1', '数据库系统概论', '王珊', '高等教育出版社', 3, 'A区-01'), ('978-7-111-22222-2', '算法导论', 'Cormen', '机械工业出版社', 2, 'A区-02'), ('978-7-111-33333-3', '深入理解计算机系统', 'Bryant', '机械工业出版社', 1, 'B区-01'); -- 插入一条借阅记录,方便测试还书 INSERT INTO borrow_records (book_id, reader_id, borrow_date, due_date, status) VALUES (1, 1, CURDATE(), DATE_ADD(CURDATE(), INTERVAL 30 DAY), 0);执行顺序很重要:先插读者和图书,再插借阅记录,否则外键约束会报错。init_data.sql可以放在项目根目录,配合一个reset_db.sh脚本一键重建。
4. 避坑与排查:图书管理系统开发中最容易翻车的五个地方
4.1 借阅记录状态字段用错,导致库存永远对不上
现象:还书之后,图书的可借数量没有恢复,或者借书时明明有库存却提示“已全部借出”。原因通常是status字段的取值定义混乱,比如借出用 0、已还用 1,但查询可用数量时只过滤了status = 0,忘记把逾期未还的status = 2也算作占用。解决方法是统一定义一个状态枚举,并且在所有查询可用数量的地方使用同一个条件status IN (0, 2)。更稳妥的做法是把这个条件封装成视图:
CREATE VIEW v_book_available AS SELECT b.id, b.isbn, b.title, b.author, b.total_copies - IFNULL(( SELECT COUNT(*) FROM borrow_records br WHERE br.book_id = b.id AND br.status IN (0, 2) ), 0) AS available FROM books b;之后所有查询都从视图走,避免条件写错。
4.2 存储过程里忘记加 FOR UPDATE,并发借书超借
现象:两个人同时借同一本只剩 1 册的书,系统都提示借书成功,结果库存变成 -1。原因是在存储过程中查询可借数量时没有加行锁,两个事务同时读到“可借数量为 1”,然后都执行插入。解决方法是在查询图书和读者时使用SELECT ... FOR UPDATE,让第二个事务等待第一个事务提交后再读取最新数据。注意FOR UPDATE必须在事务内使用,且要确保查询条件走了索引,否则会锁表。
4.3 日期计算用错函数,逾期天数算成负数
现象:还书时明明没有逾期,却提示逾期 -3 天,罚金为负数。原因是DATEDIFF(CURDATE(), due_date)的参数顺序写反了,或者due_date存储的是字符串而不是 DATE 类型。解决方法是统一用 DATE 类型存储日期,并且用DATEDIFF(return_date, due_date)计算,只有结果大于 0 才算逾期。可以在还书存储过程里加一个IF v_overdue_days > 0的判断,负数直接归零。
4.4 外键约束导致删除图书失败,但错误信息看不懂
现象:想删除一本已经借出的图书,数据库报错Cannot delete or update a parent row: a foreign key constraint fails。原因是borrow_records表有外键指向books,直接 DELETE 会被阻止。解决方法不是关闭外键检查,而是先处理借阅记录:要么把图书标记为“已下架”而不是物理删除,要么先删除或归档相关的借阅记录。推荐用软删除,在books表加is_deleted字段,查询时过滤掉。
4.5 前端调用存储过程后没有提交事务,数据没保存
现象:借书接口返回“借书成功”,但刷新页面后借阅记录没增加。原因是 PyMySQL 默认 autocommit 关闭,存储过程内部的 COMMIT 在某些驱动版本下不会自动生效,需要在 Python 代码里显式调用conn.commit()。解决方法是在每个写操作的try块最后加上conn.commit(),并且在异常处理里conn.rollback()。如果用的是连接池,还要注意连接归还前的事务状态。
5. 进阶技巧:用 SQL 审计和定时事件把系统管起来
5.1 用触发器记录关键表的变更历史
图书管理系统上线后,最怕的是“谁把这本书的库存改了”或者“谁删了这条借阅记录”。可以在books和borrow_records上各加一个审计触发器,把变更前后的数据写入audit_log表。这样出问题时能追溯,也方便做数据对账。
CREATE TABLE audit_log ( id INT PRIMARY KEY AUTO_INCREMENT, table_name VARCHAR(50), record_id INT, action_type VARCHAR(10), old_value TEXT, new_value TEXT, changed_by VARCHAR(50), changed_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TRIGGER trg_books_update AFTER UPDATE ON books FOR EACH ROW BEGIN INSERT INTO audit_log (table_name, record_id, action_type, old_value, new_value, changed_by) VALUES ('books', OLD.id, 'UPDATE', CONCAT('total_copies=', OLD.total_copies), CONCAT('total_copies=', NEW.total_copies), USER()); END;这个触发器只记录了total_copies的变化,实际可以根据需要记录更多字段。USER()返回当前数据库连接的用户名,如果是应用统一用 root 连接,这个字段意义不大,可以改成从应用层传入操作人。审计日志表会越来越大,建议按月分区或者定期归档。
5.2 用 MySQL 事件定时标记逾期记录
借阅记录不会自动变成“逾期”状态,需要定时任务扫描。MySQL 从 5.1 开始支持事件调度器,可以每天凌晨跑一次,把due_date < CURDATE()且status = 0的记录更新为status = 2。
SET GLOBAL event_scheduler = ON; CREATE EVENT ev_mark_overdue ON SCHEDULE EVERY 1 DAY STARTS CONCAT(CURDATE() + INTERVAL 1 DAY, ' 00:05:00') DO BEGIN UPDATE borrow_records SET status = 2 WHERE status = 0 AND due_date < CURDATE(); END;注意event_scheduler默认可能是 OFF,需要先开启。事件执行时间设在凌晨 0:05,避开业务高峰。如果数据库重启,事件调度器需要重新开启,可以在配置文件里加event_scheduler=ON持久化。
5.3 一个验证数据一致性的对账 SQL
无论有没有用冗余字段,都建议定期跑一次对账查询,检查books.total_copies和实际借阅记录是否匹配。下面这条 SQL 会列出所有“可借数量为负”或者“可借数量大于总库存”的异常图书:
SELECT b.id, b.isbn, b.title, b.total_copies, (SELECT COUNT(*) FROM borrow_records br WHERE br.book_id = b.id AND br.status IN (0, 2)) AS borrowed, b.total_copies - (SELECT COUNT(*) FROM borrow_records br WHERE br.book_id = b.id AND br.status IN (0, 2)) AS available FROM books b HAVING available < 0 OR available > b.total_copies;这条查询在借阅记录表数据量大时可能较慢,可以放在低峰期执行,或者只对最近有变更的图书跑。如果发现异常,优先检查存储过程和触发器的逻辑,而不是手动改数据。
5.4 我自己的习惯:先写测试 SQL,再写应用代码
做了几个图书管理系统的项目之后,我养成了一个习惯:任何存储过程或触发器写完后,先不急着接前端,而是在 MySQL 命令行里用几条测试 SQL 把边界情况跑一遍。比如借书时读者不存在、库存为 0、读者已借满、并发借同一本书,还书时记录不存在、逾期 1 天、逾期 30 天。这些用例跑通了,再写 Python 接口,能省掉大量联调时间。另外,每次改完存储过程,我都会重新执行一遍init_data.sql重建数据库,确保从零开始也能跑通。这个习惯看起来笨,但能避免“在我机器上是好的”这类问题。希望帮到你。
本文还有配套的精品资源,点击获取