又是一年毕业季,宿舍楼下、公告栏里、QQ群里到处是学长学姐甩卖教材、台灯、自行车的信息。但这种方式太零散了,信息发出来几分钟就沉底,有价值的东西根本传不到需要的人手里。我当时在做的这个课题,就是针对这个真实痛点,搭建一个基于微信小程序的校园二手交易平台,后端用的SSM框架,也就是Spring、SpringMVC、MyBatis这一套经典组合。整个项目带完整文档和源码,适合正在做课程设计、毕业设计或者想练手完整前后端项目的同学参考。
这个平台解决的核心问题很明确:让校园里的闲置物品能在一个封闭、可信的小范围里快速流转。学生用微信扫一扫就能进入小程序,不用下载App,在宿舍群、班级群里转发一下就能传播,这比传统Web网站方便太多。而后端选SSM而不是Spring Boot,一方面是因为这是很多高校Java课程的主流教学内容,另一方面这套框架足够轻量,对于这种规模的项目来说结构清晰、易于理解,把Spring的IoC、AOP思想,SpringMVC的请求处理流程,MyBatis的数据映射机制全部串起来了,做完这个项目,对Java后端的理解会上一个台阶。
这篇文章我会从选型思路、数据库设计、后端接口实现、小程序端页面逻辑、常见问题排查五个维度来完整拆解这个项目,全程都是实际开发中会踩到的坑和验证过的方案。
1. 项目整体设计与选型思路
1.1 为什么用微信小程序而不是App或H5
我做这个项目之前,最早考虑过两个替代方案:一个是纯H5手机网页,另一个是Android原生App。
H5的方案开发成本确实低,但问题在于没有一个天然的流量入口。你做完一个网页,用户怎么找到它?要么记网址,要么扫码进去,但下次再用又要重新找入口。而且H5在微信内置浏览器里的体验明显打折,底部导航栏、下拉刷新这些交互都需要额外适配,更别说调用微信的登录能力了,流程会绕一大圈。原生App的体验倒是好,但开发周期长、还要上架应用商店,对校园场景来说太重了,学生不可能为了买个二手台灯专门装一个App。
微信小程序完美避开了这两个问题。它在微信生态里天然存在,用户用完即走,下次想用从聊天记录或者最近使用的小程序里一点就开。对于校园二手交易这个高频但轻量的场景,这个体验是最合适的。另外小程序提供了完整的前端开发框架和组件库,页面跳转、下拉刷新、图片上传、用户登录这些能力都是现成的,开发效率比原生App高一个量级。
1.2 SSM框架的选型逻辑
后端选择SSM,我承认有一点“教学导向”的成分,但这不意味着这个选型不合理。
Spring负责对象管理和依赖注入,Service层和Mapper层的Bean都由Spring容器统一管理,这样各层之间的耦合度降到最低。SpringMVC负责HTTP请求的接收和响应,Controller层的注解式开发非常直观,一个@RequestMapping就能完成URL映射。MyBatis负责数据库操作,对于这个项目里大量涉及多表关联、动态条件查询的场景(比如按分类筛选商品、模糊搜索商品名称),MyBatis的动态SQL写起来非常灵活,比JPA那种全自动ORM更可控。
相较于Spring Boot,SSM确实需要手动写更多配置文件,Web.xml、Spring配置、SpringMVC配置、MyBatis配置,每一处都要自己配。但正因为这样,整个请求处理链路才会清晰可见,对理解Java Web项目的运行机制帮助很大。如果你已经掌握了SSM,再去写Spring Boot就是降维打击。
1.3 角色权限与核心模块拆解
整个系统的用户角色分两种:普通用户和管理员,权限边界很清晰。
普通用户的核心操作路径是:注册/登录、浏览首页商品列表、按分类筛选、搜索商品、查看商品详情、收藏商品、留言咨询、发布商品、管理自己发布的商品(编辑、下架、标记为已售)、查看收藏列表。
管理员的职责是维护平台秩序:管理用户账号(查看详情、禁用)、管理商品分类(新增、编辑、删除)、审核商品(对违规商品做下架处理)、查看平台的基本运营数据(用户总数、商品总数等)。
从模块化角度看,整个项目可以拆成五个核心模块:用户模块、商品模块、分类模块、收藏模块、留言模块。用户和商品是核心实体,收藏和留言是围绕商品展开的互动行为,分类是商品的维度标签。这种模块划分方式,无论是数据库设计还是后端接口设计,都遵循“高内聚低耦合”的原则,每个模块的开发可以独立推进、单独测试。
2. 数据库设计与核心表结构
2.1 整体的ER关系梳理
数据库设计是这类项目的地基,我之前见过有同学直接在上面堆字段,结果后面改接口时改到怀疑人生。正确的做法是先把实体关系理顺。
这个项目的实体关系其实很清晰:
- 用户(User)和商品(Goods)是一对多关系:一个用户能发布多件商品。
- 分类(Category)和商品是多对一关系:一件商品只属于一个分类,一个分类下有多件商品。
- 用户(User)和商品(Goods)通过收藏表(Favorite)形成多对多关系:一个用户可以收藏多件商品,一件商品可以被多个用户收藏。
- 商品(Goods)和留言(Message)是一对多关系:一件商品下可以有多条留言。
顺着这个ER关系去建表,逻辑上就不会乱。实际开发中我还加了两张辅助表:一张是订单相关的记录(这个项目以线下交易为主,所以订单表做了简化,只记录状态),另一张是管理员的操作日志表(可能有点冗余,但上线有审计需求时你就知道这个表有多香)。
2.2 关键核心表结构与设计说明
用户表(user)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | int | 主键,自增 |
| openid | varchar(64) | 微信用户的唯一标识,后端登录时拉取 |
| nickname | varchar(50) | 昵称,默认为“微信用户” |
| avatar | varchar(255) | 头像URL |
| phone | varchar(20) | 联系电话(发布商品时需要) |
| role | tinyint | 角色,0-普通用户,1-管理员 |
| status | tinyint | 状态,0-正常,1-禁用 |
| create_time | datetime | 注册时间 |
openid是这张表的灵魂。微信后台会为每个小程序、每个微信用户生成一个唯一的openid,这就是我们的天然用户ID,不需要额外做账号密码体系。
商品表(goods)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | int | 主键,自增 |
| user_id | int | 发布者ID,关联user表 |
| category_id | int | 分类ID,关联category表 |
| title | varchar(100) | 商品标题 |
| description | text | 商品描述 |
| price | decimal(10,2) | 价格 |
| original_price | decimal(10,2) | 原价(用于显示折扣) |
| images | varchar(1000) | 商品图片,多张用逗号分隔 |
| status | tinyint | 状态,0-在售,1-已售,2-下架 |
| view_count | int | 浏览量 |
| create_time | datetime | 发布时间 |
这里有个很关键的细节:多张图片在数据表里怎么存?如果你非要拆一张子表去存,那查询商品列表时就要多一次联表查询,性能反而差。实际项目里我用逗号分隔的字符串存储,查询时直接取出来按逗号split,前端循环渲染就行。这种一定程度的反规范化,在数据量不大的场景下是最实用的。
分类表(category)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| name | varchar(50) | 分类名称 |
| sort | int | 排序权重 |
| create_time | datetime | 创建时间 |
分类表很简单,实际可以预置一些常见分类:教材书籍、数码电器、生活用品、运动器材、服饰美妆、其他。管理员可以在后台管理这些分类,前台首页的分类栏直接按sort排序循环输出。
收藏表(favorite)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| user_id | int | 用户ID |
| goods_id | int | 商品ID |
| create_time | datetime | 收藏时间 |
这里有个容易忽略的点:user_id和goods_id必须加联合唯一索引。不然用户手快连续点两次收藏,就会插入两条重复记录,列表页就会出现两行一模一样的收藏项。加了唯一索引后,我直接在Service层做insert ignore,重复点击也不会报错,这个设计非常省心。
2.3 索引设计与查询优化细节
数据量小的时候索引看不出差别,但一旦商品数据超过几千条,没有索引的SQL就会明显变慢。我们在商品表上建三个核心索引:
idx_goods_category(category_id):支撑首页按分类筛选。idx_goods_status(status):支撑“在售/已售/下架”的状态过滤。idx_goods_create_time(create_time):支撑最新商品排序。
用户表上针对openid建唯一索引,这是登录查询的高频SQL,每次用户进入小程序都要根据openid查一次用户信息,这个索引必须加。收藏表上user_id和goods_id的联合唯一索引,既是约束也是查询加速器。
另外浏览量的更新策略我实际用了“延迟更新”的思路。用户在详情页每次刷新都把view_count加1的话,会带来频繁的写操作。我的做法是前端离开详情页时上报一次浏览记录,后端接口统一做update,这样既保证了数据基本准确,又限制了一次进详情页只写一次数据库。
3. 后端SSM核心实现
3.1 SSM三大配置文件的搭建细节
我在做SSM配置时,发现很多同学的困难不是代码看不懂,而是配置文件的加载顺序和职责划分搞不清楚,导致项目一启动就报各种Bean找不到、URL映射404的错。
正确的方式是梳理好Web.xml这个总入口:
<!-- web.xml --> <web-app> <!-- 1. 加载Spring容器 --> <context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:applicationContext.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <!-- 2. 配置SpringMVC前端控制器 --> <servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping> <!-- 3. 字符编码过滤器,解决中文乱码 --> <filter> <filter-name>encoding</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> </filter> </web-app>这里要注意一个非常坑的点:Spring容器和SpringMVC容器是父子容器关系。SpringMVC容器只扫描Controller,Spring容器只扫描Service和Mapper。如果你把Service扫描也写进了SpringMVC的配置,大概率会出现事务失效的情况,因为Service层的Bean被两个容器各实例化了一次。我的配置里严格区分了扫描范围:spring-mvc.xml里只配<context:component-scan base-package="com.campus.controller"/>,applicationContext.xml里配Service、Mapper的扫描,这个约定一定要记住。
spring-mvc.xml里还有两个核心配置:一个是注解驱动(<mvc:annotation-driven/>),这是RequestMappingHandlerMapping和RequestMappingHandlerAdapter的开关;另一个是静态资源映射,如果你把商品图片存在了项目根目录的upload文件夹,必须配置<mvc:resources mapping="/upload/**" location="/upload/"/>,不然浏览器访问图片会返回404。
MyBatis的配置相对简单,核心是驼峰映射和日志:
<configuration> <settings> <setting name="mapUnderscoreToCamelCase" value="true"/> <setting name="logImpl" value="STDOUT_LOGGING"/> </settings> </configuration>开启驼峰映射后,数据库的create_time字段就能自动映射到JavaBean的createTime属性,否则你要在ResultMap里写一大堆字段映射,完全没必要。
3.2 微信登录接口的原理与实现
这是全项目最关键的一个接口,前端所有需要用户身份的请求都依赖它。
微信小程序的登录机制是这样的:小程序端调用wx.login()拿到一个临时code,把code传给后端;后端拿着code加上小程序的AppID和AppSecret,去请求微信的接口获取openid和session_key。code的有效期只有5分钟,且只能使用一次。
后端核心代码如下:
// WxLoginController.java @RestController @RequestMapping("/api/user") public class WxLoginController { @Autowired private UserService userService; @PostMapping("/login") public Result login(@RequestBody Map<String, String> params) { String code = params.get("code"); // 两个关键参数从配置文件读取 String appId = config.getAppId(); String appSecret = config.getAppSecret(); // 请求微信接口获取openid String url = "https://api.weixin.qq.com/sns/jscode2session?appid=" + appId + "&secret=" + appSecret + "&js_code=" + code + "&grant_type=authorization_code"; String result = HttpUtil.get(url); JSONObject json = JSON.parseObject(result); String openid = json.getString("openid"); if (StringUtils.isEmpty(openid)) { return Result.error("登录失败"); } // 查询用户是否存在,不存在则自动注册 User user = userService.findByOpenid(openid); if (user == null) { user = new User(); user.setOpenid(openid); user.setNickname("微信用户" + openid.substring(openid.length() - 6)); user.setRole(0); user.setStatus(0); userService.register(user); } // 生成自定义token,这里我用UUID,实际可存Redis String token = UUID.randomUUID().toString().replace("-", ""); // 返回给前端 Map<String, Object> data = new HashMap<>(); data.put("token", token); data.put("userInfo", user); return Result.success(data); } }关于openid的获取,我一直用HttpUtil直接调用微信API,没有引入微信官方SDK,因为这个项目只用到了登录这一个接口,引入SDK反而增加了依赖复杂度。
Token生成后,前端每次请求都会在Header里带上token,后端用拦截器统一校验。这个项目没有上Redis,token就存在内存Map里,项目重启后token会失效,用户需要重新登录。这在开发演示阶段完全够用,但如果要上线,建议换成Redis并设置过期时间。
3.3 商品发布与图片上传实现
商品发布是整个项目的核心业务流,涉及图片上传和商品信息入库两个动作。小程序的图片上传机制和普通form表单不一样,必须用wx.uploadFile,而后端对应的是MultipartFile接收。
这里有一个重要的经验:小程序端要先把图片传到服务器,拿到图片URL后再带着URL去提交商品表单。不要试图在一次请求里同时传图片和JSON参数,那样会在HTTP协议层面绕弯子,服务端解析也会变得非常麻烦。正确的流程是:
- 前端调用
wx.chooseMedia选图。 - 对选中的图片循环调用
wx.uploadFile,上传接口返回图片访问路径。 - 把本地临时路径替换为服务器路径,多个路径用逗号拼接。
- 再调用商品保存接口,把商品信息带着图片路径列表一起传给后端。
后端上传接口:
// FileUploadController.java @PostMapping("/upload") public Result upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return Result.error("文件不能为空"); } // 获取原始文件名后缀 String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); // 重新生成文件名,避免中文、特殊字符导致的乱码问题 String newFileName = System.currentTimeMillis() + "_" + UUID.randomUUID().toString().substring(0, 8) + ext; // 存储路径:项目根目录/upload/日期/ String datePath = new SimpleDateFormat("yyyyMMdd").format(new Date()); String realPath = "upload/" + datePath + "/"; File dir = new File(realPath); if (!dir.exists()) { dir.mkdirs(); } try { file.transferTo(new File(realPath + newFileName)); String visitPath = "/upload/" + datePath + "/" + newFileName; return Result.success(visitPath); } catch (IOException e) { e.printStackTrace(); return Result.error("上传失败"); } }我在存储路径上做了日期目录的划分,这样商品图片在服务器文件系统里就不会全部堆在一起,管理起来方便很多,排查某一天上传的违规图片时也能快速定位到目录。
商品保存接口的Service层我加了事务控制:
@Transactional(rollbackFor = Exception.class) public int addGoods(Goods goods) { goods.setStatus(0); goods.setViewCount(0); goods.setCreateTime(new Date()); return goodsMapper.insert(goods); }多张图片路径已经在本方法之前上传并拼接好了,入库时就是一个带逗号的字符串。这里有个细节:上传图片成功后,如果用户放弃了发布,那服务器上会残留一张无主的图片。我的处理是上传接口先不管,等商品保存失败时,再把已经上传的图片文件删除,保持数据一致性。
3.4 MyBatis动态SQL的灵活应用
商品列表接口是查询逻辑最复杂的部分,因为它要支持按分类筛选、按关键字搜索、按状态过滤、分页、排序,而且这些条件还是组合出现的。如果不用MyBatis的动态SQL,你会写出一堆穷举组合的SQL语句,维护起来真的是噩梦。
我用<where>标签配合<if>标签轻松搞定:
<select id="searchGoods" resultType="com.campus.entity.Goods"> SELECT g.*, u.nickname AS publisherName, u.avatar AS publisherAvatar, c.name AS categoryName FROM goods g LEFT JOIN user u ON g.user_id = u.id LEFT JOIN category c ON g.category_id = c.id <where> <if test="keyword != null and keyword != ''"> AND g.title LIKE CONCAT('%', #{keyword}, '%') </if> <if test="categoryId != null"> AND g.category_id = #{categoryId} </if> <if test="status != null"> AND g.status = #{status} </if> </where> ORDER BY <choose> <when test="sort == 'price_asc'"> g.price ASC </when> <when test="sort == 'price_desc'"> g.price DESC </when> <otherwise> g.create_time DESC </otherwise> </choose> LIMIT #{offset}, #{pageSize} </select>这段SQL同时关联了用户表和分类表,一次性把商品、发布者昵称头像、分类名称查出来,前端就不需要再发额外的请求去查用户信息和分类名称了,这是一个很实用的性能优化技巧。另外注意排序字段用了<choose>而不是直接把前端参数拼进ORDER BY,这是为了防止SQL注入——排序字段没法用预编译参数,所以必须做白名单校验。
4. 小程序端核心实现
4.1 项目目录结构与全局配置
小程序端的目录结构遵循了微信官方的约定:
miniprogram/ ├── app.js # 全局逻辑,初始化登录态 ├── app.json # 全局配置,页面路由、窗口样式、tabBar ├── app.wxss # 全局样式 ├── utils/ │ └── request.js # 封装的请求工具 ├── pages/ │ ├── index/ # 首页 │ ├── category/ # 分类页 │ ├── goods-detail/ # 商品详情 │ ├── publish/ # 发布商品 │ ├── message/ # 留言咨询 │ ├── favorite/ # 我的收藏 │ ├── user/ # 个人中心 │ └── mine/ # 我发布的商品 ├── components/ │ ├── goods-card/ # 商品卡片组件 │ └── empty/ # 空状态组件 └── static/ └── images/ # 静态图片资源app.json里配置了底部tabBar,四个入口分别对应首页、分类页、发布入口按钮(这个是个人中心的快捷入口,真实交互上tabBar的按钮指向个人中心)、消息和个人中心。tabBar的icon我用的线上图片地址,但实际操作中建议大家把icon放到本地static目录,因为线上地址一旦失效,tabBar上就会显示一个难看的裂图。
全局请求封装是这个小程序项目的基础设施,我把所有的公共逻辑都放在request.js里:
// utils/request.js const BASE_URL = 'http://localhost:8080'; // 开发环境地址 function request(url, method = 'GET', data = {}) { return new Promise((resolve, reject) => { const token = wx.getStorageSync('token'); wx.request({ url: BASE_URL + url, method: method, data: data, header: { 'Content-Type': 'application/json', 'token': token || '' }, success: (res) => { // 业务状态码统一处理 if (res.data.code === 200) { resolve(res.data.data); } else if (res.data.code === 401) { // token失效,重新登录 wx.removeStorageSync('token'); wx.navigateTo({ url: '/pages/login/login' }); reject(res.data); } else { wx.showToast({ title: res.data.msg, icon: 'none' }); reject(res.data); } }, fail: (err) => { wx.showToast({ title: '网络异常', icon: 'none' }); reject(err); } }); }); } module.exports = { request };封装好之后,每个页面只需调用request('/api/goods/list', 'GET', {categoryId: 1})就能拿到数据,错误处理和登录态失效拦截都在统一层做了,页面代码非常干净。
4.2 小程序登录流程与全局用户态管理
小程序的登录逻辑在app.js的onLaunch里启动:
// app.js App({ onLaunch: function () { this.login(); }, login: function () { const that = this; return new Promise((resolve, reject) => { wx.login({ success: (res) => { if (res.code) { request('/api/user/login', 'POST', { code: res.code }).then((data) => { wx.setStorageSync('token', data.token); wx.setStorageSync('userInfo', data.userInfo); that.globalData.userInfo = data.userInfo; resolve(data); }).catch(reject); } else { console.error('登录失败', res); reject(res); } } }); }); } });这里有一个体验优化的细节:登录成功后不强制跳转到任何页面,用户直接停留在当前页面,登录态在后台默默完成。因为很多用户只是随便逛逛二手市场,并不想一进来就被强制登录。只有当用户点击“发布商品”或者“收藏”时,才检查是否存在token,如果不存在就引导去登录。
实际使用中微信用户可能多次点击登录按钮,所以登录必须做幂等处理。后端每次根据code换取openid时,如果用户在数据库里已存在,就不重复插入,而是直接返回已有用户信息,这也是为什么我在登录接口里做了“先查后插”的判断。
4.3 首页商品列表、分类筛选与分页加载
首页是小程序的流量入口,我把它做成了四块:顶部的搜索框、分类导航栏、商品瀑布流列表、加载状态提示。
商品列表的动态更新有两个关键事件:
// pages/index/index.js Page({ data: { categories: [], goodsList: [], currentCategory: 0, // 0表示全部 keyword: '', pageNum: 1, pageSize: 10, hasMore: true, loading: false }, // 下拉刷新 onPullDownRefresh: function () { this.setData({ pageNum: 1, goodsList: [], hasMore: true }); this.loadGoods().then(() => { wx.stopPullDownRefresh(); }); }, // 触底加载更多 onReachBottom: function () { if (this.data.hasMore && !this.data.loading) { this.setData({ pageNum: this.data.pageNum + 1 }); this.loadGoods(); } }, loadGoods: function () { const that = this; that.setData({ loading: true }); const params = { pageNum: that.data.pageNum, pageSize: that.data.pageSize, categoryId: that.data.currentCategory === 0 ? null : that.data.currentCategory, keyword: that.data.keyword || null }; return request('/api/goods/list', 'GET', params).then((res) => { const list = res.list || []; that.setData({ goodsList: that.data.goodsList.concat(list), hasMore: that.data.goodsList.length + list.length < res.total, loading: false }); }).catch(() => { that.setData({ loading: false }); }); } });分页这里有一个新手常犯的错误:后端返回的在售商品总数是固定的,但前端在拼接列表时如果不去重,会在某些边界情况下出现重复数据。我的处理是后端在做分页查询时,用create_time DESC, id DESC双重排序,保证每次分页顺序完全稳定,这样就不会因为新增商品导致分页数据错位。
4.4 发布页面与个人中心的功能联动
发布商品页面的表单字段有商品标题、分类选择、价格、原价(选填)、描述、图片。在模拟器上用户体验还行,但真机上有一个问题:在模拟器上wx.chooseMedia会自动从相册选择图片,但在真机上系统会弹窗询问是否允许使用相机和相册权限,这是微信的统一规范,无法跳过。
图片选择环节,我做了最多6张的限制,实际开发中可以设置count: 6:
wx.chooseMedia({ count: 6 - this.data.images.length, mediaType: ['image'], success: (res) => { const tempFiles = res.tempFiles; // 每张图片循环调用上传接口 const uploadTasks = tempFiles.map((file) => this.uploadImage(file.tempFilePath)); Promise.all(uploadTasks).then((urls) => { this.setData({ images: this.data.images.concat(urls) }); }); } });个人中心页面的模块设计则要突出“交易闭环”。我把它设计成了三块:用户信息卡片(头像、昵称、联系方式)、交易入口列表(我发布的、我卖出的、我买到的、我的收藏)、系统功能列表(意见反馈、关于平台、退出登录)。其中“我发布的”页面支持对商品进行编辑和下架操作,下架后商品从首页消失,但数据保留在数据库中,状态变为2,管理员和用户本人能看到。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 真机预览白屏 | 域名不合法或未配置 | 开发者工具勾选“不校验合法域名”,上线前配置request合法域名 |
| 手机显示网络异常 | 后端服务没跑或IP不对 | 确保后端监听0.0.0.0,前端BASE_URL改为局域网IP |
| 发布商品图片裂图 | 图片URL是本地路径无法访问 | 检查静态资源配置<mvc:resources>,确认图片存在 |
| 登录成功但获取用户信息失败 | openid不一致 | 检查AppID是否同一个,同一小程序openid才一致 |
| 商品列表加载很慢 | 全表扫描无索引 | 按2.3节为商品表、用户表加索引 |
| 页面提交后中文乱码 | 编码过滤器没配或配置位置不对 | Web.xml里加CharacterEncodingFilter,且放在过滤器链最前面 |
| 下拉刷新无效 | 页面没有开启enablePullDownRefresh | app.json或页面json中设置"enablePullDownRefresh": true |
5.2 真机预览白屏和网络请求失败
这是小程序开发中遇到最多的一个坑,我在做这个项目时也一度被搞到怀疑人生。模拟器上一切正常,一扫码真机就白屏,控制台报错net::ERR_CONNECTION_REFUSED或者not found。
原因有两个层面。
第一是域名问题。微信小程序要求所有网络请求必须走HTTPS,且域名必须在小程序后台配置为request合法域名。开发阶段为了省事,开发者工具里可以勾选“不校验合法域名”选项,但真机上没有这个选项。解决方法是:开发阶段使用局域网IP访问本机后端,但记得在手机和电脑连同一个Wi-Fi的前提下,把BASE_URL从localhost改成电脑的局域网IP,比如http://192.168.1.100:8080。真机上就能正常请求了。
第二个看不见摸不着但常见的坑是:SpringMVC默认不允许跨域。虽然小程序端的请求不算浏览器跨域请求,但在真机调试时如果走了WebView某些特殊逻辑,还是可能触发跨域问题。稳妥起见,在后端加一个全局的CORS配置:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }5.3 Tomcat图片上传大小限制
我实际测试时,上传一张手机拍的照片(大约3到5MB)到后端,直接报了MaxUploadSizeExceededException。排查后发现是SpringMVC默认的上传大小限制是1MB。解决办法是在spring-mvc.xml里配置multipart解析器:
<bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver"> <property name="defaultEncoding" value="UTF-8"/> <property name="maxUploadSize" value="10485760"/> <!-- 10MB --> <property name="maxUploadSizePerFile" value="5242880"/> <!-- 单个文件5MB --> </bean>但这里要注意,微信小程序端的上传限制是单张图片最大10MB,我们服务端如果限制为5MB,高频发生的真实场景是:用户选了一张高清原图,上传失败但前端没有提示,用户误以为发布成功了,结果商品图片是空的。我推荐的做法是前端在wx.chooseMedia选完图后,先压缩图片,再上传:
wx.compressImage({ src: file.tempFilePath, quality: 80, // 压缩质量 success: (res) => { // 用压缩后的路径上传 } });这样既能减小流量消耗,又能规避服务端大小限制问题。压缩后的图片质量对二手交易展示完全没有影响。
5.4 时间字段的时区与格式化问题
数据库datetime类型存储的是本地时间,但Java后端返回给前端的JSON默认序列化格式是yyyy-MM-dd HH:mm:ss还是时间戳,取决于Jackson的配置。我在实际项目中统一配置成中国时区的标准格式:
@Configuration public class JacksonConfig { @Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder -> { builder.simpleDateFormat("yyyy-MM-dd HH:mm:ss"); builder.timeZone(TimeZone.getTimeZone("GMT+8")); }; } }不配这个的话,前端拿到的可能是"2024-06-01T08:00:00.000+00:00"这种格式,直接显示在页面上非常奇怪。前端再配合一个相对时间的转换,比如“3分钟前发布”,体验会好很多。
5.5 商品状态流转与缓存一致性
商品从发布到交易的完整状态流是:在售(0)→ 已售(1)或下架(2)。这里的核心边界情况是:当卖家把商品标记为“已售”时,其他用户如果刚好在浏览详情页并点击“收藏”,应该给一个友好的提示,而不是直接报错。我的做法是在收藏接口里先查询商品的status,如果不是在售状态,直接返回“该商品已下架或已出售”。
因为商品列表没有做缓存,每次刷新都是从数据库实时查询,所以状态更新后,用户重新进入首页就能看到最新状态,不存在缓存一致性问题。如果以后数据量大了,要给首页列表加Redis缓存,到时候要注意商品状态变更后主动删除缓存,不然用户会看到已售商品还挂在首页上。
6. 这个项目还能怎么演化
如果你打算在这个项目基础上继续做深,我个人觉得有两个很有价值的方向。
第一个方向是增加“订单交易”流程。目前的线下交易模式比较原始,加一个订单表之后,用户可以在线约定面交时间、地点、生成交易码,实际见面后由卖家扫码确认交易完成,这样双方的交易安全性会提高很多,平台也可以做一些信用评价体系。
第二个方向是引入消息推送。微信小程序提供了订阅消息能力,用户收藏的商品有降价、被其他用户留言时,可以给收藏者推送一条模板消息。这个功能开发量不大,但对用户体验的提升是巨大的,因为二手交易很大程度上靠“抢”,及时的通知能显著提高成交率。
我把这个项目的源码、数据库脚本、部署文档都整理在项目包里了,如果按着这篇文章的顺序一步步做,一个完整的前后端分离项目基本能搭起来。最想强调的还是那句话:做一个项目,数据表设计不要图省事,配置文件的加载顺序一定要搞清楚,联调阶段把日志打详细一点,遇到报错先看控制台而不是凭感觉乱猜。把这些基本素养练好了,你后面写什么项目都会顺手很多。