
做毕设这几年被问到最多的题目其实是这种看起来一点不高大上的“书城购物系统”。很多人第一反应是不就是个商品增删改查但真正动手以后才发现一个基于Spring Boot的在线图书电商平台背后牵扯到的用户认证、商品检索、购物车、订单、库存、支付模拟几乎把Java Web业务开发的主线全走了一遍。这篇分享我就按自己从零搭这套系统的实际顺序把需求拆解、技术选型、数据库设计、核心编码和调试踩坑完整写一遍。正在发愁毕设选题或者想补一个拿得出手的Java Web项目经验的人可以直接照这条路径往下走先跑通再扩展。1. 先把这个题目的真实需求拆清楚1.1 这道题考的不是“会写购物车”许多同学一看“书城购物系统”就以为是简单的CRUD急着打开IDE写代码。实际上导师之所以认这个题是因为它把电商业务里最核心的场景全装了进来。拆开看就是两条线前台是用户视角注册登录、逛书城、搜书、加购物车、下单、支付、看订单后台是管理员视角维护图书、分类、库存、订单状态。把这些功能拉通以后真正的技术考点才浮出来。第一个是认证与权限普通用户和管理员怎么区分哪些接口必须登录才能调第二个是商品检索与分页条件组合查询怎么写得既灵活又不出错第三个是购物车到订单的状态转换订单从待支付到已取消之间到底有哪几条合法路径第四个是库存一致性两个人同时买同一本书会不会超卖第五个是事务与异常下单做到一半失败时前面的操作能不能整体回滚。这五个点每个都能在答辩现场展开讲十分钟。1.2 功能边界哪些必须有哪些可以当亮点做毕设最忌讳的是没有边界感。我见过有人非要给书城加优惠券、秒杀、签到、积分兑换结果答辩前一周还在改优惠券过期逻辑连最基础的下单流程都没演示顺畅。按下面的清单来把必做项稳定跑通选做项挑一个做到位已经能超过大部分同期作品了。前台功能用户注册、登录、退出必做图书列表、分类筛选、关键字搜索必做图书详情展示必做购物车添加、修改数量、删除、勾选结算必做订单提交与模拟支付必做订单列表、订单详情、待支付状态取消订单必做收货地址管理选做个人中心、图书评价可作亮点后台功能管理员登录必做图书管理包括新增、编辑、上架下架必做图书分类管理必做订单管理包括查看明细、发货操作必做用户管理选做销量统计图表可作亮点这种题目最友好的地方在于边界清楚每一条业务线都可以单独讲清楚。哪怕只有一个订单模块只要你把状态机、事务、库存扣减讲明白导师就会觉得你是真做了东西。1.3 为什么这个题目对新手相对友善网上关于“Spring Boot 图书商城”的资料非常多你遇到的绝大多数问题都能搜到答案不会像冷门选题那样卡死在一个未知报错上。它还有一个隐性优势就是写论文很方便。题目里的“在线图书电商平台”对应互联网文化消费“数字化书籍交易管理系统”对应业务流程信息化这两个定位写进开题报告和需求分析非常自然不用硬造背景。整篇论文的章节结构也很顺选题背景、需求分析、系统设计、数据库设计、核心实现、系统测试每一段都有真实代码和数据支撑。2. 技术选型与项目初始化我为什么这么配2.1 后端Spring Boot 2.7 MyBatis-Plus如果让我给一个标准答案我会选JDK 8或11Spring Boot 2.7.xMyBatis-Plus 3.5.xMySQL 8.0Redis按需选配登录态用JWT。这套组合最大的优点是没有一样是冷门的遇到问题随便一搜就是现成方案。有人纠结要不要上Spring Boot 3.x我的建议是不要。Spring Boot 3要求JDK 17很多学校机房和老机器装的是JDK 8而且3.x之后包名从javax变成了jakarta你搜到的很多旧方案直接对不上。毕设的第一目标是稳定不是追新。Spring Boot 2.7虽然不是最新但依然有长期的社区维护兼容资料最齐全作为课程设计和毕业设计完全够用。核心依赖大概是这样dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdorg.springframework.security/groupId artifactIdspring-security-crypto/artifactId /dependency这里只引了核心的几样。注意spring-security-crypto不是一整条Security框架它只提供BCryptPasswordEncoder这类密码哈希工具不会给你加一堆默认过滤器也不影响我们的轻量级拦截器方案。至于为什么用MyBatis-Plus而不是JPA我是这么想的JPA的查询能力很强但它的隐式行为太多了懒加载、一级缓存、实体状态管理有一个没理解透就突然报错。MyBatis-Plus则很直观一张表对应一个实体类BaseMapper直接给好CRUDQueryWrapper可以写条件查询分页插件拉出来就能用。对新手来说能看到SQL的ORM比黑盒ORM友好得多答辩时也更容易解释“我这条SQL是怎么查出来的”。2.2 前端到底要不要前后端分离这是一个两难选择。前后端分离用Vue3 Element Plus Axios做出来的页面干净现代答辩显得专业但它要求你理解Node环境、跨域、异步组件联调阶段很考验耐心。如果选Thymeleaf Bootstrap服务端渲染一个工程直接启动不用额外起前端服务也不用处理跨域部署非常省事缺点是页面交互感相对传统。我的倾向很直接还有两个月以上时间可以上Vue3视觉效果和答辩加分都值只剩两三周就老实选Thymeleaf先把业务逻辑跑通。下面主要按后端REST接口的思路来讲这套思路不管你前端最终用哪种技术都能对接上。2.3 工程结构与应用配置工程分包这件事我在辅导时强调过无数遍。不要把所有类都往controller里塞一个像样的Spring Boot项目至少要有这样的分层com.bookcity ├── controller # 接口层 ├── service # 业务逻辑层 ├── mapper # MyBatis-Plus数据层 ├── entity # 实体类 ├── dto # 请求/响应的数据传输对象 ├── vo # 页面视图对象 ├── config # 配置类比如分页插件、跨域、拦截器注册 ├── interceptor # 登录鉴权拦截器 ├── common # 统一返回体、异常处理、常量 └── BookcityApplication.java每次我看到有人把业务逻辑全写在Controller里就知道后面维护要吃苦。比如想统一处理登录状态只要在interceptor包加一个类就行不用改几十个接口。配置文件方面最容易被坑的是数据库时区和编码我习惯这样写server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/bookcity?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这里的logic-delete配置我先卖个关子后面数据库设计部分会详细讲为什么需要它。3. 数据库设计让评审一眼看出专业度3.1 核心表结构与字段设计数据库设计是整套系统的地基。我按业务域划分三块用户域、商品域、交易域。交易域是重头包含购物车、订单、订单明细、支付流水。下表是最简化的核心表清单表名用途关键字段user用户信息id, username, password, nickname, role, statusbook_category图书分类id, name, sortbook图书信息id, category_id, title, author, publisher, isbn, price, stock, sales, status, cover_urlcart_item购物车条目id, user_id, book_id, quantity, checkedorders订单主表id, order_no, user_id, total_amount, status, receiver_infoorder_item订单明细id, order_id, book_id, book_title, book_price, quantityaddress收货地址id, user_id, receiver_name, receiver_phone, detailpay_log支付流水id, order_no, pay_amount, pay_type, pay_time图书表的DDL可以做成这样CREATE TABLE book ( id BIGINT NOT NULL AUTO_INCREMENT, category_id BIGINT NOT NULL COMMENT 分类ID, title VARCHAR(200) NOT NULL COMMENT 书名, author VARCHAR(50) DEFAULT NULL COMMENT 作者, publisher VARCHAR(100) DEFAULT NULL COMMENT 出版社, isbn VARCHAR(30) DEFAULT NULL COMMENT ISBN, price DECIMAL(10,2) NOT NULL COMMENT 售价, stock INT NOT NULL DEFAULT 0 COMMENT 库存, sales INT NOT NULL DEFAULT 0 COMMENT 销量, cover_url VARCHAR(255) DEFAULT NULL COMMENT 封面图, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, deleted TINYINT NOT NULL DEFAULT 0 COMMENT 逻辑删除, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, PRIMARY KEY (id), KEY idx_category_id (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;订单主表的设计同样关键CREATE TABLE orders ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 订单号, user_id BIGINT NOT NULL COMMENT 下单用户, total_amount DECIMAL(10,2) NOT NULL COMMENT 订单总金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已发货 3已完成 4已取消, receiver_name VARCHAR(50) DEFAULT NULL, receiver_phone VARCHAR(20) DEFAULT NULL, receiver_address VARCHAR(255) DEFAULT NULL, pay_time DATETIME DEFAULT NULL, delivery_time DATETIME DEFAULT NULL, finish_time DATETIME DEFAULT NULL, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里故意没有创建外键后面会说明原因。3.2 订单、库存、金额的几个关键决策有几个决策必须在建表前定下来不然后期重构非常痛苦。金额字段一律用DECIMAL(10,2)不要用double或float。浮点数在计算机里本身就是近似值算总价的时候很容易出现0.10.2不等于0.3这类问题。数据库里存金额价格、总价、支付流水全部用定点数计算结果才能精确。订单号不要用数据库自增。订单号会出现在支付流水、日志、快递单号关联等多个地方自增id可读性差也容易暴露系统业务量。我用的是时间戳用户ID随机数的组合或者直接用一个雪花ID生成器塞进Long里。不管用哪种都要在订单号上加唯一索引这是防止重复下单的重要兜底。订单明细必须做“快照”。快照听起来很专业其实核心就是下单那一刻把图书名称、图书单价、封面URL连同book_id一起存进order_item。为什么这么做因为一本书三个月后完全可能改价、改书名甚至下架但历史订单必须还原当时的交易信息不能跟着商品变动。答辩时讲到这个点导师会觉得你真的理解业务。库存字段就直接放在book表里不需要独立的库存服务。扣减库存时用“条件更新”来保证安全细节我在第五章展开说。3.3 逻辑删除、索引与外键的态度在MyBatis-Plus里给实体类加一个TableLogic注解再把全局配置里的logic-delete-field配上就能让deleted字段自动生效。查询时自动带deleted0删除操作变成UPDATE ... SET deleted1。这个设计在用户和订单表里尤其有价值哪天你想恢复数据还能找回来直接物理删除的风险太大了。外键我一般不怎么建。单机小系统建外键倒也没问题但电商业务中订单和商品、订单和用户之间的一致性更依赖业务逻辑控制而不是数据库约束。以后如果扩展成微服务数据库层面根本无法跨服务建外键。所以我的习惯是表里只建普通索引在Service层做一致性校验。索引方面至少要覆盖这几处book.category_id支撑分类筛选orders.user_id支撑“我的订单”查询orders.order_no唯一索引支撑订单精确查找cart_item.user_id支撑购物车加载这些索引量级不大但都是实际查询会走的热点路径。4. 核心业务功能从0到1落地4.1 认证与鉴权BCrypt JWT用户表里一定不要明文存密码。我见过不少课程设计把password字段直接存“123456”答辩时一查数据就看到观感非常差。正确做法是使用BCrypt加密BCryptPasswordEncoder encoder new BCryptPasswordEncoder(); // 注册时 String hashed encoder.encode(userDTO.getPassword()); user.setPassword(hashed); // 登录校验时 boolean matched encoder.matches(rawPassword, user.getPassword());为什么不用MD5MD5可以被彩虹表反查加盐也只是增加一点破解成本。BCrypt是专门为口令哈希设计的算法每次加密会自动混入随机盐同样的明文每次加密结果都不同安全性完全不在一个级别。登录成功以后我生成一个JWT返回给前端。JWT里主要放userId、username、role过期时间设24小时public String createToken(Long userId, String username, String role) { SecretKey key Keys.hmacShaKeyFor(SECRET.getBytes()); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(username, username) .claim(role, role) .setExpiration(new Date(System.currentTimeMillis() 24 * 3600 * 1000)) .signWith(key, SignatureAlgorithm.HS256) .compact(); }鉴权部分用拦截器实现比引入整套Spring Security轻得多。在WebMvcConfigurer里注册一个HandlerInterceptor先定义哪些路径放行比如注册、登录、图书列表、图书详情其余以/api/order/、/api/cart/开头的接口都要求Header里存在合法的Authorization: Bearer token。拦截器解析token成功后把userId塞进Request AttributeService层从那里取当前操作人避免接口到处传用户参数。4.2 图书检索与分页查询前台的图书列表页几乎必然要接受多个筛选项分类、关键字、价格区间、上架状态。用MyBatis-Plus的LambdaQueryWrapper写起来非常舒服LambdaQueryWrapperBook wrapper Wrappers.lambdaQuery(); if (StringUtils.hasText(keyword)) { wrapper.and(w - w.like(Book::getTitle, keyword) .or().like(Book::getAuthor, keyword) .or().like(Book::getPublisher, keyword)); } if (categoryId ! null) { wrapper.eq(Book::getCategoryId, categoryId); } if (priceMin ! null) { wrapper.ge(Book::getPrice, priceMin); } if (priceMax ! null) { wrapper.le(Book::getPrice, priceMax); } wrapper.eq(Book::getStatus, 1); wrapper.orderByDesc(Book::getCreateTime); PageBook page bookMapper.selectPage(new Page(pageNum, pageSize), wrapper);这里有一个许多新手都会踩的语法坑关键字条件里的and嵌套or。如果直接写wrapper.like(title, kw).or().like(author, kw)SQL的or优先级会把它后面所有条件都带偏分类和价格筛选就会失效。正确写法是先在wrapper.and(w - ...)里面做or组合让这一组条件先作为一个整体再去连接外面的eq条件。分页插件必须在配置类里注册否则selectPage查询不会真正拼接LIMITConfiguration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }4.3 下单事务、库存和快照下单是整个系统最核心的方法也是最容易被追问的地方。方法上的Transactional不是随便加的它要保证“创建订单-写明细-扣库存-清购物车”这四步要么全部成功要么全部回滚Transactional(rollbackFor Exception.class) public OrderVO createOrder(Long userId, ListCartItemDTO checkedItems) { // 1. 校验商品状态和库存 for (CartItemDTO item : checkedItems) { Book book bookMapper.selectById(item.getBookId()); if (book null || book.getStatus() ! 1) { throw new ServiceException(商品不存在或已下架); } if (book.getStock() item.getQuantity()) { throw new ServiceException(库存不足); } } // 2. 生成订单主表 String orderNo generateOrderNo(); Order order buildOrder(userId, orderNo, checkedItems); orderMapper.insert(order); // 3. 写明细 扣库存 for (CartItemDTO item : checkedItems) { orderItemMapper.insert(buildOrderItem(order.getId(), item)); int rows bookMapper.deductStock(item.getBookId(), item.getQuantity()); if (rows 0) { throw new ServiceException(库存不足); } } // 4. 删除已结算的购物车记录 cartItemMapper.deleteBatchIds(cartItemIds); return orderVO; }扣库存的SQL是这段代码的精华UPDATE book SET stock stock - #{count}, sales sales #{count} WHERE id #{id} AND stock #{count}注意这里的AND stock #{count}这不是普通的update而是一个带条件约束的原子操作。在MySQL InnoDB的行锁机制下同一时刻只有一个事务能更新这一行并发请求会排队执行。如果库存不足实际影响行数为0代码里抛异常整个事务回滚库存不会被扣成负数。等讲第五章超卖问题时你会看到这个写法是最小且最可靠的方案。4.4 模拟支付与完整状态流转一个电商系统如果只能创建订单却无法支付答辩时被问“那怎么算交易完成”就很尴尬。我推荐做一个模拟支付接口把业务闭环做完用户点“去支付”后前端调用POST /api/order/{orderNo}/pay。后端做三件事校验订单属于当前登录用户、校验订单状态确实是待支付、生成一条支付流水并记录支付时间然后把订单状态从“待支付”改为“已支付”。管理员后台看到已支付订单后可以点“发货”状态变为“已发货”用户收到货后点“确认收货”状态变为“已完成”。待支付状态下用户也可以主动取消订单。如果想让演示效果更炫可以做一个假的二维码弹窗用户点击“模拟扫码成功”前端通过轮询订单状态来更新页面。加一张支付流水表会让这套设计更完整因为支付金额、支付时间、支付方式都被记录在案查账和统计都有据可依。现阶段不需要真的接支付平台沙箱环境对毕设来说已经很耗费精力模拟支付足够体现业务理解了。5. 调试与上线阶段我踩过的几个大坑5.1 库存超卖并发扣减的排查与修复有一年一个读者在群里求助说用并发工具模拟30个用户同时买同一本书库存明明设了10最后压出来变成-2。他把代码贴出来我一眼就猜到问题他是先SELECT stock在Java里判断stock 0再执行UPDATE book SET stock stock - 1。这个流程在单线程下没问题多线程下就崩了。两个线程同时读到stock1都认为自己能买先后执行更新最后库存变0但第二个人本不应该买到。更极端时库存可能被直接扣成负数。排查链路是固定的先用压测工具复现并发场景再去看日志和SQL确认问题出在“先查后改”这条非原子路径上。修复方式就是把它改成一条带条件的UPDATE。改完以后用同样的并发脚本再压一遍库存只会停在0到正数之间超卖消失。如果导师追问“高并发怎么办”你可以如实回答当前方案靠数据库行锁保证正确性单机部署规模下完全够用如果要扩展到集群再引入Redis预扣减Lua脚本。但那是微服务架构的话题对这个课题来说属于过度设计。5.2 事务不生效的三种常见原因事务不生效是这类项目里最隐蔽的坑表现形式是下单到一半报错结果订单没回滚库存却扣了。最常见的原因有三个。第一个是同类内部自调用。比如Service里有一个createOrder方法又在同一个类里的另一个方法写了this.createOrder()。Spring的Transactional通过代理对象生效this调用绕过了代理事务自然不生效。解决办法是把被调用的方法放进另一个Bean或者从Controller直接调Service方法。第二个是异常被吞掉。很多人习惯在事务方法里写Transactional public void createOrder(...) { try { // 扣库存、写订单 } catch (Exception e) { log.error(下单失败, e); } }这段代码最大的问题不是捕获了异常而是捕获之后没有重新抛出。事务看到的是“方法正常返回”它不知道业务失败于是不会回滚。正确做法是捕获后记录日志再重新抛出业务异常。第三个是异常类型不匹配。Transactional默认只对RuntimeException回滚如果你抛的是一个受检异常比如Exception本身事务就不会回滚。所以我习惯显式写Transactional(rollbackFor Exception.class)明确告诉Spring所有异常都要回滚。5.3 分页插件与多表查询的count问题MyBatis-Plus分页插件在单表场景下很好用但当你自定义SQL比如把book和category连表查询时插件自动生成的count语句偶尔会出问题。最直观的表现是总条数不对或者分页到第二页时出现重复数据。排查过程通常是先看控制台打印的count SQL发现JOIN条件被优化丢了一部分导致count结果少于实际总行数。解决办法有三种一是尽量把分类名称等展示字段冗余到DTO/VO里用单表分页再组装这是最推荐的方式二是自己手写count语句三是调整分页插件的join优化参数。对书城这种规模我建议走第一种简单、可控、不用折腾插件。5.4 前后端联调的跨域和Long精度问题前后端分离模式下第一个拦路虎就是跨域。Vite的devServer默认跑在5173后端跑在8080浏览器会拦截跨域请求。后端要开放跨域配置Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }另一个特别经典的坑是Long类型精度丢失。Java后端主键用Long没有问题但返回给前端后JavaScript的Number类型超过9007199254740992就会丢精度。你用了雪花ID或者订单号超过这个数以后前端拿到的ID末尾几位会变成0再传回后端就查不到数据了。解决办法是给实体类的主键字段加JsonSerialize(using ToStringSerializer.class)或者前端统一按字符串处理ID。这个小细节在答辩时提一下很能体现工程经验。6. 让这个毕设从“能跑”到“答辩能过”的加分项6.1 统一返回体与全局异常处理接口返回格式如果不统一前端写起来非常难受。我习惯用一个三字段的ResultT结构成功就是code200, msgok, data业务数据业务异常就是code400, msg具体原因未登录就是code401。每个Controller方法都返回这个结构前端Axios拦截器里一次解析。配合全局异常处理器把未知异常、参数校验异常、业务异常分开处理。不要小看这个半小时就能完成的改进它能把几百行重复的try/catch从Controller里清光代码规范度抬升一个台阶。6.2 接口文档与演示数据准备如果用了Spring Boot可以直接集成Knife4j启动后访问/doc.html就能看到接口文档。演示环节不用当场翻代码把接口文档打开一页页展示给老师看专业感立刻拉满。演示数据要提前准备好。SQL初始化脚本里预置一个管理员账号、一个普通用户账号、三个分类、十几本图书。登录后最好已经有一条待支付的购物车记录这样点开页面就能直接演示下单不用现场临时找书、临时加购。图片资源尽量放到项目的静态目录不要用外链图床。我见过有人答辩现场断网图书封面全裂观感很受影响。稳定永远是演示的第一原则。6.3 部署环境搭建与演示稳定性项目打包成jar后用java -jar bookcity.jar启动比依赖IDE运行要稳得多。部署服务器时注意放行端口、配置数据库编码和时区把初始化SQL导入后在配置文件里确认连接无误。数据库和服务端的时区一定要统一。一旦serverTimezone配错订单创建时间会比实际差8个小时演示时容易被看出“系统时间不对”。日志里也要保留下单、支付的关键操作万一现场出bug看日志比猜原因快得多。6.4 答辩时最容易被追着问的三个问题第一个问题“为什么不用JSP”回答思路JSP是Java Web早期的服务端渲染方案Spring Boot官方更推荐Thymeleaf或前后端分离REST接口。项目本质上还是完整的Java Web后端逻辑与页面渲染技术的选择无关。第二个问题“为什么不用Spring Security”回答思路拦截器JWT已经实现了认证与鉴权代码直观出了问题更容易定位。Spring Security功能更全面但对这个规模的项目是过度设计还会增加学习和配置成本。第三个问题“如果用户量大了怎么办”回答思路当前单机架构可以满足课程设计规模。扩展方向包括Redis缓存热门图书、订单异步化、数据库读写分离、再往后才是分库分表。关键在于体现你知道演进路径而不是真的去把它实现成微服务。这套系统我前前后后带人做过好几轮最大的体会是毕设不是真的做一个淘宝而是把一个边界明确的小系统做到每个环节都能自圆其说。宁可少一个花哨功能也要把登录、下单、支付、改状态这条主线打磨干净。最后再分享一个小技巧开发前先用一张状态流转图把订单从待支付到已完成的每一步写清楚然后照着图写接口。状态理清了代码写起来会顺非常多。