选题阶段最容易被问的一个问题就是:“做个漫画小程序到底难不难?”今年陆续帮几位学弟学妹把关过类似题目,看到“springboot爱看漫画小程序”这个标题的频率确实不低。今天就把这个题目从立项到落地拆开聊一聊,包括技术选型、数据库设计、接口规划、小程序端实现,以及那些文档里查不到的坑。给正在做毕业设计或者想练手完整全栈项目的朋友一个可以照着走的参考。
漫画类小程序和常见的商城、博客项目有个本质区别:它的核心是内容展示与阅读体验,而不是交易闭环。这意味着你要重点处理图片加载、章节分页、阅读进度、收藏同步这些场景,工作量集中在内容组织与状态维护上。后端用Spring Boot,前端走小程序原生框架,这是一套非常稳妥、也足够展示能力的组合。
1. 项目整体定位:一个漫画小程序该做什么
1.1 为什么选Spring Boot加小程序
先说选型。Spring Boot在校园项目里的统治力不用多说,生态成熟、资料多、出了问题能搜到答案。小程序端选择原生框架而不是uniapp或Taro,原因很直接:毕业设计答辩时,老师大概率会问“你这个页面怎么渲染的、接口怎么调的”,用原生框架你能把每一行代码的逻辑讲清楚。第三方跨端框架虽然开发效率高,但封装层太多,一旦被追问内部机制,很容易卡壳。
后端不用Spring Cloud那套微服务,单体应用足够。漫画小程序的并发量在演示场景下不会成为瓶颈,把单体应用做好分层、写清楚接口文档,反而更符合毕业设计的评审逻辑。我见过不少同学一上来就搞Nacos、Gateway、Seata,结果服务起不来、事务调不通,最后草草收场。记住一点:毕业设计的核心是完整性和逻辑自洽,不是技术栈越花哨越好。
1.2 功能清单:照着这个列表做不会漏
一个合格的漫画小程序,功能上大致可以分成四块:用户侧核心功能、内容管理功能、互动功能和系统辅助功能。我梳理一份可以直接当需求文档用的清单:
用户侧包含:微信登录(或账号密码登录)、漫画分类浏览、关键词搜索、漫画详情、章节列表、阅读器(支持翻页或滚动)、收藏漫画、阅读历史记录、个人中心。
内容管理包含:漫画信息管理(封面、简介、作者、状态)、章节管理(章节标题、图片列表)、分类管理、漫画上下架。
互动功能包含:漫画评论、点赞、阅读量统计。这个模块做得好会很加分,很多毕业设计只做到收藏就结束了,加上评论后功能完整性明显提升。
系统辅助包含:轮播图管理、公告管理、管理员登录与数据概览。
功能别贪多。我见过一份需求文档写了二十多个功能模块,包括打赏、会员、积分商城,最后实现得七零八落。把上面这份清单做完、做稳,答辩时已经很有底气了。
1.3 目录结构与代码组织方式
后端建议按这种结构拆包:
com.example.comic ├── controller // 接口层 ├── service // 业务逻辑层 ├── mapper // 数据访问层 ├── entity // 实体类 ├── dto // 入参出参对象 ├── common // 通用返回、异常处理、常量 ├── config // 配置类(跨域、拦截器、文件上传) └── utils // 工具类(JWT、日期处理等)小程序端按照页面划分即可:pages下放index、category、search、detail、reader、user等目录,utils目录放request封装和工具函数,components目录放自定义组件(比如漫画卡片组件、空状态组件)。分包可以不用,项目体积控制在2MB以内完全没问题。
2. 数据库设计:漫画业务的表结构怎么摆
2.1 核心表设计与字段规划
数据库我用MySQL,表结构设计是整个项目的地基,这步走错了后面改起来非常痛苦。一个漫画小程序至少需要这几张表:
用户表(user):id、openid(小程序登录标识)、nickname、avatar、gender、create_time。openid要加唯一索引,这是小程序登录后识别用户身份的关键字段。
漫画表(comic):id、title、author、cover_url、category_id、description、status(连载中/已完结)、is_show(是否上架)、click_count、create_time、update_time。cover_url存的是封面的访问路径。
分类表(category):id、name、sort_order。漫画和分类的关系,如果考虑一部漫画属于多个分类,就建中间表;如果只是单一分类,直接在comic表里加category_id即可。建议先用单一分类,简单直观。
章节表(chapter):id、comic_id、title、chapter_no、images(章节图片地址,可以用JSON数组存储,也可以用逗号分隔)、create_time。chapter_no用于章节排序,一定要有。
收藏表(favorite):id、user_id、comic_id、create_time。加唯一联合索引(user_id, comic_id)防止重复收藏。
阅读记录表(read_history):id、user_id、comic_id、chapter_id、read_time。记录用户读到哪一话,下次从上次进度继续。
评论表(comment):id、user_id、comic_id、content、like_count、create_time。
轮播图表(banner):id、image_url、link_comic_id、sort_order。
管理员表(admin):id、username、password(加密存储)、create_time。
2.2 表关系与索引设计的细节
表关系上,user到favorite是一对多,comic到favorite也是一对多;comic到chapter是一对多;user到read_history是一对多。画ER图的时候把这几个关系标清楚就行。
索引不是越多越好。在comic表的category_id、title上加普通索引,因为列表查询和搜索会走这两个字段。chapter表的comic_id要加索引,这个字段是查询频率最高的。favorite表的(user_id, comic_id)联合索引是必须的。其他的字段暂缓,等真遇到慢查询再加也不迟。
存储引擎用InnoDB,字符集用utf8mb4。有同学问我为什么不用utf8,因为评论和昵称里会出现表情符号,utf8mb4是唯一稳定支持表情存储的方案。
2.3 设计过程中的几个取舍
第一个取舍是章节图片的存储方式。我在chapter表里用images字段存JSON数组,比如["https://xxx/1.jpg","https://xxx/2.jpg"]。这个方案的好处是查询章节详情时一条SQL全查出来,不需要再去关联子表。缺点是如果一章有上百张图片,这条数据会比较大,但相比拆成单独的表少了很多查询开销。漫画单章图片一般控制在20到50张,这个方案完全够用。
第二个取舍是软删除还是物理删除。漫画、章节这类内容数据,建议加is_deleted字段做逻辑删除。原因很简单:你删掉了一个漫画,用户收藏表里的记录会变成孤儿数据,阅读历史也会错乱。逻辑删除至少保留了恢复的可能性。
第三个取舍是阅读量直接用字段累加,不做独立的流水表。漫画的点击量在毕业设计场景下用update语句做counter加一就够了,不需要引入Redis或者消息队列。
3. 后端实现:Spring Boot的分层与核心接口
3.1 初始化项目与基础配置
创建项目时,Spring Boot版本用2.7.x就够了,JDK用1.8可稳定运行。依赖引入这几项:Spring Web、MyBatis-Plus、MySQL Driver、Lombok、JWT库、Hutool工具包。MyBatis-Plus能省掉大量单表CRUD的XML编写,Hutool提供一些常用的字符串和日期处理,都是提升效率的好帮手。
配置文件里记住几个关键项:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/comic_db?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai username: root password: yourpassword jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 servlet: multipart: max-file-size: 10MB max-request-size: 100MB mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0MyBatis-Plus的开箱即用不是白给的,实体类继承BaseMapper后,基本的insert、select、update都能直接调用。复杂的多表查询、分页查询,用它的Page插件也能轻松搞定。
3.2 登录鉴权:小程序端怎么保持会话状态
漫画小程序的用户体系,核心思路是先通过微信登录拿到openid,然后签发一个Token给小程序端,后续请求都带上这个Token来识别用户。
如果不想接微信登录(需要小程序AppID和密钥),做一个账号密码登录也可以。但微信登录绝对是加分项。流程是这样:小程序端调用uniapp的uni.login或原生框架的wx.login拿到code,把code传给后端;后端拿code去微信接口换openid;查数据库,没有就自动注册,有就直接登录;生成JWT返回前端。
JWT工具类里设置过期时间,我一般设置成7天:
public class JwtUtils { private static final String SECRET = "your-secret-key"; private static final long EXPIRE = 7 * 24 * 60 * 60 * 1000; public static String createToken(Integer userId, String openid) { return JWT.create() .withClaim("userId", userId) .withClaim("openid", openid) .withExpiresAt(new Date(System.currentTimeMillis() + EXPIRE)) .sign(Algorithm.HMAC256(SECRET)); } public static Integer getUserId(String token) { return JWT.require(Algorithm.HMAC256(SECRET)) .build() .verify(token) .getClaim("userId") .asInt(); } }拦截器里统一校验Token,放行登录接口、漫画查询接口,拦截需要用户身份的接口(收藏、阅读记录、评论)。小程序端Storage里存Token,请求拦截器里统一加到header。
有同学会问:小程序端不都在前端校验页面吗,后端拦不拦无所谓吧?大错特错。前端校验只是体验优化,后端拦截才是安全底线。接口没有鉴权就相当于家门没锁,只要懂点网络请求的人就能直接调你的接口删数据。
3.3 漫画展示核心接口:列表、搜索、详情
漫画列表接口是高频接口。建议用MyBatis-Plus的分页插件,返回结构统一用分页对象(records、total、pageNum、pageSize)。列表字段不需要把description这种长文本查出来,减少数据传输量。
@GetMapping("/comic/list") public Result getComicList(@RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize, @RequestParam(required = false) Integer categoryId, @RequestParam(required = false) String keyword) { LambdaQueryWrapper<Comic> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(categoryId != null, Comic::getCategoryId, categoryId) .and(StringUtils.hasText(keyword), w -> w.like(Comic::getTitle, keyword) .or().like(Comic::getAuthor, keyword)) .eq(Comic::getIsShow, 1) .orderByDesc(Comic::getCreateTime); Page<Comic> page = comicMapper.selectPage(new Page<>(pageNum, pageSize), wrapper); return Result.success(page); }搜索建议用LIKE模糊查询就够了。Elasticsearch对漫画小程序来说就是杀鸡用牛刀,毕业设计答辩也没老师指望你用全文检索引擎。
详情接口返回漫画基本信息、分类名称、章节列表、收藏状态。收藏状态需要根据当前Token解析出的userId去favorite表查一下。如果没登录或者没收藏,返回false即可。这样前端详情页就能直接显示“收藏”或“已收藏”。
章节详情接口返回章节信息和图片列表:
@GetMapping("/comic/chapter/{id}") public Result getChapterDetail(@PathVariable Integer id) { Chapter chapter = chapterMapper.selectById(id); List<String> images = JSONUtil.toList(chapter.getImages(), String.class); Map<String, Object> data = new HashMap<>(); data.put("chapterId", chapter.getId()); data.put("chapterNo", chapter.getChapterNo()); data.put("images", images); return Result.success(data); }3.4 收藏与阅读记录:状态同步的细节
收藏的接口设计很简单,POST /favorite/add传comicId,POST /favorite/cancel传comicId。但有个细节要注意:收藏之前先查一下数据库,如果已经收藏了就直接返回成功,不要重复插入。虽然联合索引能兜底,但业务层先判断一次可以少一次异常。
阅读记录的更新逻辑是这样的:用户翻阅到某话时,前端调POST /history/report,传comicId和chapterId。后端先去查有没有记录,有就更新chapterId和read_time,没有就插入。这里最忌讳的是每次翻页都上报,会造成大量重复写操作。实际操作中可以在小程序端做个节流,比如进入章节页时上报一次,退出时再上报一次。
阅读历史记录列表,要关联查询出漫画的封面和标题。SQL可以用两张表join,也可以查询出history列表后,用field收集comicId批量查漫画信息,再在内存里组装。后者的好处是SQL简单、数据库压力小,是性能更优的做法。
3.5 统一返回格式与全局异常处理
接口返回格式从第一天就要统一。我习惯用这个结构:
{ "code": 200, "message": "success", "data": {} }code为200时表示成功,其他值表示失败。配合全局异常处理器,把业务异常、参数校验异常、系统异常分别处理,前端只需要统一判断code即可。
全局异常处理的写法不复杂,但非常体现工程素养。哪些异常该映射成什么提示信息,提前想清楚,后期联调省心很多。比如Token过期返回401,前端收到401就跳登录页重新登录,这个流程直接在拦截器里处理,而不是把异常堆到页面里。
4. 小程序端实现:从首页到阅读器的完整链路
4.1 项目结构与request封装
小程序端建议用原生框架,页面用WXML + WXSS + JS。目录结构上面已经提过,这里重点说request封装。
// utils/request.js const BASE_URL = 'https://your-api-domain.com/api'; function request(url, method, data) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + url, method: method || 'GET', data: data || {}, header: { 'Content-Type': 'application/json', 'Authorization': wx.getStorageSync('token') || '' }, success: (res) => { if (res.data.code === 200) { resolve(res.data.data); } else if (res.data.code === 401) { wx.removeStorageSync('token'); wx.navigateTo({ url: '/pages/login/login' }); reject(res.data); } else { wx.showToast({ title: res.data.message, icon: 'none' }); reject(res.data); } }, fail: (err) => { wx.showToast({ title: '网络异常', icon: 'none' }); reject(err); } }); }); } module.exports = { get: (url, data) => request(url, 'GET', data), post: (url, data) => request(url, 'POST', data) };BASE_URL建议用已备案的HTTPS域名。如果只是本地演示,可以在微信开发者工具里勾选“不校验合法域名”,这个选项在工具栏详情里能找得到。
4.2 首页与分类页:数据渲染与交互细节
首页通常由轮播图、分类导航、漫画推荐列表几个模块组成。onLoad阶段并行请求轮播图和推荐列表,用Promise.all控制并发,比串行请求快不少。
漫画列表的卡片建议封装成自定义组件:
<comic-card comic="{{item}}" bindtap="goDetail"></comic-card>WXML里用wx:for循环渲染列表,注意给每一项加上wx:key="id",避免渲染性能问题和控制台警告。
列表展示的时候注意图片懒加载,直接用image组件的lazy-load属性,这是小程序原生支持的,一行代码就能改善长列表的加载性能。
首页的加载方式建议用“下拉刷新 + 触底分页加载”的组合:
onPullDownRefresh() { this.setData({ pageNum: 1, comicList: [] }); this.loadComics(); wx.stopPullDownRefresh(); }, onReachBottom() { if (this.data.hasMore) { this.setData({ pageNum: this.data.pageNum + 1 }); this.loadComics(); } }分页加载时有个常见的坑:二次加载的数据直接赋值而不是追加,导致列表只有最后一页数据。正确做法是this.setData({ comicList: this.data.comicList.concat(res.records) })。
4.3 详情页与阅读器体验优化
详情页展示封面、标题、作者、状态、简介、章节列表,还要显示收藏数和阅读量。收藏按钮点击时要防止重复点击,可以加一个isThrottle标志,等接口返回再恢复。
阅读器页面是整个小程序体验要求最高的地方。漫画阅读器的两种方式:
第一种是翻页模式:一页一张图,左右滑动翻页。实现用swiper组件,current对应当前页,bindchange监听翻页事件。这种方式图片加载压力小,每次只渲染当前页和相邻页。
第二种是滚动模式:整章图片纵向排列,用户连续向下滚动阅读。这种方式需要一次加载全部图片,如果图片量大或者网速慢,会出现长时间白屏。
我推荐用swiper翻页模式,同时做预加载。用wx.previewImage做图片预览,阅读器里bindtap某张图片时,让用户强制预览当前图片,后面拼上剩余图片,用户可以在预览模式下快速翻阅,体验很像主流漫画App。
阅读器的背景色建议用深灰色或黑色,纯白背景长时间阅读会刺眼。图片宽度设为100%,高度自适应,翻页时用wx.nextTick确保swiper的高度跟随图片变化。
章节列表的排序要按chapter_no升序,不要按id排。实际运营中可能中间插入一章,按id排就乱了。这个细节在答辩时主动提出来,老师会认为你考虑到了数据维护的实际场景。
阅读记录上报放在onUnload和onHide里,调用POST /history/report,传漫画ID和当前章节ID,这样用户每次打开小程序都能从上次读到的地方继续。
5. 文件上传与图片存储:容易被忽视却极其重要的一环
5.1 漫画图片的存储方式选型
漫画的图片存储有两条路:一是直接传到服务器本地磁盘,二是用云对象存储。毕业设计建议用本地存储,简单省心。
在Spring Boot里配置上传路径:
file: upload-dir: /data/comic-images/ access-prefix: /uploads/写一个WebMvcConfigurer做静态资源映射:
@Configuration public class WebConfig implements WebMvcConfigurer { @Value("${file.upload-dir}") private String uploadDir; @Value("${file.access-prefix}") private String accessPrefix; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(accessPrefix + "**") .addResourceHandler("file:" + uploadDir); } }这样图片访问路径就是http://localhost:8080/uploads/xxx.jpg。上传接口用MultipartFile接收文件,文件名用UUID生成,存储后返回访问路径。
5.2 批量上传章节图片的实现思路
漫画章节通常是一次传很多张图片。前端可以用wx.uploadFile配合Promise串行上传,也可以同时上传几张,但注意不要一次开太多并发。后端批量上传接口设计成接收多个文件,用List<MultipartFile>接收后循环存储,返回所有图片的路径数组。
有小程序的图片上传还有一个更稳妥的方案:小程序端先用wx.getImageInfo拿到图片的本地临时路径,然后逐个调上传接口。每章图片控制在20到50张,串行上传虽然慢一点但稳定。
5.3 上传过程中的坑
本地存储的第一个坑是服务器重启后路径容易错乱,参考上述配置用绝对路径就能避免。第二个坑是数据库存储的路径和实际访问路径不一致,建议数据库中只存/uploads/xxx.jpg这种相对路径,前端请求时在BASE_URL后面拼上完整地址,这样换域名或换服务器时不需要改数据库。第三个坑是图片压缩,漫画图片如果单张超过1MB,用户加载时会明显卡顿,上传时可以在前端用wx.compressImage压缩一下再传,画质损失在可接受范围内。
6. 常见问题排查与避坑实录
6.1 经典问题速查表
这是我做这类项目时遇到的典型问题以及处理方案:
| 问题现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 小程序请求后端接口403 | 跨域拦截 | 后端配置CORS跨域,或用@CrossOrigin注解,正式上线需配置HTTPS域名 |
| 登录后Token校验失败 | 密钥不一致或Token过期 | 检查新旧Token,JWT的SECRET保持一致;服务器时间是否准确 |
| 图片无法显示 | 静态资源映射没生效 | 检查磁盘路径和映射前缀是否匹配,数据库里存的路径是否带了域名 |
| 列表分页重复加载 | pageNum没有累加 | 触底加载时先this.data.pageNum + 1再调用接口 |
| 用户收藏后状态不同步 | 详情页没有重新查询收藏状态 | onShow里重新请求详情接口,而不是依赖页面栈里的旧数据 |
| 章节顺序错乱 | 按id排序而非chapter_no | 改用chapter_no排序 |
| 数据库中文乱码 | 连接串未指定编码 | url上加characterEncoding=utf8mb4,确保数据库本身用utf8mb4 |
6.2 两个印象深刻的排查过程
有一次图片上传后访问返回404,排查了一个多小时,最后发现是file:后面少写了一个斜杠。file:D:\\data\\和file:\\D:\\data\\效果完全不同,一个能访问一个404,这个问题没有捷径,只能对照目录一层层看日志。
还有一次,用户反馈首页加载很慢,看network发现列表页一次请求了几十张原图。当时做的一件事就是把home接口返回的图片路径从原图换成缩略图路径。在后端加一个图片缩略图处理,封面图统一压缩到宽400像素,列表页加载速度立竿见影。
6.3 答辩前必须做的准备工作
第一,准备好一个演示账号,里面预置好分类数据、漫画数据、章节数据、评论数据,保证演示的时候不出现空列表。第二,把接口文档整理成一份PDF或在线文档,评审老师一般会看。第三,准备好项目的ER图、架构图、功能模块图,这些不一定在论文里,但答辩PPT里放一张会非常加分。第四,提前想清楚可能被追问的问题:为什么用MyBatis-Plus不用MyBatis?Token过期怎么处理?阅读量怎么统计的?这些问题平时实现时想明白了,答辩时自然对答如流。
整个项目从零到一做完大概是这样的时间安排:两天设计表结构,五天做后端接口,三天做小程序页面,两天联调和修bug,剩下时间写论文和准备答辩。如果时间紧张,可以先把核心的用户、漫画、章节、收藏这几条链路跑通,再补充评论、轮播图、后台管理这些外围模块。我做这类项目最大的体会是:真正的难点从来不在某个技术点有多难,而在于把一条完整链路从头到尾打通,中间任何一个环节的疏漏都会在前端暴露出来。只要链路通了,后面所有工作都只是在这个骨架上填充细节而已。
最后分享一个小建议:项目做完之后,给自己留半天时间,把数据库表结构、关键接口、核心流程各写一页总结。这页纸在写论文时是提纲,在答辩时是讲稿,在日后复盘时是沉淀。漫画小程序这类题目,每年都有人做,做出来的人很多,但能把整个设计思路讲清楚的并不多。在这个领域,把一件事做完整、说清楚,本身就是最好的毕业设计。