简介:一份面向Java初学者的课程设计实践源码包,围绕学生信息管理系统的完整实现展开。项目覆盖学生信息的增加、删除、修改与查询,同时融入Swing图形界面、事件监听、异常处理、文件序列化及JDBC数据库连接等关键Java技术,并应用了单例、工厂、MVC等设计模式,适合正在完成课程设计、备战期末项目或希望巩固面向对象编程的读者。资源共54个文件,含29个class编译产物、22个java源码文件,以及Eclipse工程配置(project、classpath、prefs),压缩包仅51KB,可导入开发环境直接查看运行效果。从文件结构看,源码涉及登录、学生管理、成绩查询、信息插入等模块,便于按功能拆分阅读和二次开发;对撰写课程设计报告中的技术分析、类图说明也有参考价值。目前已有558人学习下载,是一份轻量而完整的Java综合练习样本。
1. Java学生信息管理系统课程设计:从选题到答辩的一次完整拆解
学生信息管理系统是Java Web课程设计里出现率最高的题目之一,往届源码里十个项目至少两三个是它。功能本身不复杂:学生信息的增删改查、班级课程管理、成绩录入与统计。但想拿高分,光把CRUD跑通远远不够,数据库设计、分层架构、权限控制、答辩演示,任何一环出问题都会被评委追问到卡壳。
我拆过好几份学生信息管理系统的课程设计源码,也帮人调过跑不起来的烂尾项目。这份资源的核心价值在于,它不是孤立的代码包,而是把从需求分析到数据库建模、后端分层、前端分页、最终答辩的完整路径都串了起来。适合正在赶课程设计的在校生,也适合想快速过一遍JSP+Servlet+JDBC经典栈的初学者。
下面按我自己复现这套项目的顺序展开:先定表结构,再写后端代码,补前端页面,最后集中讲避坑和答辩准备。每个环节我都会给出直接能抄的代码和参数说明。
2. 系统设计与数据库建模:先定表结构,再写代码
2.1 需求拆解:课程设计要覆盖哪些功能
拿到这种项目,别急着打开IDE敲代码。先想清楚系统到底给谁用。很多课程设计源码只有一个管理员角色,所有操作堆在一个页面,演示起来倒是省事,但答辩时评委一句“你的系统和其他人的有什么区别”就能把你问住。
常见做法是设计三种角色:管理员、教师、学生。管理员维护班级、课程、用户账号;教师录入和修改成绩;学生只能查询自己的相关信息。角色不同,菜单和按钮就不同,这就顺理成章地带上了权限控制,是答辩时的加分点。
功能清单我习惯用一张表列出来,做数据库设计时逐项核对:
| 功能模块 | 角色 | 具体操作 |
|---|---|---|
| 登录注销 | 全部 | 用户名密码登录、Session校验、退出 |
| 学生管理 | 管理员 | 新增、编辑、删除、按班级/关键字筛选 |
| 班级管理 | 管理员 | 班级增删改查、学年设置 |
| 课程管理 | 管理员 | 课程信息维护、学分设置 |
| 成绩录入 | 教师 | 单条/批量录入、修改成绩 |
| 成绩查询 | 学生/教师 | 按学期查、按课程查、平均分统计 |
这张表就是后面建表、写Dao、做页面的依据。我没有把统计报表设计成花哨图表,课程设计阶段用SQL的AVG、COUNT做简单统计就够了,做得太重反而容易在演示时卡顿出丑。
2.2 数据库表设计:四张核心表与关键约束
数据库是整个系统的地基。表设计不合理,后面写DAO时每个方法都在补坑。学生信息管理系统最少需要四张表:班级表、学生表、课程表、成绩表。班级和学生是一对多,学生和课程通过成绩表形成多对多关系。
建表SQL如下:
-- 班级表 CREATE TABLE t_class ( id INT PRIMARY KEY AUTO_INCREMENT, class_name VARCHAR(50) NOT NULL COMMENT '班级名称', grade_year INT NOT NULL COMMENT '入学年份' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 学生表 CREATE TABLE t_student ( id INT PRIMARY KEY AUTO_INCREMENT, stu_no VARCHAR(20) NOT NULL UNIQUE COMMENT '学号', name VARCHAR(50) NOT NULL COMMENT '姓名', gender TINYINT DEFAULT 1 COMMENT '1男 0女', birthday DATE, class_id INT NOT NULL, phone VARCHAR(20), status TINYINT DEFAULT 1 COMMENT '1在读 0离校', FOREIGN KEY (class_id) REFERENCES t_class(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;学号加了UNIQUE约束,防止录重。status字段是逻辑删除标志,默认1,删除学生时只把它改成0,不是真删。逻辑删除的好处是历史成绩单还能关联到学生,演示时误删数据也有“后悔药”可吃。不用物理删除,这是我反复强调的点。
课程表和成绩表继续:
CREATE TABLE t_course ( id INT PRIMARY KEY AUTO_INCREMENT, course_name VARCHAR(100) NOT NULL, credit INT DEFAULT 2 COMMENT '学分' ); CREATE TABLE t_score ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, score DECIMAL(5,2), exam_date DATE, term VARCHAR(20) COMMENT '例如2024-2025-1', UNIQUE KEY uk_stu_course (student_id, course_id, term), FOREIGN KEY (student_id) REFERENCES t_student(id), FOREIGN KEY (course_id) REFERENCES t_course(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;成绩表的联合唯一约束是关键中的关键。没有它,同一个学生同一学期同一门课可以被insert出两条记录,做成绩统计时平均分直接被翻倍。加了这个约束,重复插入直接报错,从数据库层面挡住脏数据。
关于外键建不建,有两派意见。课程设计这种小项目我建议建上。第一,演示时删除被外键引用的数据会报错,反而能体现你懂约束;第二,答辩时外键关系是好素材。成绩字段用DECIMAL(5,2),不用FLOAT,浮点数的存储误差在展示平均分时会很难看。
2.3 配置文件与数据库连接:换环境不焦虑
很多初学者写的源码把数据库连接写死在Dao类里,别人拿到项目换台电脑就得改十来处。正确做法是连接信息和密码放到properties文件,用连接池管理连接。我一般用Druid,启动快,参数直观。
# src/main/resources/jdbc.properties jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/student_mis?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=你的密码 jdbc.initialSize=5 jdbc.maxActive=20读取配置的数据库工具类:
public class DBUtil { private static DataSource dataSource; static { try { Properties props = new Properties(); ClassLoader cl = DBUtil.class.getClassLoader(); props.load(cl.getResourceAsStream("jdbc.properties")); dataSource = DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }static代码块确保类加载时只初始化一次连接池。每次getConnection其实就是从池子里借连接,用完调用close后会归还池子而不是真的关闭。URL里的serverTimezone=Asia/Shanghai是MySQL 8之后必写的,不写就报时区错误。characterEncoding=utf8配合建表时的utf8mb4,能把中文乱码问题挡在数据库这一层。
提示:复制这份资源到自己电脑时,只要改jdbc.password和database名,其他地方不用动。把配置从代码里拆出来,换环境才不焦虑。
3. 分层架构与核心代码实现:实体类、DAO、Service与Servlet
3.1 分层包结构与实体类:让代码自己会说话
课程设计不需要上微服务,但分层思想必须有。我习惯的包结构如下:
src/main/java ├── cn/edu/demo/entity 实体类 ├── cn/edu/demo/dao 数据访问接口与实现 ├── cn/edu/demo/service 业务逻辑层 ├── cn/edu/demo/servlet 控制层 ├── cn/edu/demo/filter 过滤器 └── cn/edu/demo/util 工具类实体类简单,一个JavaBean对应一张表:
public class Student { private Integer id; private String stuNo; private String name; private Integer gender; private Date birthday; private Integer classId; private String phone; private Integer status; // getter / setter 省略 // 无参构造省略 }用Integer而不用int,是为了让映射工具在遇到数据库NULL时不报错。字段名和数据库列名保持驼峰与下划线对应,后面写ResultSet映射时省心。DAO接口:
public interface StudentDao { List<Student> findByCondition(String keyword, Integer classId, int offset, int pageSize); int countByCondition(String keyword, Integer classId); Student findByStuNo(String stuNo); int insert(Student stu); int update(Student stu); int deleteById(Integer id); // 逻辑删除,update status=0 }接口里写“做什么”,实现类里写“怎么做”。课程设计里很多同学把业务操作全塞进Servlet,串行代码又长又难调。把数据访问抽到DAO层后,Servlet只负责接收请求、调用Service、跳转页面。小项目可以省略Service直接Servlet调DAO,但为了答辩时有话说,建议保留一个薄Service层,哪怕只是转调DAO,也能让你在回答“分层设计”时有的可讲。
3.2 登录与权限控制:Session管理和密码加密
登录模块是整个系统最容易出问题的位置。如果密码明文存数据库,评委看一眼就会扣分。简单做法是MD5加盐。
public class MD5Util { public static String md5WithSalt(String password, String salt) { String s = md5(password + salt); return s; } // 内部调用标准MD5,具体实现省略 }登录Servlet的校验逻辑:
@WebServlet("/login") public class LoginServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String username = req.getParameter("username"); String password = req.getParameter("password"); User user = userDao.findByUsername(username); if (user != null && user.getPassword().equals( MD5Util.md5WithSalt(password, user.getSalt()))) { req.getSession().setAttribute("loginUser", user); resp.sendRedirect("index.jsp"); } else { req.setAttribute("error", "用户名或密码错误"); req.getRequestDispatcher("login.jsp").forward(req, resp); } } }判断顺序要记牢:先查用户是否存在,再比对密码。如果用户不存在就直接返回,避免被攻击者通过报错信息探测用户名存在与否。比对密码用equals,别用==,String的内容比较用equals是基础中的基础,但每次都会有人翻车。
权限控制靠过滤器,拦截未登录访问内部页面的请求:
@WebFilter("/*") public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; HttpSession session = req.getSession(false); String uri = req.getRequestURI(); if (uri.endsWith("login.jsp") || uri.endsWith("/login")) { chain.doFilter(request, response); return; } if (session == null || session.getAttribute("loginUser") == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } chain.doFilter(request, response); } }过滤器思路:白名单放行登录相关资源,其余请求一律检查Session。Session为空或没有登录用户,直接踢回登录页。注意req.getSession(false)的写法,参数传false的意思是拿不到Session就返回null,而不是自动创建一个新Session。很多人在这里写getSession(true),结果未登录请求也能“正常”通过,因为过滤器帮他建了个空Session,这就是权限形同虚设的根源。
参数和作用域说明:loginUser是后面JSP判断登录状态、显示用户名的依据。你可以把它换成userId或username,但过滤器和页面里所有引用必须同步改,否则登录成功跳转后页面照样显示未登录。
3.3 通用增删改查封装:BaseDAO与分页查询
学生信息管理系统里,每个DAO实现类都在写重复的SQL和ResultSet映射。我一般抽一个BaseDAO,把通用的更新、查询封装起来。
public abstract class BaseDAO { public int executeUpdate(String sql, Object... params) { try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { for (int i = 0; i < params.length; i++) { ps.setObject(i + 1, params[i]); } return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException(e); } } public <T> List<T> queryList(String sql, RowMapper<T> mapper, Object... params) { List<T> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { for (int i = 0; i < params.length; i++) { ps.setObject(i + 1, params[i]); } try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { list.add(mapper.map(rs)); } } } catch (SQLException e) { throw new RuntimeException(e); } return list; } }executeUpdate把insert、update、delete统一收口,调用方只需要传SQL和参数数组。try-with-resources写法保证Connection、PreparedStatement、ResultSet都会自动关闭,避免连接泄漏。连接一旦泄漏,跑几个小时服务就假死。
参数说明:Object... params是可变参数,调用时直接传(1, "张三")或(new Object[]{1, "张三"})都行。PreparedStatement全程用setObject赋值,比拼字符串SQL安全得多,从根源上避开SQL注入。我见过不少课程设计代码拿字符串拼接SQL,输入框里输个' or 1=1 -- 直接把整张表查出来,那种代码一旦被评委输入测试串,当场社死。
分页查询为什么放这里讲,因为几乎每个列表页都要分页。分页SQL用LIMIT加offset:
SELECT * FROM t_student LIMIT 10 OFFSET 20含义是跳过前20条,取10条,对应第3页的数据。对应的COUNT语句:
SELECT COUNT(*) FROM t_student WHERE 条件分页总数算不对的,基本都是因为COUNT条件没跟上筛选条件。筛选班级后分页总数还是全表的,点下一页直接空白,原因就是COUNT写死了。
4. 前端界面与交互:JSP + JSTL + 组合条件分页
4.1 分页与组合条件查询:让筛选和翻页串起来
在Servlet中,分页和条件查询总是结伴出现。前端传过来的参数可能有三个:当前页码pageNum、关键字keyword、班级ID classId。后端代码:
int pageNum = Integer.parseInt( req.getParameter("pageNum") == null ? "1" : req.getParameter("pageNum")); int pageSize = 10; int offset = (pageNum - 1) * pageSize; List<Student> list = studentDao.findByCondition(keyword, classId, offset, pageSize); int total = studentDao.countByCondition(keyword, classId); int totalPages = (total + pageSize - 1) / pageSize; req.setAttribute("list", list); req.setAttribute("pageNum", pageNum); req.setAttribute("totalPages", totalPages); req.setAttribute("total", total);totalPages的计算公式是向上取整的经典写法:total是23,pageSize是10,(23+9)/10=3,正好3页。如果直接写total/pageSize结果是2页,最后一页3条数据就显示不出来。offset=(pageNum-1)pageSize对应第2页下标从10开始,第1页从0开始,这个换算千万别记错成pageNumpageSize,否则第一页和第二页会重复显示同一批数据。
JSP页面用JSTL输出分页条:
<div class="pagination"> <c:if test="${pageNum > 1}"> <a href="student/list?pageNum=${pageNum-1}&keyword=${keyword}&classId=${classId}">上一页</a> </c:if> <c:forEach begin="1" end="${totalPages}" var="p"> <a href="student/list?pageNum=${p}&keyword=${keyword}&classId=${classId}" class="${p == pageNum ? 'active' : ''}">${p}</a> </c:forEach> <c:if test="${pageNum < totalPages}"> <a href="student/list?pageNum=${pageNum+1}&keyword=${keyword}&classId=${classId}">下一页</a> </c:if> </div>分页链接里必须带上原来的查询条件。用户搜索完“张三”点了第二页,请求里如果没有keyword,后端查出来的还是全表数据,这个坑出现频率极高。classId如果为null或空字符串,拼接URL前要做空值判断,否则会多出一个classId=垃圾参数。
组合条件查询的DAO实现:
public List<Student> findByCondition(String keyword, Integer classId, int offset, int pageSize) { StringBuilder sql = new StringBuilder( "SELECT id, stu_no, name, gender, class_id, phone FROM t_student WHERE status = 1 "); List<Object> params = new ArrayList<>(); if (keyword != null && !keyword.trim().isEmpty()) { sql.append("AND (name LIKE ? OR stu_no LIKE ?) "); params.add("%" + keyword + "%"); params.add("%" + keyword + "%"); } if (classId != null) { sql.append("AND class_id = ? "); params.add(classId); } sql.append("ORDER BY stu_no LIMIT ? OFFSET ?"); params.add(pageSize); params.add(offset); return queryList(sql.toString(), mapper, params.toArray()); }为什么用StringBuilder拼SQL而不是固定SQL?因为查询条件不固定,用户可能只填关键字、只选班级、或者两个都填。用一个List收集参数,避免SQL和参数数量对不上。注意LIKE匹配的keyword要自己拼上%通配符,我没在SQL里写LIKE ?%,为的是让调用方能精确控制通配符位置。
这里有个小细节:条件多了之后,如果SQL里写了AND name LIKE ?但params没传对应值,PreparedStatement执行时会报“参数索引超出范围”。调试时我会先打印最终SQL和参数列表,肉眼对比占位符数量和参数数量,比反复重启Tomcat快得多。
4.2 表单校验:前端JS和后端双重校验
新增和编辑是用户输入大段信息的位置。生日格式不对数据库直接报错,学号重复唯一约束报错,用户一脸懵。课程设计阶段至少要加两层校验。
前端JS负责第一层拦截,用户输错了马上提示,不提交表单:
function validateStudentForm() { var name = document.getElementById("name").value.trim(); var stuNo = document.getElementById("stuNo").value.trim(); var phone = document.getElementById("phone").value.trim(); if (name === "") { alert("姓名不能为空"); return false; } if (!/^\d{6,12}$/.test(stuNo)) { alert("学号格式错误,应为6-12位数字"); return false; } if (phone !== "" && !/^1[3-9]\d{9}$/.test(phone)) { alert("手机号格式不正确"); return false; } return true; }正则含义:学号是6到12位纯数字;手机号以1开头、第二位3到9、后面9位数字共11位。phone是非必填字段,但如果填了必须格式正确。判定顺序先写phone !== "",如果漏掉这个空值判断,用户不填电话时也会被正则拦住,表单直接提交不了。
后端校验是第二层,也是真正起作用的防线。恶意请求可以绕过浏览器直接调接口,前端拦不住。后端至少校验关键字段:
if (student.getName() == null || student.getName().trim().isEmpty()) { throw new IllegalArgumentException("姓名不能为空"); } if (student.getStuNo() == null || !student.getStuNo().matches("\\d{6,12}")) { throw new IllegalArgumentException("学号格式错误"); }Java字符串里匹配数字要写\d,不是\d。正则在Java字符串中需要转义。后端异常统一在Servlet里捕获,catch到IllegalArgumentException后回到表单页显示错误消息,而不是让异常直接打在页面上变成一堆英文堆栈。前端校验提升体验,后端校验保证安全,两个都做,演示时既能展现细节,也能经得起“绕过前端直接调接口”的追问。
5. 避坑与常见问题:课程设计里最容易翻车的五个点
5.1 页面乱码:全链路排查顺序
现象:登录用户名是中文,提交后页面显示乱码,或数据库里存成问号。
原因:Tomcat默认编码、JSP页面编码、MySQL连接编码三方不一致。常见的是JSP页面用了GBK,数据库建表用了utf8mb4,URL里没带characterEncoding,三个环节各转各的,传到数据库就彻底乱了。
解决:按链路统一编码。JSP顶部写pageEncoding="UTF-8",Servlet里在读取参数前执行req.setCharacterEncoding("UTF-8"),连接URL带characterEncoding=utf8,MySQL建库用utf8mb4。四步做完,中文基本不会再出问题。顺序别乱,尤其req.setCharacterEncoding必须在第一个getParameter之前执行。
5.2 ClassNotFoundException:依赖包没进WEB-INF/lib
现象:本地IDE里运行正常,导出war包部署到另一台环境就报ClassNotFoundException,比如Druid或MySQL驱动找不到。
原因:开发时IDE的classpath里有依赖,但项目结构里没有把jar包放到WEB-INF/lib下。Tomcat部署时只认WEB-INF/lib里的jar,不认IDE的classpath配置。
解决:把Druid、MySQL驱动、JSTL标签库这几个jar复制到webapp/WEB-INF/lib目录。如果用Maven工程,在pom.xml里把scope设成compile,确认打war包时jar被包含进来,别用provided,那会让Tomcat误以为外部容器提供这些依赖。检查war文件可以用压缩工具打开看WEB-INF/lib下有没有对应jar,一眼就能确认。
5.3 连接池假死:连接泄漏与超时
现象:系统跑着跑着突然所有页面转圈,重启Tomcat又恢复正常,日志里大量Connection is not available或timeout报错。
原因:大多数是代码里拿到Connection后没有关闭,或者关闭顺序不对,连接被借走不还,池子被掏空。还有一种情况是maxActive设得太大,数据库自身的连接数上限被撑爆。
解决:所有JDBC操作都用try-with-resources写法,Connection、PreparedStatement、ResultSet三个对象在try括号里声明,结束自动关闭。maxActive一般设20以内,initialSize设5,空闲超时设置maxIdle参数。排查时先看数据库最大连接数和当前活跃连接数,对比就能定位是不是代码泄漏。
5.4 逻辑删除与唯一约束冲突
现象:删除一个学号为2024001的学生后,再新增一个同样学号的学生,数据库报Duplicate entry错误,但页面上明明看不到那个已删除的人了。
原因:我在2.2里说了用status逻辑删除而不是物理删除,但学号上还是UNIQUE约束。已删除记录的stu_no还占着坑,真实存在表里,唯一索引照样生效。
解决:三个方案选一个就好。方案一,唯一约束改成复合唯一字段包含status,但逻辑上很绕。方案二,删除时把学号改成一个带时间戳的占位值,比如stu_no_202501011200,这样释放了原学号的唯一坑位。方案三,干脆放弃唯一约束改应用层查询校验。课程设计推荐方案二,演示效果直接,代码改动小。
5.5 MySQL 8 驱动与时区报错
现象:项目在别人电脑上是MySQL 5.7,自己电脑装的是MySQL 8,启动时报错CLIENT_PLUGIN_AUTH is required或The server time zone value is unrecognized。
原因:MySQL 8的JDBC驱动换成com.mysql.cj.jdbc.Driver,旧驱动com.mysql.jdbc.Driver在MySQL 8上不兼容。时区问题是因为MySQL 8默认时区是系统时区,JDBC取连接时需要明确指定serverTimezone。
解决:驱动类换成com.mysql.cj.jdbc.Driver,URL加上serverTimezone=Asia/Shanghai。如果用的是老版本Druid,建议升级到较新版本,老版本Druid解析MySQL 8的驱动元数据偶尔会报异常。这套组合我测试过多次,是最稳妥的MySQL 8搭配。
6. 答辩前必须验证的六个场景:从演示到提问的实战收尾
6.1 演示场景清单
答辩时间通常只有五分钟,现场演示千万别边想边点。我习惯提前把场景固定成一条完整链路,照着走不会漏:
| 顺序 | 操作 | 验证点 |
|---|---|---|
| 1 | 管理员登录 | 错误密码提示、正确密码进入主界面 |
| 2 | 新增一个学生 | 前端校验拦截空值,合法数据入库成功 |
| 3 | 按姓名/班级组合搜索 | 筛选后列表正确,分页链接保留条件 |
| 4 | 修改该学生信息 | 表单回显无误,保存后列表刷新 |
| 5 | 逻辑删除该学生 | 列表不再显示,数据库记录状态变为0 |
| 6 | 教师录成绩、学生查成绩 | 不同角色能看到不同菜单,权限生效 |
这六个场景覆盖了增删改查、权限、分页、逻辑删除四个核心知识点。演示时每步操作说一句“这里我为什么要这么设计”,比闷头点鼠标强得多。
6.2 边界场景准备
评委最爱问的就是边界条件。心里提前有数,现场就不慌。必背的三个问题:删除一个“已被成绩表引用的学生”会怎样?答:外键约束会拦住,提示违反完整性约束,因此我用逻辑删除避免这个问题。分页超过总页数会怎样?答:totalPages是向上取整计算出来的,页码链接超过最后一页就没有data,界面上不会出现空白页。密码为什么不存明文?答:MD5加盐存储,数据库泄露也不会直接看到明文密码。
6.3 一个值得养成的习惯
从那次答辩被问到逻辑删除后,我每次准备这种课程设计类型的项目,都会留出二十分钟把演示链路完整走三遍,其中一遍故意输入错误数据看系统反应。这个习惯让我躲过了不少现场翻车的尴尬场面。希望帮到你。
本文还有配套的精品资源,点击获取