有些项目你一看标题就大概猜到它是什么套路:微信小程序做前端、SSM(Spring+SpringMVC+MyBatis)做后端、内容选个传统文化题材,一套标准毕设或课设配置。但真正动手把一个这样的项目从空目录做到能跑、能演示、能答辩,中间要踩的坑和要补的细节,比大多数人预想的多得多。
这篇就以我实际搭过的一个中国剪纸微信小程序为例,把整个设计思路、代码结构、接口约定、部署联调这些环节完整拆一遍。不是泛泛讲架构图,而是直接告诉你每一步是怎么定的、SQL是怎么写的、小程序那边怎么把数据画出来,以及最容易被忽略的那些联调细节。特别适合准备拿同类题目做毕业设计的同学,或者想快速搭一个文化类小程序Demo的开发者参考。
1. 项目背景:为什么是剪纸题材,又为什么用SSM
1.1 剪纸题材的数字化逻辑
剪纸作为非物质文化遗产,天然适合用图片类应用去承载。它的视觉形式强,稍微拍一张成品图就能传递美感;分类维度又很清晰——按人物、花鸟、窗花、吉祥图案分,用户可以很快产生浏览欲望。它不是那种需要复杂3D展示或音视频强交互的题材,用小程序这种轻量载体最合适,用户点开即看,随手转发。
我做这个项目定下的产品基调很简单:以作品图集为核心,搭配分类筛选、搜索、收藏、评论这些常规互动功能,再给管理员配一套后台管理界面。整体功能量控制在“一个合格课设”的范围内,但代码结构必须是真实项目级别的,不能是那种把所有逻辑堆在一个Controller里的demo。
1.2 用SSM而不是SpringBoot的真实原因
现在很多人新项目直接上SpringBoot,这个选择本身没错。但我当时考虑的是课程设计和毕设的评分习惯,尤其是涉及源码讲解和过程考核的时候,SSM这种手写配置的方式反而能体现出对框架原理的理解。Spring的IOC容器、SpringMVC的请求流转、MyBatis的SQL映射,每一层都能单独拿出来讲,这在答辩时是很加分的。
另外,SSM的生态资料非常全,遇到问题基本都能搜到现成答案。它和小程序的组合也是过去几年毕设的主流搭配,参考案例多,老师挑不出毛病。用SpringBoot虽然开发快,但在“展示工作量”这件事上,SSM更合适。从纯工程效率看,SpringBoot确实省事;从项目学习价值看,SSM让你把HTTP请求从进入DispatcherServlet开始,到Controller、Service、Mapper,最后到数据库执行SQL返回结果的全链路都过一遍,这个认知对后面用任何框架都有帮助。
所以我最终定下的技术组合是:微信小程序原生开发做前端,SSM做后端接口,MySQL存数据,本地用Tomcat跑服务,线上部署到云服务器。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 系统架构与功能模块划分
2.1 三层架构怎么落到这个项目上
我把整个系统分成了三个物理端:微信小程序客户端、SSM后端服务、MySQL数据库。小程序端只管页面渲染和用户交互,所有数据都通过HTTP请求走后端接口;后端严格执行Controller、Service、Mapper三层分包;数据库只存数据,不做任何业务逻辑。
后端包结构长这样:
code复制com.kaic.culture
├── controller // 接口层,接收小程序请求
├── service // 业务层,处理收藏、分类查询等逻辑
│ └── impl
├── mapper // MyBatis接口,对应XML中的SQL
├── entity // 数据库实体类
├── config // Spring配置、拦截器配置
├── interceptor // 登录状态拦截器
└── common // 统一返回结果、Token工具、异常处理
有人会觉得课设项目不用分这么细,Controller里直接写业务也能跑。分层的价值在项目稍微复杂一点以后会立刻体现:比如收藏功能需要在判断登录状态的同时去查作品是否存在、然后写收藏表、再更新作品的收藏数,这几个操作横跨了接口校验、业务组装、数据访问三个层面,如果不分层,Controller会越写越臃肿。
2.2 用户端和管理端到底各做了什么
用户端的功能我控制在六个模块:首页作品瀑布流、分类浏览、关键词搜索、作品详情、收藏管理、个人中心。个人中心里最核心的是登录态展示和我的收藏列表,其他像关于页面这种纯静态内容,我直接写死在页面上,不需要后端接口。
管理端没有单独做PC页面,而是复用同一套SSM接口,通过一个简单的AdminController提供数据管理请求,配合一个由Bootstrap搭建的简易管理页。管理页能完成作品上传(图片以Base64传给后端)、作品分类的新增与编辑、用户列表查看、收藏数据和评论数据的统计展示。
下表是两端的功能清单:
| 端 | 功能模块 | 核心操作 |
|---|---|---|
| 小程序端 | 首页 | 轮播图、热门剪纸展示、分类Tab切换 |
| 小程序端 | 作品模块 | 列表浏览、关键词搜索、分类筛选 |
| 小程序端 | 详情页 | 大图查看、作品介绍、评论列表 |
| 小程序端 | 互动模块 | 收藏/取消收藏、发表评论 |
| 小程序端 | 个人中心 | 微信登录、我的收藏、留言记录 |
| 管理端 | 作品管理 | 新增/编辑/删除剪纸作品 |
| 管理端 | 分类管理 | 分类的增删改查 |
| 管理端 | 数据统计 | 用户数、收藏数、评论数汇总 |
功能拆到这里,我心里已经有边界了:小程序端是重头,管理端只要够用就行。很多人在这种项目上翻车,就是因为管理端塞了太多页面,结果核心的作品展示和收藏交互反而没时间做精致。
3. 数据库设计:五张表撑起整个业务
3.1 核心表结构与字段设计
数据库命名用了cut_paper作为库名,一共五张表:用户表、剪纸作品表、分类表、收藏表、评论表。另外加了管理员表和轮播图表,主要是因为管理端需要独立的登录账号,首页轮播图也需要配置项,但我把它们合并到了用户表和配置表里来精简表数量。
用户表cut_user主要字段:id、openid(微信唯一标识)、nickname、avatar、create_time。openid对外不可见,小程序端拿到的用户身份用一个自研token代替。
作品表cut_product是最重要的表,字段设计上花了一些心思:
code复制id 作品ID
title 作品标题
category_id 分类ID
cover_image 封面图URL
detail_images 详情图URL(允许多张,用逗号分隔)
description 作品描述
favorite_count 收藏数量
view_count 浏览量
create_time 发布时间
status 上下架状态 0下架 1上架
detail_images用逗号分隔存储多张图片,这么做牺牲了规范化的第三范式,但换来了查询效率。因为小程序的请求量主要落在详情页上,一次查询把所有图片URL拿出来,比多建一张子表再做关联查询更快,也更简单。从实际项目角度看,这种取舍没问题。
分类表cut_category字段:id、name、sort_order。事先预置四个分类:窗花类、人物类、花鸟类、吉祥图案类。每个分类下挂对应的作品,小程序端首页的分类Tab就是遍历这张表生成的。
收藏表和评论表比较简单:收藏表核心字段是id、user_id、product_id、create_time,唯一索引建在user_id和product_id的联合上,防止重复收藏;评论表字段是id、user_id、product_id、content、create_time,前端展示时联表查用户昵称和头像。
3.2 建表SQL与索引设计要点
我用Navicat手动建的表,这里给出收藏表的建表语句作为参考:
sql复制CREATE TABLE `cut_favorite` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) DEFAULT NULL,
`product_id` int(11) DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_user_product` (`user_id`,`product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
索引这里有一个小教训:一开始我只给user_id建了普通索引,查询某个用户收藏列表时没问题,但判断“当前用户有没有收藏过这个作品”时,用两个条件同时查时总要走两次索引,后来改成联合索引后发现性能好很多。在我的收藏列表页需要按时间倒序展示,这条联合索引也直接覆盖了排序需求。
作品表的status字段我建了普通索引,因为首页只查status=1的作品,这个过滤条件加上索引之后,数据量上千条时的查询差别感知明显,不过说实话小数据量下索引意义不大,建它是为了让SQL习惯保持专业。真正需要留意的是detail_images这个字段——它在POJO里对应一个String,返回给前端时会把逗号分隔的字符串转成数组,这个转换在Service层做,避免小程序端再处理。
3.3 表之间的关联关系
五张表的关系并不复杂,我在做的时候给评论表加了user和product的冗余字段,这样查询评论列表时可以直接查出昵称和作品标题,减少一次关联。冗余字段的代价是如果用户改了昵称,历史评论里的昵称不会同步更新。考虑到这是一般评论区的常见行为,为了性能接受这个瑕疵。
管理端统计数据时,用几条SQL分别count各表记录数即可。收藏数有专门的favorite_count字段维护,用户每次收藏成功时执行update操作,不需要定时聚合,数据一致性足够,代价是每次收藏操作多一条update语句,属于典型的以写换读。
4. SSM后端核心实现:接口怎么设计,代码怎么写
4.1 统一返回格式与异常处理
后端给小程序端的所有接口,返回的JSON结构完全统一成三个字段:code、message、data。
java复制public class Result<T> {
private Integer code;
private String message;
private T data;
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.code = 200;
result.message = "success";
result.data = data;
return result;
}
public static <T> Result<T> error(Integer code, String message) {
Result<T> result = new Result<>();
result.code = code;
result.message = message;
return result;
}
}
小程序端封装了一个request函数,在success回调里统一判断code是否为200,等于200才取data,否则弹出message。这个约定在联调时能省去大量排查时间。全局异常处理我用springmvc的@ControllerAdvice加@ExceptionHandler,把业务异常和系统异常分开,业务异常返回50001这种业务码,系统异常直接返回500并且打印日志。
4.2 登录接口与Token机制
小程序端登录流程:wx.login获取临时code,把它传给后端接口/user/login,后端拿code去微信接口换openid和session_key,然后把openid作为唯一标识去用户表查询,如果不存在就自动注册新用户,最后生成一个自定义token返回给小程序。
这里我没有直接用微信的session_key有效期,而是自己生成了一个32位随机字符串token,存到数据库user表的token字段里,并把过期时间设置为30天。小程序端拿到token后存入storage,之后每次请求都在header里带X-Token字段。后端写了一个拦截器,拦截除登录接口以外的所有请求,从header里取token,去数据库比对,比对失败就返回401。
xml复制<mvc:interceptors>
<mvc:interceptor>
<mvc:mapping path="/**"/>
<mvc:exclude-mapping path="/user/login"/>
<mvc:exclude-mapping path="/product/list"/>
<mvc:exclude-mapping path="/product/detail"/>
<bean class="com.kaic.culture.interceptor.LoginInterceptor"/>
</mvc:interceptor>
</mvc:interceptors>
这个拦截器看起来简单,但有三个细节必须处理:一是放行的接口不止login,所有不需要登录就能访问的查询接口都要在exclude里列出来;二是token过期后小程序端收到401,要能自动跳转到登录页重新授权,不能卡死在当前页面;三是登录拦截不能拦截图片URL,那些是静态资源,不走接口。
4.3 作品列表的分页与条件查询
作品列表接口是最常用的接口,支持三个参数:categoryId(分类ID)、keyword(搜索关键词)、pageNum和pageSize(分页参数)。用户从首页切换分类Tab时传categoryId,搜索框输入关键词时传keyword,两个条件可以叠加。
Controller层直接把参数透传给Service:
java复制@ResponseBody
@RequestMapping("/product/list")
public Result<PageResult<ProductVO>> list(@RequestParam(required = false) Integer categoryId,
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "10") Integer pageSize) {
return Result.success(productService.queryPage(categoryId, keyword, pageNum, pageSize));
}
MyBatis的XML里动态SQL这样写:
xml复制<select id="queryPage" resultType="com.kaic.culture.entity.Product">
SELECT * FROM cut_product
<where>
<if test="categoryId != null">
AND category_id = #{categoryId}
</if>
<if test="keyword != null and keyword != ''">
AND title LIKE CONCAT('%', #{keyword}, '%')
</if>
AND status = 1
</where>
ORDER BY create_time DESC
LIMIT #{offset}, #{pageSize}
</select>
注意这里的offset要自己算,不是直接把pageNum传给MyBatis。我在Service层里计算了offset=(pageNum-1)*pageSize,因为MyBatis的LIMIT不支持直接传页码。另外还要用PageResult包装分页参数,返回给前端时把total传过去,小程序端的触底加载才有个总条数来判断是否还有下一页。
4.4 收藏与评论的防重复处理
收藏接口逻辑比较简单:判断用户是否已收藏,没有则插入记录并favorite_count加1,已收藏则删除记录并让favorite_count减1。这里被我做成一个接口,通过前端传一个type参数区分收藏还是取消,减少一次请求往返。
关键点在于并发场景下的防抖。万一用户手快连点两次收藏按钮,两个请求同时进来,就可能出现重复插入。我在收藏表上建了唯一索引,插入时捕获DuplicateKeyException,捕获到以后直接返回“你已经收藏过了”的提示。这个做法简单粗暴但非常有效,比先查再插入更可靠。
评论功能相对简单,唯一要注意的是评论内容后端要做一个HTML转义,防止内容里携带恶意标签。我用了一个简单的工具类把尖括号替换成全角符号,防XSS攻击足够了。
5. 小程序端:从页面结构到交互实现的完整过程
5.1 页面文件怎么组织
微信小程序的每个页面由wxml、wxss、js、json四个文件组成。我的页面结构如下:
code复制pages/
├── index/ // 首页:banner + 分类Tab + 作品瀑布流
├── category/ // 分类页(与首页Tab共用,但独立入口)
├── detail/ // 作品详情:大图、描述、收藏按钮、评论区
├── search/ // 搜索结果页
├── favorite/ // 我的收藏列表
├── mine/ // 个人中心:用户信息、收藏入口、退出登录
└── login/ // 登录页(授权窗口)
首页用scroll-view实现分类Tab横向滚动,下面是双层for循环:外层遍历分类列表,内层遍历该分类下的作品卡片。瀑布流效果并不是真正的不等高布局,而是两列左流右流的近似方案,每个卡片固定宽度,图片高度按比例自适应,视觉上已经足够自然。
5.2 wx.login与用户信息授权
登录流程在小程序端的完整实现是这样的:
javascript复制handleLogin() {
wx.login({
success: (res) => {
if (res.code) {
wx.request({
url: `${baseUrl}/user/login`,
method: 'POST',
data: { code: res.code },
success: (loginRes) => {
const { token, userInfo } = loginRes.data.data;
wx.setStorageSync('token', token);
this.setData({ userInfo });
}
});
}
}
});
}
这里有一个容易踩坑的知识点:wx.login获取的code是一次性的,有效期只有五分钟,而且使用一次之后立即失效。如果你在小程序后台代码里误用了两次,比如先拿它换openid又拿它换unionId,第二次调用必然失败。我从一开始就只让后端接口调用一次微信服务,如果需要缓存用户信息,后续都从自己数据库里查询。
用户昵称和头像的处理我用了最简单的方案:首次登录时从微信的userInfo接口获取并传到后端存储,后续就不再做更新操作。这样做有个好处是避免每次都弹授权框,用户体验更好,坏处是用户修改微信头像后小程序里的头像不会跟着变,不过这不是硬伤,反而有一个固定头像能让UI更稳定。
5.3 首页作品流与触底加载
首页数据加载用的是onReachBottom钩子,每次触底把pageNum加1,然后请求下一页数据。这里比较讲究的是数据累加逻辑:
javascript复制onReachBottom() {
if (this.data.hasMore) {
const nextPage = this.data.pageNum + 1;
this.loadProducts(nextPage);
}
}
loadProducts(pageNum) {
wx.request({
url: `${baseUrl}/product/list`,
data: {
categoryId: this.data.currentCategoryId,
pageNum: pageNum,
pageSize: 10
},
success: (res) => {
const list = res.data.data.list;
this.setData({
products: this.data.products.concat(list),
pageNum: pageNum,
hasMore: list.length === 10
});
}
});
}
hasMore的判定我用list.length === 10而不是判断total,原因是这样前端不需要知道总条数,也不用在最后一页做额外判断,非常简单有效。实际测试中最后一页如果返回的条数不足10条,hasMore自动变成false,触底后不再发请求。如果恰好最后一页正好10条,会多发起一次请求然后得到一个空列表,多加一个空列表判断即可。这个处理在数据量上千条时体验不错,不会有闪烁和加载延迟。
5.4 详情页的收藏与评论交互
详情页右上角有一个收藏图标,初始化时通过/checkFavorite接口查询当前用户是否已收藏,然后切换图标的实心和空心状态。点击收藏按钮时,如果在两秒内再次点击,前端做一个简单防抖,避免并发请求。评论区的输入框在键盘弹起时,我用adjust-position属性让输入框跟随键盘上移,避免被键盘遮住。
评论列表用的是scroll-view,每次加载10条评论,下拉刷新就重新从第一页拉取。评论用户的头像和昵称因为我在后端冗余存储了,详情页拉取评论列表时直接就带上了,不需要再额外查用户表,整体加载速度感知明显。
6. 联调、部署与真实踩坑记录
6.1 本地联调时的cors配置
开发阶段小程序把请求域名设为127.0.0.1加端口,需要在开发者工具的“详情-本地设置”里勾选“不校验合法域名”,否则localhost请求直接报错。后端要处理跨域,因为小程序的origin是https://servicewechat.com,而本地后端是http://localhost:8080,浏览器同源策略拦截掉这些请求必须在后端解决。
我用SpringMVC的CORSFilter统一配置:
xml复制<mvc:cors>
<mvc:mapping path="/**"
allowed-origins="*"
allowed-methods="GET,POST,PUT,DELETE,OPTIONS"
allowed-headers="Content-Type,X-Token"
max-age="3600"/>
</mvc:cors>
这个配置在云服务器部署后依然有用,因为AppID不同或来源域名变化时,allowed-origins设为*能省掉很多麻烦。虽然从安全角度来说生产环境应该精确指定域名,但很多人在这类项目中卡住就是因为忘了配跨域,小程序端报出一个“request:fail”非常难排查。
6.2 图片403防盗链问题
项目刚部署到服务器时发现一个大问题:所有的剪纸图片都能在浏览器正常打开,但在小程序里图片全部裂开。排查网络请求发现图片返回403,原因是我图床上传的图片设置了Referer防盗链,而小程序发起图片请求时Referer是servicewechat.com,被图床拦截了。
解决方案有三种:一是把图片上传到自己的服务器,不走第三方图床;二是购买支持关闭防盗链的图床;三是后端做图片代理。我选择了最稳妥的第一种,在自己的服务器上加了一个upload目录存图片,并且上传接口限制文件类型只能是jpg、png、gif。这样做还有一个附带好处:管理端上传的作品图片集中在同一目录,清理和维护都很方便。
6.3 数据库时区与时间字段不一致
部署后时间字段一直显示比北京时间早8个小时,这是因为MySQL默认用的UTC时区,而服务器是北京时间。看起来只是显示问题,但在管理端统计“今日新增收藏”时出了严重bug,凌晨以后的数据全部被当成前一天。
修复方法是在数据库连接URL里加serverTimezone=Asia/Shanghai和useSSL=false:
code复制jdbc:mysql://localhost:3306/cut_paper?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
加了这一行之后,时间问题彻底解决。所以所有涉及Java连接MySQL的项目,连接串里的时区参数一定早配早安心。
6.4 MyBatis驼峰映射与下划线字段
实体类的字段我用驼峰命名(createTime),数据库字段用下划线命名(create_time),结果查询返回的数据里createTime一直为null。刚开始还以为是SQL写错了,排查半天发现MyBatis没有开启驼峰映射。
在mybatis-config.xml里加一行配置:
xml复制<settings>
<setting name="mapUnderscoreToCamelCase" value="true"/>
</settings>
如果用的是SpringBoot,就在application.yml里加map-underscore-to-camel-case: true。这个配置几乎每个SSM项目都必须要配,不然带下划线的字段全都会失效。
6.5 小程序端setData的渲染性能问题
我最初在评论列表每加入一条新评论时,都调用一次setData(评论数组),结果在评论数量超过50条时,页面开始出现明显的卡顿。后来优化为:把评论数据收集到一个临时数组,每次滚动到底部时一次性setData。这个改进让滚动流畅度提升明显。另一个问题是每次点赞或收藏操作都要setData来切换样式,这类高频操作必须保留。
6.6 服务器部署的完整步骤
部署到云服务器时,我把项目打包成war包放到Tomcat的webapps目录下。需要注意Tomcat和JDK版本匹配,我用的是Tomcat 8.5 + JDK 1.8,两个版本配合最稳。小程序端请求的域名必须是HTTPS,所以我对nginx做了反向代理,把443端口的请求转发到Tomcat的8080端口,SSL证书就是我在云服务商那申请的一张免费证书。
nginx的核心配置:
nginx复制server {
listen 443 ssl;
server_name yourdomain.com;
ssl_certificate /etc/nginx/ssl/ssl.pem;
ssl_certificate_key /etc/nginx/ssl/ssl.key;
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
小程序后台的request合法域名也填这个HTTPS的地址。从这里开始,整个系统走通:用户在小程序里看到剪纸作品,点亮收藏,发表评论,管理员在后台维护内容。
7. 项目做完之后我的一些实际操作体会
如果这个项目是你第一个完整做完的“小程序+后端”项目,做完以后建议自己再做两件事:一是把源码里的SQL语句全部跑一遍拿到实际数据,形成用户、作品、收藏、评论各几十条的样本数据,演示时有数据支撑比空页面有说服力得多;二是把网络请求的封装统一到一个api.js文件里,方便后续换域名或者加拦截器,我实际项目中一开始把request写在每个页面里,后面接统一token拦截时改了七八个文件才改干净。
另外给准备拿这个题目来做毕设的同学提个醒,分类Tab上面如果只有一个“全部”选项,演示的时候会很干,建议在首页放一个轮播图,如图片是剪纸的历史由来、非遗介绍、衍生品图片,这个效果虽然不增加开发难度,但视觉丰富度能提不少。
我整理这个项目源码时还顺手把每张表都造了一组典型数据,把首页轮播图换成带有文化介绍的图,把管理端里加了一页简单的数据统计,让整个项目的完整度和叙事性都强了很多。这也算这几天挖下来的一个心得:技术本身不难,难的是把项目做得像个能用的产品,而剪纸这类文化题材本身就能给技术项目加上一层意义感,这是我坚持选这个题目的另一个原因。
