ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot+Vue校园失物招领系统:从前端到后端实战

SpringBoot+Vue校园失物招领系统:从前端到后端实战 简介一套基于Spring Boot与Vue实现的校园失物招领管理系统面向计算机相关专业学生、毕业设计开发者及需要快速搭建失物招领平台的校内技术团队。系统覆盖失物登记、招领管理、预约领取等核心流程后端由Java与Spring Boot完成前端以Vue构建响应式页面并配套MySQL 5.7数据库脚本与JDK 1.8环境说明。压缩包共1816个文件以java、vue、js、css等源码文件为主同时包含sql数据库脚本、docx功能文档、bat启动脚本及jpg界面预览图整体大小61.59MB目录层级清晰便于直接导入和二次开发。项目已通过严格调试核心控制器与页面组件齐全学习时可借助运行脚本和项目文档快速启动。该资源已有2761人学习/下载特别适合作为毕业设计参考或Spring BootVue全栈入门实践项目。1. 校园失物招领系统为什么这种“小项目”反而最能练手一台笔记本电脑、一张校园卡、一副 AirPods丢在食堂或图书馆靠发朋友圈和表白墙寻物基本等于大海捞针。校园失物招领系统要解决的就是这个高频刚需背后的信息不对称问题失主不知道去哪找捡到东西的人不知道怎么还。用 Springbootvue 来做这件事不是因为它有多新而是因为它恰好覆盖了从数据库设计、接口开发到前端联调的完整链路——这种项目看着是典型的 CRUD 系统真正动手时才发现关联查询、状态流转、图片上传、权限控制这些细节才是大头。适合正在做毕设、课设或者想用一套主流 Java 技术栈把前后端打通练手的人。2. 从需求到模块这个系统到底要做什么2.1 核心角色与权限失物招领系统的用户角色不需要做得很重但也不能完全没有。常见做法是分三类游客、登录用户和管理员。游客只能浏览失物和招领列表以及搜索登录用户能发布失物信息、招领信息能对信息进行认领或确认管理员负责审核信息、处理认领状态、管理用户。这里要注意一个设计点认领动作不是简单的“点一下按钮”就能完成。现实中失主看到招领信息联系拾主线下确认然后再在系统里更新状态。我见过不少项目把“认领”做成点击即完成结果状态管理混乱失主和拾主都说不清东西到底还在不在。所以后端设计时认领应该是一个带状态流转的操作而不是一个简单的字段更新。权限控制上用 Spring Security 还是拦截器取决于你的项目定位。如果只是毕设或课设用拦截器加 Redis 存储登录态就够了实现简单出了问题也好排查。Spring Security 功能强但配置复杂对新手不友好一旦配错排查成本反而高。2.2 失物与招领两条状态线这是整个系统最核心的业务逻辑。失物和招领应该分开建表还是合成一张表两种做法都有人在用我的建议是分两张表原因有三个字段有差异招领物品可能需要描述拾取地点失物需要描述丢失地点、状态流不同失物状态是“寻找中→已找到”招领状态是“待认领→已归还”、后续扩展互不影响比如招领物品可能需要登记保管地点。状态线具体是这样的失物表lost_item状态 0寻找中失主发布后默认状态状态 1已找到失主确认找回或管理员核实后关闭招领表found_item状态 0待认领拾主发布后默认状态状态 1认领中有人提交认领申请待失主/拾主确认状态 2已归还拾主确认归还完成认领申请建议单独建一张表记录谁提交的认领申请、对应哪条招领信息、目前是什么状态。这样做的好处是你能看到一条招领信息被尝试认领过几次、当前处理到哪一步后面做管理页面也好展示。下面这段 SQL 是建表的核心部分字段和注释可以直接抄。-- 失物信息表 CREATE TABLE lost_item ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL COMMENT 发布者用户ID, title varchar(100) NOT NULL COMMENT 物品名称, description text COMMENT 物品描述如品牌、颜色、特征, lost_place varchar(200) DEFAULT NULL COMMENT 丢失地点, lost_time datetime DEFAULT NULL COMMENT 丢失时间, image_url varchar(500) DEFAULT NULL COMMENT 物品图片路径, status tinyint NOT NULL DEFAULT 0 COMMENT 0-寻找中, 1-已找到, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT失物表; -- 招领信息表 CREATE TABLE found_item ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL COMMENT 发布者用户ID, title varchar(100) NOT NULL COMMENT 物品名称, description text COMMENT 物品描述, found_place varchar(200) DEFAULT NULL COMMENT 拾取地点, found_time datetime DEFAULT NULL COMMENT 拾取时间, image_url varchar(500) DEFAULT NULL COMMENT 物品图片路径, status tinyint NOT NULL DEFAULT 0 COMMENT 0-待认领, 1-认领中, 2-已归还, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT招领表; -- 认领申请表 CREATE TABLE claim_record ( id bigint NOT NULL AUTO_INCREMENT, found_id bigint NOT NULL COMMENT 招领信息ID, user_id bigint NOT NULL COMMENT 认领人ID, claim_reason varchar(500) DEFAULT NULL COMMENT 认领说明如物品特征、购买时间, contact_phone varchar(20) DEFAULT NULL COMMENT 联系电话, status tinyint NOT NULL DEFAULT 0 COMMENT 0-待确认, 1-已通过, 2-已拒绝, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_found_id (found_id), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT认领申请表;参数说明几个容易忽略的地方lost_time和found_time用datetime而不是varchar是为了后面做时间范围检索和排序status字段用tinyint加注释逻辑清晰后面 MyBatis-Plus 里可以直接映射枚举idx_status这个索引很关键列表页默认按状态过滤没有索引的话数据量上来后分页查询会明显变慢。分表后前端列表页其实可以分开两个 tab 或两个页面展示失物列表展示“正在寻找的东西”招领列表展示“捡到的东西等待认领”。这两个页面结构相似但状态操作按钮完全不同分开写反而比强行合并更省事。2.3 技术选型为什么是 Springbootvue 而不是其他后端用 Springboot前端用 vue这个组合在校园项目里出现频率最高不是偶然。Springboot 自带内嵌 Tomcat打 jar 包就能跑省去传统 SSM 项目配置 XML 和部署容器的麻烦vue 组件化开发列表页、表单页、详情页拆成组件后代码量比 JSP 时代少一大截。配套的组合也基本固定MyBatis-Plus 操作数据库、Redis 存登录态和验证码、JWT 生成 token、axios 做 HTTP 请求。也有不少人问Java 后端这么重用 Node.js 或 Python Flask 不是更轻吗如果你只是交作业确实更轻。但如果你后面想找工作Springboot 在校园招聘里的出现频率远高于其他后端框架vue 目前也是前端框架里需求量最大的之一。这个选题直接用业务系统当练手简历上能写的东西比“个人博客”和“商城”这种千篇一律的项目要具体得多。3. 后端落地SpringBoot 接口设计与关键实现3.1 项目结构与实体设计一个容易犯的错误是把所有代码堆在 Controller 里一个方法写几百行。失物招领系统虽然不大但按分层结构来写后面加功能、排查问题都会快很多。推荐结构如下src/main/java/com/example/lostfound/ ├── controller/ # 接口层 │ ├── LostItemController.java │ ├── FoundItemController.java │ └── ClaimRecordController.java ├── service/ # 业务逻辑层 │ ├── LostItemService.java │ └── impl/ ├── mapper/ # MyBatis-Plus 数据访问层 ├── entity/ # 数据库实体 ├── common/ # 统一返回结果、异常处理、工具类 └── config/ # 跨域、Redis、拦截器配置实体类直接用 MyBatis-Plus 的注解映射到表省去写 XML。关键点TableName指定表名TableId(type IdType.AUTO)指定自增主键字段名和表字段驼峰对应即可MyBatis-Plus 默认开启了下划线转驼峰。Data TableName(lost_item) public class LostItem { TableId(type IdType.AUTO) private Long id; private Long userId; private String title; private String description; private String lostPlace; JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8) private LocalDateTime lostTime; private String imageUrl; private Integer status; private LocalDateTime createTime; private LocalDateTime updateTime; }JsonFormat这里要特别说明前端传到后端的lostTime如果是个字符串后端 LocalDateTime 字段直接接收会报错必须靠这个注解统一格式或者在前端传值时就转成时间戳。实际开发中注解漏写是联调时最常遇到的 400 错误来源之一。查询逻辑用 LambdaQueryWrapper 写比原生 SQL 短很多而且类型安全。比如查状态为 1 的失物列表只需要一行条件构造器不需要自己拼 SQL。3.2 多条件检索的两种写法失物招领系统的核心功能是搜索搜索质量直接决定系统能不能用。用户最常见的操作是按物品名称搜、按地点搜、按时间范围搜、按状态筛。实现方式有两种一种是用 MyBatis-Plus 的 Wrapper 动态拼接另一种是写自定义 SQL。项目初期第一种够用但做熟练后你会更倾向第二种因为能精确控制索引使用。先看第一种用 LambdaQueryWrapper 动态拼接public IPageLostItem searchLostItems(LostItemQuery query) { LambdaQueryWrapperLostItem wrapper new LambdaQueryWrapper(); // 关键词搜索title 和 description 都匹配防止用户只记得物品特征不记得名字 if (StringUtils.hasText(query.getKeyword())) { wrapper.and(w - w .like(LostItem::getTitle, query.getKeyword()) .or() .like(LostItem::getDescription, query.getKeyword()) ); } // 地点筛选支持模糊匹配 if (StringUtils.hasText(query.getPlace())) { wrapper.like(LostItem::getLostPlace, query.getPlace()); } // 状态筛选0-寻找中, 1-已找到, 不传默认查全部 if (query.getStatus() ! null) { wrapper.eq(LostItem::getStatus, query.getStatus()); } // 时间范围前端传 beginTime 和 endTime if (query.getBeginTime() ! null query.getEndTime() ! null) { wrapper.between(LostItem::getLostTime, query.getBeginTime(), query.getEndTime()); } // 按发布时间倒序 wrapper.orderByDesc(LostItem::getCreateTime); return lostItemMapper.selectPage(new Page(query.getPageNum(), query.getPageSize()), wrapper); }这段逻辑有个细节关键词同时包了 title 和 description但用and包了一层避免和其他条件拼接时产生括号错乱。这类 bug 在动态 SQL 里很常见查询结果时多时少排查起来最费时间。第二种方式是写自定义 SQL适合做热门地点统计和跨表联查。比如统计“哪个地点丢东西最多”这属于典型的分组聚合查询Wrapper 写起来很别扭直接写 SQL 更清晰。这种情况在 Service 里用Select注解写在 Mapper 接口上就行。3.3 图片上传与静态资源映射失物招领系统里图片上传是必做的功能。物品照片是失主确认物品的重要依据有没有图片直接影响认领效率和可信度。常见做法是上传到本地目录然后通过 SpringBoot 的静态资源映射提供访问 URL。部署到服务器后再把上传目录挂载到磁盘或者换成对象存储这个渐进路径比较适合校园项目的体量。PostMapping(/upload) public ResultString upload(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { return Result.error(文件为空); } // 限制文件类型和大小防止上传恶意文件 String originalFilename file.getOriginalFilename(); String suffix originalFilename.substring(originalFilename.lastIndexOf(.)); if (!Arrays.asList(.jpg, .jpeg, .png, .gif).contains(suffix)) { return Result.error(仅支持图片文件); } if (file.getSize() 5 * 1024 * 1024) { return Result.error(文件大小不能超过5MB); } // 用 UUID 重命名避免中文文件名乱码和重名覆盖 String newFileName UUID.randomUUID().toString().replace(-, ) suffix; // 按日期分目录存储避免单个目录文件过多 String dateDir new SimpleDateFormat(yyyyMMdd).format(new Date()); String uploadPath uploadDir / dateDir; File dir new File(uploadPath); if (!dir.exists()) { dir.mkdirs(); } try { file.transferTo(new File(uploadPath, newFileName)); return Result.success(/upload/ dateDir / newFileName); } catch (IOException e) { log.error(上传失败, e); return Result.error(上传失败); } }逻辑说明按日期分目录一方面避免单目录文件过多影响 IO 性能另一方面也方便按时间清理过期图片。返回给前端的 URL 是相对路径前端在展示时拼上后端地址。这里有个容易踩的坑file.transferTo()在某些情况下会因为文件被占用而报 IOException最好先判断目录可写再执行迁移。静态资源映射的配置放在 application.yml 里spring: servlet: multipart: max-file-size: 5MB max-request-size: 10MB web: resources: static-locations: file:D:/data/upload/,classpath:/static/static-locations配置了/upload/**路径映射到本地磁盘目录。Windows 本地开发路径是D:/data/upload/部署到 Linux 时改成/data/upload/就行。注意路径末尾的斜杠不能丢丢了会映射失败。4. 前端落地Vue 页面与后端对接的常见姿势4.1 路由与页面划分vue 单页应用的页面划分不要一开始就铺开做很多页面而是先想清楚页面之间的关系。失物招领系统最少需要这些页面首页/列表页展示失物和招领信息流支持搜索和筛选详情页展示物品详情和认领按钮发布页发布失物/招领信息的表单页我的发布查看自己发布过的物品及当前状态个人中心认领管理处理认领申请管理员视角路由设计上用嵌套路由把个人中心底下的“我的失物”“我的招领”收在一起后面加“我的认领记录”也方便。// router/index.js import { createRouter, createWebHistory } from vue-router const routes [ { path: /, component: () import(/views/Home.vue) }, { path: /detail/:type/:id, component: () import(/views/ItemDetail.vue) }, { path: /publish, component: () import(/views/Publish.vue), meta: { requiresAuth: true } }, { path: /user, component: () import(/views/UserLayout.vue), redirect: /user/lost, children: [ { path: lost, component: () import(/views/user/MyLost.vue) }, { path: found, component: () import(/views/user/MyFound.vue) }, { path: claims, component: () import(/views/user/MyClaims.vue) } ] } ] const router createRouter({ history: createWebHistory(), routes }) // 路由守卫需要登录的页面未登录跳转到登录页 router.beforeEach((to, from, next) { const isLogin localStorage.getItem(token) if (to.meta.requiresAuth !isLogin) { next(/) } else { next() } })路由守卫这段逻辑很关键它拦截了未登录用户直接手敲 URL 访问发布页和认领管理页的情况meta字段里标记哪些页面需要登录。路由懒加载通过() import()实现首屏加载会快不少。4.2 axios 封装与跨域处理前后端分离开发中axios 封装是第一步。统一处理请求头带 token、响应拦截统一解析后端返回结构、错误码统一提示这三件事不做后面每个页面都要重复写同样的逻辑。// utils/request.js import axios from axios import { ElMessage } from element-plus const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器自动携带 token request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer token } return config }) // 响应拦截器统一处理返回结果 request.interceptors.response.use( response { const res response.data // 后端统一返回格式 { code, message, data } if (res.code 200) { return res } else if (res.code 401) { // 登录过期清空 token 并跳转首页 localStorage.removeItem(token) window.location.href / ElMessage.error(登录已过期请重新登录) } else { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } }, error { ElMessage.error(网络错误请检查后端服务) return Promise.reject(error) } ) export default requestbaseURL设置为/api开发环境下通过 vite 的代理把请求转发到localhost:8080生产环境下用 Nginx 反代。这样项目的 API 路径不写死换环境只需要改代理配置。vite 代理配置在vite.config.js里这是新手最容易卡住的地方// vite.config.js export default defineConfig({ server: { port: 3000, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })changeOrigin: true必须打开否则后端收到的请求头里的 Host 还是前端的地址某些后端框架会因此拒绝请求。这一步是前后端联调最常见的翻车点。4.3 列表渲染状态展示与时间格式化列表页是用户看到的第一界面直接体现项目的完成度。状态字段后端存的是 0、1、2 这类数字前端如果直接显示数字用户体验非常差。状态映射和时间格式化这些逻辑写成一个公共函数或者直接在组件里处理保持页面模板干净。template el-table :datatableData v-loadingloading el-table-column proptitle label物品名称 min-width140 / el-table-column proplostPlace label丢失地点 min-width120 / el-table-column label丢失时间 width170 template #default{ row } {{ formatTime(row.lostTime) }} /template /el-table-column el-table-column label状态 width100 template #default{ row } el-tag :typestatusMap[row.status].tagType {{ statusMap[row.status].label }} /el-tag /template /el-table-column el-table-column label操作 width120 template #default{ row } el-button typeprimary link clickgoDetail(row)查看/el-button /template /el-table-column /el-table /template script setup const statusMap { 0: { label: 寻找中, tagType: danger }, 1: { label: 已找到, tagType: success } } function formatTime(time) { if (!time) return - // 后端返回 2024-06-01T10:30:00 这种格式转成更友好的显示 return time.replace(T, ).substring(0, 16) } /scriptstatusMap这种映射对象是状态字段展示的标准做法比${row.status 0 ? 寻找中 : 已找到}这种三元表达式清晰得多。状态一旦增多比如招领信息有 3 个状态映射对象的优势更明显。5. Springbootvue 联调避坑5 个值得记录的问题5.1 跨域配置写了还是报错现象前端从 localhost:3000 请求 localhost:8080浏览器控制台报 CORS 错误。后端明明在 Controller 上加了CrossOrigin前端也开了 vite 代理但请求还是失败。原因CrossOrigin加在了单个 Controller 上而前端通过代理转发后实际请求是从 vite dev server 发起的根本不经过浏览器跨域逻辑或者说CrossOrigin配置的允许来源和实际请求来源不一致比如端口写错。这个问题的本质是“代理和跨域配置混用”二选一即可。解决开发环境统一用 vite 代理解决跨域后端不需要加CrossOrigin生产环境统一用 Nginx 反向代理让前端请求和后端接口同源。如果要在后端做全局跨域配置用WebMvcConfigurer的addCorsMappings方法不要在每个 Controller 上加注解。5.2 前端传时间字符串后端报 400现象前端表单里选了丢失时间提交时传的是 2024-06-10 12:00:00后端接口报 400 Bad Request控制台日志显示参数类型不匹配。原因后端实体类用了LocalDateTime类型Spring 默认无法把字符串自动转换成LocalDateTime。虽然我在实体类上加了JsonFormat但只有 JSON 序列化/反序列化时生效如果前端用表单提交application/x-www-form-urlencoded而不是 JSON这个注解就不起作用。解决前端提交时把时间字段转成 JSON 提交Content-Type设置为application/json。用 axios 提交时POST 请求体直接传对象不要手动拼 URL 参数。后端如果一定要接收表单格式的时间字符串需要加自定义转换器或使用DateTimeFormat注解。5.3 图片上传成功但访问 404现象上传接口返回了/upload/20240601/xxx.jpg这个路径但浏览器打开这个 URL 返回 404。原因static-locations配置的路径不对或者生效位置不对。把配置写在application.yml里有时被别的配置覆盖也可能路径末尾少了一个斜杠导致拼接错误。另一个常见原因是配置了但在启动时没有打印生效的静态资源映射排查起来没有头绪。解决确认 application.yml 里spring.web.resources.static-locations的值Windows 用file:D:/data/upload/Linux 用file:/data/upload/最后不要漏斜杠。然后验证一个简单方法在 resources/static 下放一张测试图片启动后访问 localhost:8080/test.jpg能访问说明静态资源映射正常再排查磁盘映射。5.4 本地跑通部署后接口全挂现象本地启动项目前后端联调一切正常打包部署到服务器前端页面能打开但所有接口请求都返回 404 或 500。原因前后端分离部署时前端打包后的 dist 目录和后端 jar 包是两套东西。如果直接把 dist 丢进 SpringBoot 的 static 目录想“合在一起部署”前端代码里通过/api发起的请求就会被 SpringBoot 前端路由接管而不是转发到后端 Controller。再加上如果使用了 History 路由模式刷新页面时会出现 404。解决部署时用 Nginx 做一层反向代理把静态请求指向 dist 目录把/api前缀的请求代理到 SpringBoot 应用端口。配置如下server { listen 80; server_name localhost; location / { root /opt/dist; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files $uri $uri/ /index.html;这行负责处理 vue 的 History 路由刷新页面时把所有路径都导向 index.html让前端路由接管页面而不是让 Nginx 返回 404。proxy_pass后面带了/api/匹配到的请求会保持完整路径转发到后端。5.5 Maven 依赖冲突与启动慢现象项目启动时报ClassNotFoundException或者某类方法不存在常见于 druid、lombok、fastjson 这几个库。另外 Springboot 项目启动要 20 秒以上排查不出原因。原因druid 版本和 Springboot 版本不兼容时经常报找不到方法fastjson 版本过低存在同样的历史问题。启动慢大部分是因为 SpringBoot 自动扫描了太多不需要的组件或者数据库连接池初始化时反复重试连接。解决检查 Maven 依赖树执行mvn dependency:tree -Dincludescom.alibaba:druid确认是否有多个版本被传递依赖拉进来了。不同模块引入同一个库的不同版本时在 pom.xml 里用dependencyManagement统一版本。启动慢的问题可以在启动类上加exclude排除数据源自动配置手动配置精确的数据源数据库不可达时检查连接池的initialSize和maxRetries参数不要让它无限制重试。以上 5 个问题是我自己或者身边的人在移动校园式系统开发里真实遇到过的。联调阶段的问题大多不是复杂的技术难题而是配置不一致、格式不统一、路径写错这类细节。建议先把项目跑通一遍再逐步从开发环境往部署环境迁移每换一步就验证一步不要等到全部配置好了再一次性排查。6. 项目跑通之后值得优先做的三个进阶方向把基础的 Springbootvue 失物招领系统跑通之后这个项目的价值才刚刚开始。如果只是停留在完成课设要求代码能跑、答辩能过那是及格线。想让它成为简历上拿得出手的东西或者想借着这个项目把技术再往上走一步有三个方向值得优先做。第一个是给系统加实时通知。物品状态变化、认领申请提交这些事件如果只靠用户刷新页面发现体验很差。引入 WebSocket 或 Server-Sent EventsSSE在状态流转时主动推送消息给相关用户整个系统的“活”感就出来了。SSE 实现比 WebSocket 简单服务端用 Spring 的SseEmitter就能搞定前端用EventSource接收不需要引入额外的消息中间件。第二个是给图片上传接对象存储顺便把图片压缩做了。本地磁盘存储的方案在单机部署时够用但一旦图片数量增多磁盘空间、备份、访问速度都会成为问题。替换成云上的对象存储服务唯一要注意的是接口要抽象一层不要在上传逻辑里写死某个厂商的 SDK后面换服务商不用改业务代码。第三个是加上全文检索能力。MySQL 的LIKE %keyword%在数据量小的时候能跑但物品描述往往比较长搜索体验一般。用 Elasticsearch 会对项目复杂度造成比较大的提升但相对较轻的替代方案是在 MySQL 里建全文索引。对失物招领这种数据量级MySQL 全文索引其实够用还能和现有 MyBatis-Plus 无缝衔接。最后说一个我的教训给这个项目做所谓的“复杂功能”时先想想有没有人用。我做第一版时花大力气做了物品分类、多级联动、收藏夹、积分系统最后实际使用最密集的功能只有搜索、发布、认领、状态更新。后来重构时把多余的都删了项目反而更好维护、更容易说清楚。功能少而深比功能多而浅更有说服力。希望你做完这个项目也能体会到“删比加难”这件事。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进