这两年我陆陆续续帮人改过不少SpringBoot的毕设项目,摄影作品分享交流平台是出现频率相当高的一个题目。它的好处在于:后端能覆盖登录鉴权、文件上传、列表分页、点赞评论这些经典场景,前端又能把微信小程序的授权、图片上传、触底加载这些能力全部串一遍,一套项目做完,前后端该练的技术点基本都碰到了。
这篇文章我就按实际开发顺序,把从业务拆解到数据库设计、后端接口、小程序前端、再到联调部署的整套过程展开讲。内容偏实操,每一步都讲清楚“怎么做的”和“为什么要这么做”。不管你是拿这个题目做毕设,还是想自己从零写一个类似的小程序社区,都应该能直接参考。
1. 摄影分享平台的业务拆解与整体设计思路
1.1 核心功能模块怎么拆才不乱
很多人在动手写代码之前容易犯一个错——上来就建表、写接口,做到一半才发现评论和消息通知的关系没理清,或者作品图片的存储方案根本扛不住数据量。我的习惯是先花半天把业务模块画清楚,再谈技术实现。
一个摄影作品分享交流平台,表面上功能很多,但剥开来看就四个核心域:
- 用户域:微信授权登录、个人资料维护、关注/粉丝关系、我的作品/收藏/点赞列表
- 作品域:发布作品(图片+标题+描述+标签)、作品列表(推荐/最新/热门)、作品详情、删除作品
- 互动域:点赞、收藏、评论(含回复)、互动消息通知
- 辅助域:搜索(按标题/标签)、敏感词过滤、内容审核标记
这里面最容易忽略的是互动消息通知。很多新手做社区类项目会把点赞、评论做出来,但“别人赞了我的作品我要不要知道”这个问题经常被跳过。如果消息通知不做,那“交流”两个字就是个空壳。哪怕毕设,也建议至少做一版简单的通知:点赞/评论时往消息表插一条记录,小程序端的消息tab拉取未读列表。这块代码量不大,但对项目完整度的提升非常明显。
另一个容易被忽略的点是图片合规处理。摄影作品分享平台是公开内容,图片和文字都需要审核。毕设级别不需要接第三方审核API,但至少要做两层:第一层是发布时对标题、描述做关键词过滤;第二层是在管理后端留一个作品下架入口,配合status字段把违规内容标记为不可见。这两项加在一起能避免很多不必要的麻烦。
1.2 SpringBoot加小程序这套技术栈怎么选
技术选型这件事,我见过太多人纠结半天。其实摄影分享平台这个量级的项目,选型原则就一条:选你最熟、社区资料最多的组合,别追求新框架。
后端用SpringBoot是顺理成章的。SpringBoot 2.7.x是目前最稳的版本,3.x虽然出了很久,但部分第三方starter的兼容性偶尔会给人“惊喜”,做项目没必要给自己找这个不痛快。持久层我用MyBatis-Plus而不是JPA,原因非常实际:MyBatis-Plus的QueryWrapper写条件查询很直白,稍微复杂点的SQL也方便手写,对不熟悉Hibernate关联映射的人来说,心智负担小得多。
前端小程序这块,原生WXML加JavaScript就够了。不要一上来就上uni-app或Taro,除非你明确知道以后要同时发布支付宝小程序或抖音小程序。原生开发的调试体验最直接,wx.xxx的API调用在官方文档里都有详细说明,出问题也好排查。如果以后真要跨端,再用uni-app重写也来得及,但那是另一个故事了。
数据库用MySQL 8.0,缓存和搜索引擎在这个量级都不需要。文件存储方面,毕设项目可以直接存服务器本地目录,通过SpringBoot的静态资源映射暴露访问链接;如果服务器带宽有限或者考虑到以后要扩展,用云厂商的OSS/COS也是顺手的事,下面第3章单独讲。
2. 数据库表设计:用户、作品、互动三块基石怎么落
2.1 用户表、作品表、评论表的字段设计
表设计是整个项目的地基,地基歪了后面全是坑。我按实际使用的表结构来讲,你可以直接参考。
用户表(user)是标准的微信小程序用户模型:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,自增 |
| openid | varchar(64) | 微信openid,唯一索引 |
| nickname | varchar(32) | 昵称 |
| avatar | varchar(255) | 头像URL |
| bio | varchar(255) | 个人简介 |
| gender | tinyint | 性别,0未知 1男 2女 |
| status | tinyint | 账号状态,0正常 1禁用 |
| create_time | datetime | 注册时间 |
| update_time | datetime | 更新时间 |
这里有个关键点:openid属于敏感信息,后端任何接口返回用户信息时都不能把openid带出去,只能在前端拿用户id做标识。我见过有人直接把整个user对象序列化返回前端,openid泄露不说,反正也是不规范的做法。正确做法是建一个UserVO,只包含id、nickname、avatar这些前端展示需要的字段。
作品表(work)是整个系统的核心表:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| user_id | bigint | 发布者ID,索引 |
| title | varchar(64) | 作品标题 |
| description | text | 作品描述 |
| cover_url | varchar(255) | 封面图URL |
| image_urls | text | 所有图片URL,JSON数组字符串 |
| tags | varchar(255) | 标签,逗号分隔 |
| location | varchar(255) | 拍摄地点,可空 |
| like_count | int | 点赞数(冗余) |
| collect_count | int | 收藏数(冗余) |
| comment_count | int | 评论数(冗余) |
| view_count | int | 浏览量(冗余) |
| status | tinyint | 0待审核 1正常 2下架 |
| create_time | datetime | 发布时间 |
图片URL用JSON数组字符串存在image_urls里,这是很多人会纠结的点。要不要单独建一张作品图片表?我的答案是:在作品最多九张图的约束下,JSON串完全够用,还能省掉一次联表查询。前端拿到字符串后JSON.parse一下就行。第一个图作为cover_url单独存一个字段,列表页不用每次解析JSON取封面。
评论表(comment)要支持最简单的回复功能:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| work_id | bigint | 作品ID,索引 |
| user_id | bigint | 评论者ID |
| parent_id | bigint | 父评论ID,0表示顶级评论 |
| reply_user_id | bigint | 被回复者ID,用于通知 |
| content | varchar(512) | 评论内容 |
| create_time | datetime | 评论时间 |
parent_id字段同时承担了“这条评论是不是回复”和“回复的是哪条评论”两个职责。做楼层回复时只需判断parent_id是否为0,非0则查出父评论的内容展示在界面上,不需要做成无限层级树,那是浪费精力。
2.2 点赞、收藏、关注三张互动表的设计取舍
互动关系本质上就是“谁对什么做了什么”,数据模型非常统一,三张表结构几乎一样。
点赞记录表(like_record):work_id + user_id 联合唯一索引,防止重复点赞。 收藏记录表(collect_record):同样work_id + user_id联合唯一。 关注关系表(follow):user_id(关注者)+ follow_user_id(被关注者)联合唯一。
这三张表的设计要点在于:唯一索引一定要建,否则前端连点两次按钮就会插两条重复数据。你当然可以在代码里先查再插,但数据库的唯一索引才是最后一道保险。
至于计数问题,我建议直接用作品表里的like_count、collect_count冗余字段。每次点赞/取消点赞时,在同一个事务里更新记录表和计数,用事务保证一致性。数据量没到百万级之前,完全不需要引入Redis做计数缓存,反而多一个缓存和数据库的一致性维护成本。如果以后数据量真的大了,再考虑用Redis的incr命令,但那是后话了。
还有一个小技巧:作品列表接口需要判断“当前登录用户是否点赞/收藏过”,常规做法是查完作品列表后再查一次互动表,In查询user_id和work_ids的匹配关系,把它们组装进返回对象。不要N条作品查N次互动表,那样列表接口基本就废了。
3. 后端接口实现:登录鉴权与作品上传的完整链路
3.1 微信登录加JWT鉴权的闭环
微信小程序登录的流程,很多文章都写过,但实际操作起来有几个细节值得注意。
前端调用wx.login拿到临时code,把code传给后端。后端拿到code后调用微信的jscode2session接口,换取openid和session_key。这里有个新手常犯的错误:直接把code拿给后端就完事了,却没有意识到jscode2session接口需要appid和appsecret,这两个东西是绝对不能出现在小程序前端代码里的。所以“前端传code,后端换openid”这个流程是强制要求,不是可选项。
后端核心代码大致是这个样子:
@PostMapping("/login") public Result<String> login(@RequestBody LoginRequest req) { // 1. 用code换openid String url = "https://api.weixin.qq.com/sns/jscode2session?appid=" + appid + "&secret=" + secret + "&js_code=" + req.getCode() + "&grant_type=authorization_code"; String response = restTemplate.getForObject(url, String.class); JSONObject data = JSON.parseObject(response); String openid = data.getString("openid"); // 2. 查用户,不存在则注册 User user = userMapper.selectOne(new LambdaQueryWrapper<User>() .eq(User::getOpenid, openid)); if (user == null) { user = new User(); user.setOpenid(openid); user.setNickname("摄影爱好者" + System.currentTimeMillis() % 10000); user.setStatus(0); userMapper.insert(user); } // 3. 生成JWT返回前端 String token = JwtUtil.generateToken(user.getId()); return Result.success(token); }JWT里的payload只放userId和过期时间,不放其他敏感信息。token有效期我习惯设成7天,小程序不是那种需要长期保持登录的重型应用,7天到期后用户重新静默登录一次,体验上没有影响。
鉴权这块用SpringMVC的HandlerInterceptor实现。写一个LoginInterceptor,从请求头Authorization里解析token,解析成功就把userId放进request的attribute里,后续接口直接从request里取当前用户id。这里我建议用一个自定义注解@RequireLogin标记需要登录的接口,而不是用路径匹配做白名单——注解的方式更灵活,比如作品详情页是公开可看的不用登录,但点赞接口必须登录,一个注解就能区分开。
@Component public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (handler instanceof HandlerMethod) { HandlerMethod method = (HandlerMethod) handler; RequireLogin anno = method.getMethodAnnotation(RequireLogin.class); if (anno != null) { String token = request.getHeader("Authorization"); Integer userId = JwtUtil.parseToken(token); if (userId == null) { throw new BizException(401, "未登录"); } request.setAttribute("userId", userId); } } return true; } }这个拦截器里我直接用抛异常的方式处理未登录状态,配合全局异常处理器转换成401 JSON返回给前端,代码比在拦截器里写response输出要干净得多。
3.2 图片上传的接口链路
摄影作品平台的核心资产是图片,所以上传接口要格外仔细。前端的wx.chooseMedia选完图后,调用wx.uploadFile把文件以multipart/form-data方式POST到后端。
后端接收上传的接口比较简单:
@PostMapping("/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { throw new BizException(400, "文件不能为空"); } // 1. 校验文件类型和大小 String originalFilename = file.getOriginalFilename(); String suffix = originalFilename.substring(originalFilename.lastIndexOf(".")); if (!Arrays.asList(".jpg", ".jpeg", ".png", ".webp").contains(suffix.toLowerCase())) { throw new BizException(400, "不支持的图片格式"); } if (file.getSize() > 10 * 1024 * 1024) { throw new BizException(400, "图片大小不能超过10MB"); } // 2. 按日期分目录存储 String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); String filename = UUID.randomUUID().toString().replaceAll("-", "") + suffix; String relativePath = "upload/" + date + "/" + filename; File dest = new File(uploadDir + relativePath); if (!dest.getParentFile().exists()) { dest.getParentFile().mkdirs(); } file.transferTo(dest); // 3. 生成缩略图 // 用Thumbnailator生成宽400的缩略图,列表页展示用 Thumbnails.of(dest).width(400).toFile(dest.getParentFile() + "/thumb_" + filename); return Result.success("/api/file/" + relativePath); }这里有两个实践中的细节。第一,文件名必须用UUID重命名,不能保留原始文件名,否则中文文件名和非法字符会让URL变得不可控,重名还会互相覆盖。第二,列表页和详情页展示的图片尺寸其实不一样——列表页只需要宽400的缩略图就够了,详情页才加载原图。生成一张thumb缩略图能显著减少列表接口的流量消耗,小程序端滑起来也更快。
如果是部署到云服务器之外的对象存储,其实逻辑差不多:MultipartFile拿到之后直接putObject到OSS/COS,拿到返回的URL存到数据库。区别是本地存储要配SpringBoot的静态资源映射,把/api/file/路径映射到upload目录;对象存储则天然带CDN加速,访问速度更好。毕设用本地存储,生产环境用对象存储,这个思路比较清晰。
3.3 首页Feed流的接口设计
首页是作品信息流,这个接口是系统的门面,性能直接影响体验。最基础的实现是分页查询作品表,按创建时间倒序:
@GetMapping("/feed") public Result<PageResult<WorkVO>> feed(@RequestParam Integer page, @RequestParam Integer pageSize) { Page<Work> p = new Page<>(page, pageSize); LambdaQueryWrapper<Work> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Work::getStatus, 1) // 只展示正常状态 .orderByDesc(Work::getCreateTime); Page<Work> result = workMapper.selectPage(p, wrapper); // 转VO,补充用户信息、是否点赞收藏 return Result.success(...); }分页参数page和pageSize的设计上,page从1开始,pageSize固定10或20。前端触底加载下一页,直到返回的数据量小于pageSize就说明没有更多了。返回体里同时带一个hasMore字段,比前端自己判断更直观。
这种limit分页在数据量小的时候没问题,但超过十万条数据后offset深翻页会越来越慢。如果考虑到以后扩展,可以改成游标分页:把排序字段和时间戳拼成一个游标,下一页查询时带上where create_time < 上一页最后一条的create_time。这个改造不难,但做好这层设计能让你少踩很多性能坑。
热门榜单是另一个常见需求,我建议加一个时间窗口条件,只统计近7天发布的作品,按like_count倒序。这样榜单会持续更新,而不是被老作品永久霸榜。SQL大概是这样:
SELECT * FROM work WHERE status = 1 AND create_time > DATE_SUB(NOW(), INTERVAL 7 DAY) ORDER BY like_count DESC, create_time DESC LIMIT 204. 小程序前端:页面交互与关键实现
4.1 tabBar页面结构和自定义导航栏适配
小程序前端我建议用四个tabBar页面:首页、发布、消息、我的。发布页做成tabBar有一个好处,用户点一下就能进入发布流程,路径最短,这对UGC社区的产品逻辑很重要。如果你不想让发布页在tabBar里占一个位置,也可以用普通页面加悬浮按钮的方式,但tabBar方案更省事。
导航栏这块有两个选择:用系统默认导航栏,或者自定义。摄影社区类应用我强烈建议自定义导航栏,因为默认导航栏没法做沉浸式效果,首页的轮播图和背景很难和状态栏融合。自定义导航栏的核心是计算状态栏高度和导航栏高度:
const systemInfo = wx.getWindowInfo(); const statusBarHeight = systemInfo.statusBarHeight; const navBarHeight = statusBarHeight + 44; // 44是胶囊按钮所在区域的标准高度不同机型的statusBarHeight不一样,iPhone的刘海屏和安卓的挖孔屏数值差异很大,所以必须运行时动态获取,不能写死。页面里用一个占位view的高度等于statusBarHeight,再往下布局,内容就不会顶到状态栏。
4.2 列表触底加载更多和分页状态管理
首页信息流的交互模型是:首次进入加载第一页,滑动到底部自动加载下一页,顶部下拉刷新回到第一页。这三点在微信小程序里都有对应能力。
分页状态管理最朴素的做法是在data里维护三个字段:
data: { list: [], page: 1, hasMore: true, loading: false }onReachBottom触底时先判断hasMore和loading,防止重复请求:
onReachBottom() { if (!this.data.hasMore || this.data.loading) return; this.loadData(); } async loadData() { this.setData({ loading: true }); const res = await request.get('/feed', { page: this.data.page, pageSize: 10 }); this.setData({ list: this.data.list.concat(res.data.list), page: this.data.page + 1, hasMore: res.data.hasMore, loading: false }); }注意列表数据的拼接方式,是list.concat而不是直接赋值,否则会丢掉已经加载的历史数据。这个逻辑看着简单,但我在实际代码review里见过太多次把list直接替换成新页数据的低级失误。
还有一个容易被忽略的细节:wxml里渲染长列表时,图片一定要加lazy-load属性,让图片在进入可视区域时才加载。否则首页一次渲染几十张图片,首屏性能会非常差。同时image组件的mode用widthFix或aspectFill,避免图片拉伸变形。
4.3 图片选择和上传的坑
微信小程序的图片选择接口wx.chooseMedia比老版的wx.chooseImage功能更完整,支持多选、预览和压缩。发布作品时最多选九张图,选择阶段就应该把压缩参数带上:
wx.chooseMedia({ count: 9, mediaType: ['image'], sourceType: ['album', 'camera'], sizeType: ['compressed'], success(res) { // res.tempFiles拿到选中图片的临时路径 } });sizeType: ['compressed']指定使用压缩图,能大幅减少上传流量。但这里有个坑:compressed压缩后的图片在部分安卓机型上会被压缩得比较狠,画质有明显损失。所以如果你的用户对图片质量有要求,建议选择compressed版本,但后端存储时保留原图URL和压缩图URL两个字段。
上传的时序问题我也踩过一次。用户选了九张图后,如果九张图同时并发上传,服务器压力大不说,小程序的wx.uploadFile并发限制也可能导致部分请求失败。稳妥的做法是串行上传:维护一个上传队列,一张传完再传下一张,每张都拿到返回的URL后拼接成一个数组,最后连同标题、描述一起调用发布接口。这样虽然慢了一点,但可靠性和顺序都有保证。发布接口一次性save整个作品记录,不会出现作品已经创建但图片还没传完的中间状态。
前端上传代码:
async function uploadImages(files) { const urls = []; for (let i = 0; i < files.length; i++) { const res = await new Promise((resolve, reject) => { wx.uploadFile({ url: `${baseUrl}/upload`, filePath: files[i].tempFilePath, name: 'file', success: resolve, fail: reject }); }); urls.push(JSON.parse(res.data).data); } return urls; }这里每次循环都await一个Promise,把并发变成了串行。九张图上传完可能需要十几秒,期间要有一个loading提示,否则用户会以为卡死了,这是交互体验的基本要求。
5. 联调与部署:高频问题排查速查
5.1 后端常见报错和处理思路
做这个项目时后端最容易出问题的几个点,我整理成了一份速查表:
| 问题现象 | 常见原因 | 处理方案 |
|---|---|---|
| 前端调接口报CORS跨域 | 未配置跨域过滤器 | WebMvcConfigurer里addCorsMappings,允许小程序域名 |
| 上传大文件直接被拒 | SpringBoot默认上传限制1MB | 配置spring.servlet.multipart.max-file-size和max-request-size |
| 时间字段返回格式是时间戳 | Jackson默认序列化行为 | 字段加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| 请求报401 | token过期或未携带 | 前端检查请求拦截器是否加了Authorization头 |
| 小程序图片403 | 服务器Referer防盗链误伤 | 对象存储配置白名单或关闭referer限制 |
| SQL查询慢 | 缺少索引 | explain分析,work.user_id加索引 |
数据库连接池这块也值得提一句。SpringBoot默认的HikariCP开箱即用,性能很好。但连接池最大连接数默认是10,如果并发高一点,偶尔会出现“connection is not available”的报错。在application.yml里把maximum-pool-size调到20,再配合合理的请求超时时间,基本不会再遇到这个问题。
5.2 小程序端高频报错和处理思路
| 问题现象 | 常见原因 | 处理方案 |
|---|---|---|
| 真机预览请求失败 | 开发者工具关了“不校验域名”,真机仍然校验 | 必须有HTTPS合法域名,且在小程序后台配置request合法域名 |
| wx.login拿不到code | 基础库版本过老 | 检查项目基础库版本,一般2.10以上没问题 |
| 图片上传成功但列表不显示 | URL路径拼接错误 | 后端返回相对路径时前端要拼接完整域名 |
| setData报错Data is too large | 一次setData传了过多数据 | 避免把大图片base64塞进data,控制单次setData在1MB以内 |
| 页面白屏 | app.json页面路径配置错了 | 检查每个page路径是否与实际文件一致 |
小程序上线还有一道绕不开的坎:域名备案。发布正式版时所有请求都必须走HTTPS,域名需要ICP备案,服务器也需要有公网IP。很多人在开发阶段一切正常,一到提交审核就被域名校验卡住。这个问题没有捷径,只能提前准备域名和备案,或者在大学环境里用已经备案好的域名做代理。
5.3 部署上线前必须检查的事项
上线前的检查清单我每次都会自己过一遍:
- 小程序后台把request合法域名配置好,包括接口域名、图片CDN域名,多个都要列进去
- SpringBoot的application-prod.yml里数据库密码用环境变量注入,不要硬编码在配置文件里
- 上传目录的磁盘空间要注意,摄影社区图片是最大空间消耗者,定期清理或者上对象存储
- 前端所有请求的baseUrl切换到线上域名,不要带着localhost或127.0.0.1
- 日志级别调成info,把错误日志单独输出到文件,出问题时有据可查
最后再分享一个小经验。整个项目做完以后,我建议把首页信息流的响应时间作为核心性能指标。一个摄影社区,如果用户刷图片要等两三秒,再好的内容体验也是白搭。所以列表接口务必返回缩略图URL而不是原图URL,数据库查询务必走索引,分页不要贪多。这些细节叠加起来,才是用户觉得“这个App很流畅”的真正原因。
这个项目做完以后,个人体会最深的一点是:SpringBoot和小程序各自单独学的时候都觉得自己会了,但真正把它们对接起来,中间那些跨域、域名校验、上传格式、权限传递的问题,才是实际开发最花时间的地方。把这些链路捋顺了,以后再做什么社区类小程序,基本都能触类旁通。