如果你自己动手写过几个 Spring Boot 项目,就会发现“学生校园服务生活集合平台”这类名字,几乎是课程设计、毕业设计里的常客。它看起来不炫技,但功能密度很高,能把 Spring Boot 常用技术栈完整串一遍。“附源码67568”这个编号,其实就是一份带版本号的交付源码,拿到手之后不能只解压跑起来就完事,真正值钱的是把“为什么这样开发”想明白。这种项目认真做下来,收获比单纯写几个 CRUD 接口大得多,因为它天然包含用户、商品、工单、公告、活动等多种业务对象,本质是一个小型交易平台的缩略版。
我接手过不少类似源码的二次改造和调试工作,也见过很多学生拿着源码却讲不清自己的项目,或者改一个小需求就崩掉。这篇文章就把这类平台从需求拆分、数据库设计、后端实现到答辩避坑的完整链路拆开讲一遍,内容既适合拿源码做二次开发的人参考,也适合压根没做过项目、想自己从零写一个符合 Spring Boot 课程设计要求的人。
1. 为什么“校园服务生活集合平台”值得做成一个 Spring Boot 实战项目
1.1 这类题目天然覆盖了企业级项目的核心场景
一个校园服务生活平台,通常要承载用户入驻、内容发布、服务申请、交易流转这些业务。不要小看这些听起来朴素的模块,把它们的共性抽象出来,其实就是一套小的电商中台模型:
- 用户中心:注册、登录、个人资料、头像上传;
- 内容中心:校园公告、新闻资讯、活动发布与报名;
- 交易中心:二手闲置商品发布、分类浏览、购买意向、交易状态流转;
- 服务中心:宿舍报修、失物招领、校园建议反馈,带状态机和管理员处理流程;
- 管理后台:用户管理、内容审核、数据统计、订单处理。
这些模块每个单独拿出来都不难,但放到一个项目里,就会引发非常真实的工程问题:统一返回格式怎么设计?异常怎么处理?分页怎么落地?文件存到什么位置?权限是用拦截器控制还是用 Spring Security 控制?多表关联查询怎么避免混乱?
这些问题,才是企业开发每天都要面对的事。很多网上的“管理系统源码”只有一个单表 CRUD,做完你会觉得 Spring Boot 很简单,但遇到真实业务就会懵。而校园服务类型项目恰好能逼你去思考这些问题,又不会因为业务太复杂而让你沉浸在业务细节里无法自拔。
1.2 复杂度刚好卡在“练手”和“演示”之间的甜点区
课程设计和毕业设计有一个矛盾点:技术上太简单,答辩没话说;业务上太复杂,一个人搞不定。校园服务生活平台就在两者之间找到了一个平衡。
它不需要对接硬件、不需要大规模分布式、不需要算法支撑,一个普通服务器甚至本地环境就能跑起来。但它的角色又是分级的:普通学生、商家/发布者、宿管/维修人员、平台管理员。这种多角色设计,会自然引出“登录怎么识别身份”和“接口怎么确保权限”的问题,让你有足够多的话可以在答辩时讲。
我改过很多源码,最大的体验是:这种项目能不能体现出水平,跟代码量关系不大,而是看设计。比如,二手商品的状态字段设计,有人用字符串随便写“在售”“已下架”“已售出”,有人用 int 枚举配合状态流流转,后者的代码在新增需求时维护成本就低很多。这种细节,才是拿来区分“只会复制粘贴”和“真理解了项目”的关键。
1.3 拿到带编号的源码后,第一件事不是启动,而是梳理
如果你是从某份编号 67568 的源码开始,我的建议是先别急着配环境,而是把项目里有哪些表、哪些角色、哪些接口理清楚,画一张简单的接口清单。源码往往存在过度封装或冗余代码,不梳理清楚,后续改需求时很可能改坏一个原本能跑的功能。
梳理方式可以很粗糙:把实体类列出来,把 Controller 里的接口路径列出来,和数据库表对应一下,你就知道这套系统大概能做什么、哪里可以改、哪里动不得。这里也顺便说一句,源码里的数据库脚本通常是核心资产,优先看它,因为它决定了业务边界。
2. 需求梳理:把“生活集合平台”拆成能落地的模块和动作
2.1 角色设计决定了权限模型
校园服务生活平台的用户角色,我在实际操作里通常分成四类:
| 角色 | 典型能力 | 权限说明 |
|---|---|---|
| 游客 | 浏览公告、浏览商品/活动 | 只能读公开内容 |
| 学生用户 | 注册/登录后发布商品、报名活动、提交报修、发布失物 | 能读写自己创建的数据 |
| 服务方(维修/宿管) | 处理报修工单,更新处理状态 | 能读写被分配的服务单 |
| 管理员 | 用户管理、内容审核、公告发布、数据统计 | 能读写所有数据 |
这个模型不需要引入复杂权限框架也能实现。课程设计阶段,我会用一张 user 表加 role 字段,再配合一个登录拦截器判断接口的角色要求,已经足够用。只有当你需要非常细粒度的“按钮级权限”时,才值得引入 Spring Security 的完整鉴权链,否则反而会让项目复杂度失控。
2.2 模块边界划分要按“业务闭环”走,不要按页面走
很多人一开始规划功能,喜欢照着前端页面一个个列:首页、个人中心、商品详情、发布页……这样列完你会发现数据之间东拉西扯,很难设计。正确做法是围绕业务闭环来:
- 二手交易闭环:发布商品 -> 商品上架 -> 被浏览/被询价 -> 标记售出 -> 下架;
- 报修服务闭环:提交报修单 -> 管理员分配/服务方接单 -> 处理中 -> 完成 -> 学生确认;
- 失物招领闭环:发布失物/拾物 -> 匹配/联系 -> 认领确认 -> 结案;
- 活动报名闭环:管理员发活动 -> 学生报名 -> 名额校验 -> 签到/结束。
闭环一旦理清,接口设计就有据可依。比如“商品上架”不是一个单独的接口,它实际上是商品表 status 字段从 0 变为 1 的操作。报修模块也不是简单“插入一条报修记录”,而是要在整个生命周期里维护一条状态机的流转记录。
2.3 状态字段是这类项目的灵魂
我在帮人改这类项目时,见过最典型的问题就是没有状态字段,或者把状态字段设计成无约束的字符串。这会导致一个后果:业务一旦走起来,数据会变得无法控制。
拿二手商品举例,一张商品表里至少要有“在售/已下架/已售出/违规禁用”这些状态。为什么要状态而不是直接删除?因为你需要保留交易记录、需要后台审核下架、需要处理用户投诉。把状态和删除分开,是一个可靠的默认选择。
报修工单的状态则建议这样流转:
待处理 -> 处理中 -> 已完成 | | | +-> 已取消(用户取消) +-> 已驳回(管理员退回)这种状态机用一张 int 字段加一套代码层常量来维护就够了,不需要工作流引擎。但你在设计表结构时一定先画出这个状态流,否则后面写 Service 时会非常痛苦。
3. 数据库设计:先理顺表之间的关系,再写一行代码
3.1 核心表结构与字段落地参考
很多课程设计源码,表结构设计得随意,比如用户表里直接放一个 “role_name” 字符串,商品分类字段直接写死在前端下拉框里,数据库里根本没有分类表。这种做法开发时省事,但答辩时一问“如果新增一个分类怎么办”就答不上来。所以建表时还是按规范的范式来。
我以交易和报修两个典型模块为例,给出一份可以直接“抄作业”的 SQL 设计:
-- 用户表 CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(100) NOT NULL COMMENT '加密后的密码', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `avatar` varchar(255) DEFAULT NULL COMMENT '头像地址', `phone` varchar(20) DEFAULT NULL COMMENT '联系电话', `role` tinyint NOT NULL DEFAULT 1 COMMENT '角色:1学生 2服务方 3管理员', `status` tinyint NOT NULL DEFAULT 1 COMMENT '账号状态:1正常 0禁用', `deleted` tinyint NOT NULL DEFAULT 0 COMMENT '逻辑删除:0未删 1已删', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间', `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; -- 商品分类表 CREATE TABLE `product_category` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `name` varchar(50) NOT NULL COMMENT '分类名称', `sort` int DEFAULT 0 COMMENT '排序权重', `status` tinyint DEFAULT 1 COMMENT '1启用 0停用', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品分类表'; -- 二手商品表 CREATE TABLE `product` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `user_id` bigint NOT NULL COMMENT '发布人ID', `category_id` bigint DEFAULT NULL COMMENT '分类ID', `title` varchar(100) NOT NULL COMMENT '标题', `description` text COMMENT '描述', `price` decimal(10,2) NOT NULL COMMENT '价格', `images` varchar(1000) DEFAULT NULL COMMENT '图片地址,多张用逗号分隔', `status` tinyint NOT NULL DEFAULT 0 COMMENT '状态:0在售 1已下架 2已售出 3违规禁用', `view_count` int DEFAULT 0 COMMENT '浏览次数', `deleted` tinyint DEFAULT 0 COMMENT '逻辑删除', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '发布时间', `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='二手商品表'; -- 报修工单表 CREATE TABLE `repair_order` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `user_id` bigint NOT NULL COMMENT '报修人ID', `content` varchar(500) NOT NULL COMMENT '报修内容', `images` varchar(1000) DEFAULT NULL COMMENT '现场图片', `status` tinyint NOT NULL DEFAULT 0 COMMENT '状态:0待处理 1处理中 2已完成 3已驳回 4已取消', `handler_id` bigint DEFAULT NULL COMMENT '处理人ID', `handler_note` varchar(500) DEFAULT NULL COMMENT '处理备注', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '提交时间', `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报修工单表';3.2 这些字段设计细节,决定了后期好不好改
第一,价格字段用 decimal 而不是 float。float 在商业计算里会出现精度问题,比如 0.1 + 0.2 不等于 0.3,这在交易类功能里是不可接受的。用 decimal(10,2) 可以存最大 8 位整数,对学生项目里的闲鱼式交易完全够用。
第二,图片地址用逗号分隔,这是一张“一对多”的简化手段。教科书上会建议你再建一张 product_image 表,但对课程设计来说,多图需求用逗号分隔存储完全能扛住,而且查询时少一次 JOIN。真正需要注意的反而是图片字段的长度,255 太短,建议给到 1000,不然存两张带签名的 OSS 地址就会爆。
第三,几乎每张表都有“deleted”逻辑删除字段。逻辑删除不是让你把查询条件里都手动写where deleted = 0,而是配合 MyBatis-Plus 的@TableLogic注解,让框架自动拼接条件。这样你做“删操作”时实际执行的是 update,数据恢复和审计都会从容很多。
第四,不要忽略 update_time 的自动更新。用ON UPDATE CURRENT_TIMESTAMP能让数据库帮你维护“最后修改时间”,在排查问题、做列表排序时帮大忙。
3.3 多表关联怎么设计才不会乱
校园服务平台的实体关系,基本都是用户主导的“一对多”:一个用户有多条商品、多个工单、多次报名。真正需要注意的只有活动和报名这种“多对多”关系。
多对多不要直接用户表里存一个 activity_ids 的逗号字段,一定要建关联表。关联表结构很简单,就是主键、活动 ID、用户 ID、报名时间、状态。这样你才能回答“某个活动报了多少人”“某个人报了哪些活动”这样的查询。我在源码改造时,见过很多把报名人数直接冗余在活动表里的做法,最后当用户取消报名时,那个数字经常对不上,反而比关联表更麻烦。
4. 后端核心实现:认证、分页、文件上传、XSS 过滤的落地方式
4.1 项目结构和统一返回对象先定好
拿到源码或自己新建项目时,先确认包结构,我的习惯是这样划分:
com.campus.platform ├── controller # 接口层,只做参数接收和结果返回 ├── service # 业务层,主要逻辑都在这 ├── mapper # 数据访问层,MyBatis-Plus 接口 ├── entity # 数据库实体类 ├── dto # 前端传入参数对象 ├── vo # 前端展示对象 ├── config # 配置类 ├── interceptor # 拦截器 └── common # 公共返回、异常、常量接口统一返回一个 JsonResult 对象,这个设计太重要了。如果每个接口返回类型都不一样,前端联调时就会很痛苦。我在课程设计阶段会这样写:
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }再配合一个全局异常处理器,把业务异常统一转换成 Result 返回,接口层就不会到处 try-catch 了。这个模式在很多企业项目里也是通用的,学会了不亏。
4.2 登录认证:拦截器还是 Spring Security?
对于校园服务生活平台,我的建议是:除非你的选题要求强制使用 Spring Security,否则用登录拦截器 + 注解就足够,而且更好讲清楚。
用拦截器的思路很直观:用户登录成功后,把用户 ID 和角色放进 Session,或者签发一个 Token 给前端。每次请求到达 Controller 之前,拦截器先判断当前请求路径是否需要登录、是否需要管理员角色。这样做的好处是逻辑透明,代码量少,出了问题容易排查。
不过现在很多前后端分离项目更习惯用 Token。课程设计阶段如果引入 JWT,效果会更好讲,也更能体现对“无状态认证”的理解。简单示意一下:
@Component public class LoginInterceptor implements HandlerInterceptor { private final String secret = "your-secret-key"; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; // 静态资源直接放行 } // 实际项目,从 Header 里取 token String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); } if (token == null || token.isEmpty()) { throw new BusinessException(401, "未登录或登录已过期"); } // 解析 token,解析失败说明 token 无效 Integer userId = JwtUtil.parseToken(token); request.setAttribute("currentUserId", userId); return true; } }但要注意,拦截器只解决“你是谁”的问题,“你能不能操作这个数据”还要在 Service 层里判断。比如学生 A 不能修改学生 B 发布的二手商品,就需要在更新商品时校验商品 user_id 是否等于当前登录用户 ID。这个“数据权限”的判断,很多课程设计的顾此失彼,我调试时经常发现有人能通过改接口参数操作别人的数据,答辩时一旦被问到会很尴尬。
4.3 MyBatis-Plus 分页:最容易因为版本问题翻车
分页是这个平台所有列表页的刚需:商品列表、公告列表、工单列表、活动报名列表,都要分页。用 MyBatis-Plus 自带的分页插件是最省力的,但配置一定要对,不然分页不生效。
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }配置完成后,Service 里直接用 Page 对象查询:
public Page<ProductVO> pageProducts(int pageNum, int pageSize, Long categoryId) { Page<Product> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Product::getStatus, 0); // 只查在售 if (categoryId != null) { wrapper.eq(Product::getCategoryId, categoryId); } wrapper.orderByDesc(Product::getCreateTime); Page<Product> productPage = productMapper.selectPage(page, wrapper); // 再封装成 VO,填充用户昵称等额外信息 return convertToVO(productPage); }这里有个坑:MyBatis-Plus 3.4 之后的PaginationInnerInterceptor和旧版PaginationInterceptor路径不一样,如果你拿到的源码是用旧版,而你在 pom 里直接引了新版依赖,编译就会报错。解决办法就是把旧类名改成新类名,同时检查分页插件版本和 MyBatis-Plus 主版本是否匹配。
另一个坑是分页参数从 1 开始还是从 0 开始。MyBatis-Plus 默认 pageNum 从 1 开始,而很多前端组件(比如某些封装的 table)默认从 0 开始。我见过太多因为这个没对齐导致第一页数据是空的。前后端联调前,一定先约定清楚。
4.4 文件上传:本地存储也有讲究
这个平台会涉及头像上传、商品图片上传、报修图片上传。课程设计阶段用本地磁盘存储就可以,但要注意几点:
需要在 application.yml 里配置路径,不要把路径写死到某台电脑的 C 盘。我的习惯是:
file: upload-dir: ./upload/ access-pattern: /upload/**然后把 upload 目录映射成静态资源,这样上传后的图片可以直接通过 URL 访问:
@Configuration public class WebConfig implements WebMvcConfigurer { @Value("${file.upload-dir}") private String uploadDir; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadDir); } }上传接口接收 MultipartFile 时,文件名一定不要直接用用户上传的原名。一方面是防止路径穿越攻击,另一方面是防止文件名冲突。我一般会用 UUID 重新生成文件名,并校验后缀白名单。图片后缀只允许 jpg、jpeg、png、gif、webp,其他类型直接拒绝。
还需要在 application.yml 里设置上传大小限制,不然一个超大文件会把接口拖垮:
spring: servlet: multipart: max-file-size: 10MB max-request-size: 50MB4.5 全局过滤器处理 XSS:文件上传接口要特别小心
有些源码里会有这样一个需求:做全局 XSS 过滤器,把请求参数里的脚本内容转义掉,防止存储型 XSS 攻击。这个方向是对的,但我在实际对接时踩过一个非常隐蔽的坑:如果在过滤器中无差别包装了 HttpServletRequest,对于 multipart/form-data 文件上传请求,可能会提前把输入流读掉,导致后续 Spring 解析文件时拿不到内容,上传接口直接失败。
这里的经验是:全局 XSS 过滤器需要判断 Content-Type,如果是 multipart/form-data,就直接放行,不要去做参数包装;文件上传中的文件名和文件内容反而要在解析完成之后,由业务层单独做校验。
一个简化版的安全过滤逻辑可以是这样:
@Component public class XssFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; String contentType = req.getContentType(); if (contentType != null && contentType.toLowerCase().contains("multipart/form-data")) { chain.doFilter(request, response); return; } chain.doFilter(new XssRequestWrapper(req), response); } }XssRequestWrapper 的核心逻辑就是重写 getParameter、getParameterValues、getHeader 等方法,把<script>之类的关键字转义成<script>。这样做之后,普通表单和 JSON 接口都有防护,文件上传接口也不会被误伤。
不过也要提醒一句:XSS 过滤只是其中一环,更关键的是前端展示时不要用 v-html 直接渲染不可信内容。前后端二手都做一下,才更稳妥。
5. 这 5 个隐藏坑,才是“附源码”项目最容易翻车的地方
5.1 Spring Boot 版本太高,反而启动不了
搜索热度里经常出现“springboot版本太高”“idea不能创建springboot项目不能使用jdk1.8”,这就是典型的环境适配问题。Spring Boot 3.x 要求 JDK 17 以上,如果你的课程设计环境还是 JDK 1.8,就会出现 IDE 里创建不了、或者启动报错的情况。
课程设计和本地老项目,我一般固定用 Spring Boot 2.7.x 系列。它既兼容 JDK 8,又能覆盖绝大多数 MyBatis-Plus、JWT、文件上传这些组件的兼容版本。不要一上来就追新,稳定跑起来比版本号漂亮更重要。
5.2 数据库连接配置和字符集
很多源码 README 里只写“导入数据库”,但没有告诉你数据库连接串里必须带时区和字符集参数。常见的 MySQL 8.x 连接串建议这样配置:
spring: datasource: url: jdbc:mysql://localhost:3306/campus_service?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver如果不加serverTimezone,可能直接报时区错误;不加characterEncoding=utf8,中文会乱码。这个问题在新手项目里出现率极高。
5.3 分页成功,但统计接口不过关
校园平台通常需要几个统计功能:今日新增用户、总商品数、待处理工单数、活动报名人数。很多源码是用多条 SQL 分别查询,或者在 Service 里循环查库,效率很低。更合理的做法是写一个带聚合查询的 SQL:
@Select("SELECT status, COUNT(*) AS cnt FROM repair_order GROUP BY status") List<Map<String, Object>> countByStatus();这样一条 SQL 就能拿到全部工单状态的数量。前端拿到后在内存里转换成饼图或者柱状图数据就行。
5.4 答辩要能讲清楚“这条数据是怎么流转的”
我在指导别人改这个项目时经常发现,代码能跑,但问一句“报修单从提交到完成,中间经历了哪些状态,每个状态对应哪个接口”,就沉默。这是最致命的。
强烈建议你在答辩前,把每个核心模块的时序流程写下来。比如报修流程:
- 学生提交
POST /repair/order,sql 中 status=0; - 管理员查询待处理工单
GET /repair/order?status=0; - 管理员指派或服务方接单
PUT /repair/order/{id}/assign,status 改为 1; - 服务方填写处理备注并完成
PUT /repair/order/{id}/complete,status 改为 2; - 学生可以查看详情确认评价。
能把这个链条讲清楚,说明你是真理解了这个项目的业务,而不是背代码。
5.5 二次开发时,先改数据库,再改代码
最后说说扩展。如果你不想只停留在跑通源码,想往上加一个“拼车”或“校园二手书回收”的功能,顺序一定不要乱。
先加表、加状态字段、加关联关系;然后生成实体类和 Mapper;再写 Service 接口和实现;最后暴露 Controller 接口。千万不要直接在 Controller 里写 SQL 或者直接查 Mapper。很多源码在二次开发时被改坏,就是因为它原本的 Service 层很薄,大家都绕过 Service 直接在 Controller 堆代码,最后连不上业务逻辑。
我在实际开发中还有一个小习惯:动手前先把表结构和状态流转画在纸上,尤其是状态机这种靠字段驱动的业务,先用文字把“从哪几个状态、通过哪些操作、到达哪些状态”列出来,再落代码。这样做出来的代码,天然就是清晰的。
写在最后的小经验
这类校园服务生活平台,真正难的不是用 Spring Boot 写接口,而是对业务进行拆解和建模。我见过太多“能跑”的源码,也见过太多“一改就崩”的项目,两者的差别往往就在于有没有把数据状态、角色权限、表之间的关系想清楚。如果你问我这个项目最值得花时间去打磨的地方,我会说是数据库设计与状态流转,而不是多写几个接口。
最后再分享一个技巧:给这样的平台加功能时,尽量往“闭环”上靠,不要东加一个按钮、西加一个页面。一个功能如果不是从创建到完成能走通的,就不要做。这样你的项目才会越做越像真正的产品,而不是一堆功能的堆积。