微信小程序+SSM评分系统开发实战:从架构设计到前后端联调 我做了不少微信小程序 SSM 后端的课题项目其中“评分小程序”这种类型见得尤其多——课堂上给老师打分、驾校对教练评价、健身房对私教服务反馈、企业对员工季度互评……业务不同骨架几乎一模一样。最近整理手里这套weixin219微信小程序评分小程序ssm(文档源码)项目时发现它把整个评分业务闭环做得比很多同类毕设都完整用户端小程序评分、后台管理、数据统计、文档源码齐全。这篇就把我从拿到标题到跑通全流程、再到排查各种坑的经验完整写出来给正在做类似题目或者想学微信小程序 SSM整合的人一份可以直接照着抄的参考答案。1. 整体设计与思路拆解1.1 项目定位与核心需求先把这个标题拆开看。weixin219是项目编号不用管核心是“微信小程序评分 SSM 文档 源码”。这里有几层信息量前端载体是微信小程序意味着用户扫码即用、免安装、轻量后端是 SSMSpring SpringMVC MyBatis一套虽然“老”但依然大量存在于教学和企业旧系统中的 Java Web 组合评分是业务核心背后涉及评分项配置、评分提交、评分统计、历史记录这几条主线带文档带源码说明这是一个可交付的完整项目最常见的用途是毕业设计、课程设计或者企业内的小型评价系统。这类评分项目的本质是把“谁评谁、评什么、怎么算分、结果给谁看”这件事搬到线上。拿课堂评教举例学生是小程序端用户老师是被评对象评分项包括教学态度、讲课清晰度、作业批改及时性等评分完成后后台聚合出每位老师的均分和各维度得分。你只要把“老师”换成“教练”“司机”“员工”“评分项”换一批名称数据库里改几条记录就是一个新项目。所以做这个题目的关键不是写死业务而是把评分模型搭得通用。1.2 技术选型为什么微信小程序 SSM先说小程序端。微信小程序相比 App 和 H5 有几个不可替代的优势用户从微信聊天、扫码、搜索直接进入获客成本最低微信提供完整的开发框架和调试工具一个人也能搞定前后端对于评分这种低频轻量操作用户不可能为打个分去下载一个 App。这也是为什么这几年“评分类应用”几乎全部落地在小程序上。再说 SSM 后端。诚实地说如果是 2024 年从零开始的商业项目我更推荐 Spring Boot MyBatis Plus配置更少、生态更好。但作为课题项目SSM 有三点价值是 Spring Boot 替代不了的一是学校教学和企业老项目里存量极大学完能直接上手旧系统维护二是 SSM 的配置是显式的请求怎么进来、Bean 怎么装配、SQL 怎么映射每一步都看得见对理解 Java Web 底层非常有帮助三是答辩时“手写配置”本身就是一个加分亮点。这套项目用 SSM 恰恰是它的教学价值所在。1.3 功能模块与系统边界一个标准的评分小程序我把功能分成三层用户端小程序微信授权登录拿到 openid 换自定义 token查看“待评分任务”列表例如未完成的课程/服务单进入评分页对多个评分维度逐项打分可填写评语提交后查看自己的历史评分记录和已提交详情。管理端Web 或小程序管理页维护被评对象教师/教练/员工信息配置评分项包括评分维度名称、分值区间、权重查看和导出评分结果按对象汇总均分、各维度得分简单的数据看板展示评分总数、最高分、参与人数等。后端只做三件事认证、提供业务接口、算分。不需要多复杂的微服务一个单体应用完全够用。数据库按“用户—评分任务—评分明细—评分项”四张核心表设计这个我在下一节详细拆。2. 核心细节解析与实操要点2.1 数据库设计的几个关键决策评分系统的表结构看起来简单但有几个设计决策直接决定后面代码好不好写。第一张表是用户表t_user字段包含openid、nickname、avatar、role区分普通用户和管理员。openid是微信用户的唯一标识必须加唯一索引否则同一个用户能注册出多条记录登录逻辑会乱。第二张表是被评对象表t_target比如老师表、教练表。建议保留name、category、description、status其中status控制该对象是否开放评分。被评对象和用户之间是间接关联——通过评分任务产生交集不直接建立外键关系。第三张表是评分记录主表t_score_record这是核心中的核心。字段设计record_id主键user_id评分人target_id被评人total_score总分comment评语create_time提交时间唯一索引uk_user_target (user_id, target_id)保证同一用户对同一对象只能提交一次评分。第四张表是评分明细表t_score_detail字段包含detail_id、record_id关联主表、item_id关联评分项表、item_score单项得分。设计明细表的目的是支持“分维度统计”——比如老师 A 的教学态度均分 9.2 分讲课清晰度均分 8.8 分这只有把每次评分的分项得分单独存下来才能查出来。如果只存总分就只能算总体均分后面做分析报表会非常吃力。第五张表是评分项配置表t_score_item字段包含item_id、item_name、max_score、weight、sort_order。注意weight字段虽然很多简单项目没做权重但实际业务里“教学内容”和“教学态度”的权重往往不同预留这个字段到时候总得分 Σ(单项得分 * 权重)算总分就灵活得多。这五张表的关系走的是“一对一明细 一对多配置”的经典模型既不复杂又能覆盖 90% 的评分需求。有同学会纠结要不要用“多对多关联表”把用户和被评对象直接关联我不建议——评分记录本身就是关联关系没必要再多一张中间表多一层 JOIN 性能差还容易出错。2.2 微信小程序登录与 token 签发流程小程序端的登录不能直接用传统的账号密码——微信生态里后端能拿到的最可靠身份标识是openid而小程序端通过wx.login()拿到的code是临时凭证。标准流程是这样小程序端调用wx.login()获取code把code发送到后端接口/api/login后端拿code去微信官方接口jscode2session换取openid和session_key这个请求必须由后端发不能在小程序端直接调因为需要appid和appsecret这两个密钥绝不能暴露在客户端代码里后端查询t_user没有这条openid就自动注册有就直接返回后端生成一个自定义 token可以是一个 UUID 或者 JWT以openid为 key 存入 Redis 或内存缓存返回给小程序端小程序端把 token 存到wx.setStorageSync(token)后续所有请求在 header 里带Authorization: token。为什么不用session_key直接当登录态因为session_key是微信用来解密用户手机号、运动数据等敏感信息用的有效期随时可能失效而且微信不建议业务方长期持有。自己签发 token 的好处是过期时间可控、服务端能主动注销、后续可以扩展刷新机制。简单项目里把 token 存到 Redis 也行只存内存里重启就失效也凑合但如果部署到服务器上建议至少把 token 存进数据库一张表避免重启导致用户全部掉线。2.3 防重复提交与评分范围校验评分系统最容易出的线上问题是重复提交和越界打分。我见过一个项目用户快速点击两次提交按钮生成了两条评分记录后台统计的均分直接被拉偏。防重复要从三处一起做。第一处是数据库层。在t_score_record表上加uk_user_target(user_id, target_id)唯一索引这样就算代码没拦住数据库也会报Duplicate entry错误数据不会错乱。这是兜底方案。第二处是后端接口层。提交评分接口进来后先SELECT COUNT(*) FROM t_score_record WHERE user_id ? AND target_id ?如果有记录直接返回“您已评价过”。这里注意先查再插会有并发窗口所以数据库唯一索引依然是必须的二者配合才能做到真正防重。第三处是前端交互层。评分页的“提交”按钮点击后立刻disabled同时显示一个“提交中”的 loading防止用户再点。别小看这一层它能把 90% 的重复请求挡在源头。评分范围校验也很关键。前端虽然会在slider或rate组件上限制 1~5 分或者 1~10 分但接口层必须再校验一次——前端改个参数就能绕过 UI 限制是常规操作。如果请求里的某项得分大于配置的max_score直接返回参数错误。这也是答辩时考官喜欢问的点“你如何保证数据的合法性”把这两层校验说清楚就是好答案。3. 实操过程与核心环节实现3.1 环境准备与项目骨架搭建做这个项目我先列一下环境清单全部按常见方案来JDK 1.8虽然现在 JDK 17 都普及了但 SSM 老项目在 8 上最稳兼容性最好Maven 3.6.x管理依赖和打包MySQL 5.7 或 8.0数据库编码统一utf8mb4否则小程序端传进来的 emoji 昵称会乱码Tomcat 8.5 或 9.0SSM 是 war 包部署方式需要外部 Servlet 容器微信开发者工具稳定版即可Navicat 或任意 MySQL 客户端执行建表 SQL。项目骨架推荐用 Maven 的标准 Web 结构。如果你是用 IDEA 新建项目选择maven-archetype-webapp骨架然后手动补上java、resources、test目录。这里有一个 IDEA 的小坑默认生成的目录结构没有java目录你得手动右键Mark Directory As - Sources Root不然代码全都报红。weixin219-score-miniapp/ ├── pom.xml └── src └── main ├── java │ └── com │ └── score │ ├── controller │ ├── service │ ├── dao │ ├── entity │ └── common ├── resources │ ├── jdbc.properties │ ├── log4j.properties │ ├── spring-mvc.xml │ ├── spring-mybatis.xml │ └── mapper └── webapp └── WEB-INF └── web.xml对应的pom.xml核心依赖我贴出来dependencies dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version5.2.22.RELEASE/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.9/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.6/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.28/version /dependency dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.8/version /dependency dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.12.5/version /dependency /dependenciespom.xml里有两处需要注意。第一Spring 版本选 5.x而不要选 4.x——4.x 对 Java 8 新特性支持不好而且很多依赖在新旧版本间不兼容。第二MySQL 驱动版本必须和你的 MySQL 版本匹配8.x 的 MySQL 配 5.1.x 驱动会出现连接报错或者Public Key Retrieval is not allowed的问题。3.2 SSM 后端核心代码实现后端代码我按分层来写Controller 负责接收请求和参数校验Service 负责业务逻辑和事务Mapper 负责和数据库打交道Entity 对应数据库表。先看统一的返回结构。小程序端需要知道请求是成功还是失败、错误信息是什么、数据是什么。我用一个泛型类统一封装public class ResultT { private Integer code; // 200 成功500 失败401 未登录 private String msg; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.code 200; r.msg success; r.data data; return r; } public static T ResultT error(String msg) { ResultT r new Result(); r.code 500; r.msg msg; return r; } }这里把返回结构统一定下来非常重要。很多新手项目返回格式五花八门有的直接返回 Map有的返回 JSONObject前端封装请求时每个接口都得单独处理极其痛苦。统一使用Result对象后小程序端的 request 封装只需要判断一次code即可。登录接口的 Controller 部分RestController RequestMapping(/api) public class LoginController { Autowired private UserService userService; PostMapping(/login) public ResultLoginVO login(RequestBody LoginDTO dto) { // 调用微信 jscode2session 接口换取 openid String openid userService.getOpenidByCode(dto.getCode()); // 查询或注册用户签发 token LoginVO vo userService.loginByOpenid(openid); return Result.success(vo); } }getOpenidByCode里实际上是用 Spring 的RestTemplate调用微信接口public String getOpenidByCode(String code) { String url https://api.weixin.qq.com/sns/jscode2session ?appid appid secret secret js_code code grant_typeauthorization_code; RestTemplate restTemplate new RestTemplate(); String response restTemplate.getForObject(url, String.class); JSONObject json JSON.parseObject(response); return json.getString(openid); }注意appid和secret要放到jdbc.properties或单独的配置文件中不要硬编码在 Java 类里。评分提交接口的 Service 层这里演示事务和防重的组合Service public class ScoreServiceImpl implements ScoreService { Autowired private ScoreRecordMapper recordMapper; Autowired private ScoreDetailMapper detailMapper; Autowired private ScoreItemMapper itemMapper; Override Transactional(rollbackFor Exception.class) public void submitScore(ScoreSubmitDTO dto, Long userId) { // 校验评分项是否合法 ListScoreItem items itemMapper.selectByIds(dto.getItemScores().keySet()); if (items.size() ! dto.getItemScores().size()) { throw new BusinessException(评分项不存在); } // 防重复提交 int count recordMapper.countByUserAndTarget(userId, dto.getTargetId()); if (count 0) { throw new BusinessException(您已评价过该对象); } // 计算总分和明细 ScoreRecord record new ScoreRecord(); record.setUserId(userId); record.setTargetId(dto.getTargetId()); int total 0; for (ScoreItem item : items) { Integer score dto.getItemScores().get(item.getItemId()); if (score null || score 1 || score item.getMaxScore()) { throw new BusinessException(评分超出范围); } total score; } record.setTotalScore(total); record.setComment(dto.getComment()); recordMapper.insert(record); // 逐条插入明细 for (Map.EntryInteger, Integer entry : dto.getItemScores().entrySet()) { ScoreDetail detail new ScoreDetail(); detail.setRecordId(record.getRecordId()); detail.setItemId(entry.getKey()); detail.setItemScore(entry.getValue()); detailMapper.insert(detail); } } }这里加Transactional注解很关键——主表插入成功后明细插入失败整个事务回滚不会出现“只有总记录没有明细”的脏数据。真实项目中这句话值十块钱多表写入必须加事务单表查询可以不加。对应的 Mapper XML 里防重复的 SQL 和明细插入 SQLselect idcountByUserAndTarget resultTypeint SELECT COUNT(*) FROM t_score_record WHERE user_id #{userId} AND target_id #{targetId} /select insert idinsert parameterTypecom.score.entity.ScoreRecord useGeneratedKeystrue keyPropertyrecordId INSERT INTO t_score_record (user_id, target_id, total_score, comment, create_time) VALUES (#{userId}, #{targetId}, #{totalScore}, #{comment}, NOW()) /insertuseGeneratedKeystrue和keyPropertyrecordId这两行配置是关联明细表的桥梁——插入主表后 MyBatis 会把自动生成的主键回填到record.getRecordId()这样后面插入明细才有外键可用。少了这两行recordId永远是 null明细表插入必然失败。3.3 小程序前端核心页面与请求封装小程序端我按三个核心页面来说首页待评列表、评分页、记录页。先看请求封装utils/request.js这个文件是前后端联调的命根子。我见过太多项目把wx.request到处复制粘贴改个 baseURL 要改十几个文件所以我一开始就封装好const BASE_URL http://localhost:8080/score-api function request({ url, method GET, data {} }) { return new Promise((resolve, reject) { wx.request({ url: BASE_URL url, method, data, header: { Content-Type: application/json, Authorization: wx.getStorageSync(token) || }, success: (res) { if (res.statusCode 200 res.data.code 200) { resolve(res.data.data) } else if (res.data.code 401) { wx.navigateTo({ url: /pages/login/login }) } else { wx.showToast({ title: res.data.msg || 请求失败, icon: none }) reject(res.data) } }, fail: (err) { wx.showToast({ title: 网络异常, icon: none }) reject(err) } }) }) } module.exports { request, BASE_URL }封装之后页面调用就非常简洁。评分页提交评分const app getApp() const { request } require(../../utils/request) Page({ data: { targetId: null, targetName: , items: [], comment: }, onLoad(options) { this.setData({ targetId: options.targetId }) this.loadScoreItems() }, loadScoreItems() { request({ url: /api/score/items }).then((res) { this.setData({ items: res.map(item ({ ...item, score: Math.floor(item.maxScore / 2) // 默认给个中位分 })) }) }) }, onSliderChange(e) { const index e.currentTarget.dataset.index this.setData({ [items[${index}].score]: e.detail.value }) }, onCommentInput(e) { this.setData({ comment: e.detail.value }) }, submitScore() { const itemScores {} this.data.items.forEach(item { itemScores[item.itemId] item.score }) request({ url: /api/score/submit, method: POST, data: { targetId: this.data.targetId, itemScores, comment: this.data.comment } }).then(() { wx.showToast({ title: 评分成功, icon: success }) setTimeout(() wx.navigateBack(), 1500) }) } })这里有两个细节值得说。第一评分项列表在onLoad里异步加载页面渲染时数据是空的所以 WXML 里要用wx:for渲染前先判断items.length 0否则控制台会有渲染警告。第二提交时把itemScores组装成对象而不是数组后端按key查评分项配置会方便很多。评分页的 WXML 片段view classscore-page view classtarget-info text classtarget-name{{targetName}}/text /view view classitem-list view classitem-card wx:for{{items}} wx:keyitemId view classitem-header text classitem-name{{item.itemName}}/text text classitem-score{{item.score}}分/text /view slider min1 max{{item.maxScore}} step1 value{{item.score}} >mvn clean package -DskipTests然后把target/score-api.war放到 Tomcat 的webapps目录启动 Tomcat 即自动解压部署。注意spring-mvc.xml里的application.properties中数据库连接地址、用户名密码要改成服务器上的配置。数据库导出 SQL 后在服务器执行导入mysql -u root -p score_db score.sql小程序端真正的线上发布需要你在微信公众平台备案域名把后端接口配置为 HTTPS然后在“开发管理 - 服务器域名”里把request合法域名加进去。微信现在要求小程序的接口域名必须是 HTTPS 且证书有效这一步经常有人卡住。如果你暂时没有 HTTPS 证书可以用宝塔面板一键申请免费的 Let‘s Encrypt 证书比手动配 Nginx 省事很多。4. 常见问题与排查技巧实录4.1 小程序端请求失败与网络异常的定位真机预览时说“网络异常”这一条几乎每个做小程序的人都会遇到。我总结一个排查顺序按这个顺序来十分钟内能解决 80% 的问题服务器安全组是否放行了 8080 端口阿里云、腾讯云的服务器默认只开 80/443你需要在安全组规则里手动添加 8080 端口或者干脆用 Nginx 把 80 端口代理到 8080。小程序后台的“服务器域名”是否已经配置如果你用的是体验版可以在开发者工具里勾选“不校验合法域名”但手机上打开体验版“不校验”不生效所以域名必须真实配置。请求地址是否用了 HTTPS从基础库 2.x 开始微信对http://的请求基本是直接拦截的哪怕勾了不校验合法域名也只能在开发者工具里跑通。后端日志有没有报错去 Tomcat 的logs/catalina.out里看异常栈90% 的“网络异常”其实是后端 500只是小程序端统一弹了网络异常而已。所以我在请求封装里其实是把data.code ! 200的响应单独弹 toast 而不是统一弹“网络异常”就是为了便于排查。4.2 数据库乱码与 emoji 昵称问题微信用户的昵称经常带 emoji比如“小李”。如果你的数据库连接没有指定characterEncodingutf8mb4插入nickname字段时就会抛出Incorrect string value: \xF0\x9F\x8C\x9E的异常。这个问题的完整解法有两步。第一步数据库连接 URL 加上参数jdbc:mysql://localhost:3306/score_db?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/Shanghai第二步在建表 SQL 里明确指定表字符集CREATE TABLE t_user ( ... ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci;utf8mb4和utf8的区别在于utf8只支持三字节的字符而 emoji 是四字节所以必须用utf8mb4。如果数据库已经建好了不想重来就用ALTER TABLE t_user CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;一条命令改掉。4.3 评分明细为空或统计结果不对这个坑出过好多次。现象是评分记录主表有数据但t_score_detail表只有部分记录或者统计某个对象的均分时结果偏小。原因通常是两个。第一明细插入时record_id为 null。前面说了解决方式是在主表插入时加上useGeneratedKeystrue和keyPropertyrecordId然后检查你的 Mapper XML 是否真的配置了这两个属性。如果不确定就在insert后打一行日志打印record.getRecordId()。第二总分和明细不一致。有的项目直接在代码里算总分后存到主表但是如果某个评分项漏存了明细总分是算进去了可统计各维度得分时占比不均。我后来在submitScore里刻意加了一个校验逻辑明细得分之和必须等于主表总分不相等直接抛异常回滚。这个“自检”逻辑是日常开发里值得养成的一个习惯——对核心数据的一致性别指望人眼去盯代码里就该防住。4.4 小程序审核被拒的高频原因与合规建议做这类课题项目通常要发布上线绕不开微信审核。我见过最多的被拒原因有这几个类目选择错误。评分类小程序建议选择“工具 - 效率”或者“教育 - 教育信息服务”如果你选的类目不匹配会被拒。没有用户协议和隐私政策。隐私政策是标配在小程序后台“用户隐私保护指引”里如实填写你收集了哪些信息比如微信昵称、头像并在小程序里增加“用户协议”和“隐私政策”两个页面入口。涉及收集用户敏感信息但未说明用途。你只需要 openid 来做身份标识就在隐私说明里写清楚“用于识别用户身份”不需要收集手机号就不要申请手机号验证组件。评分结果不对用户公开但页面里有明显的个人隐私信息展示。建议在小程序里把“已完成的评分记录”设计成仅本人可见不要去展示所有用户评分。如果你最后只是做校内答辩或演示不需要正式发布那就用体验版甚至开发者工具直接演示。但在文档里还是要把“上线合规”部分的说明写好因为这通常也是评分答辩时的加分项。4.5 数据汇总统计的常见实现方式评分系统绕不开统计报表。后端写一个ScoreStatController核心 SQL 是SELECT target_id, ROUND(AVG(total_score), 2) AS avg_score, COUNT(*) AS score_count FROM t_score_record GROUP BY target_id ORDER BY avg_score DESC;这个 SQL 一查被评对象的总体排名就是一条数据。如果要看某个对象的各维度得分SELECT i.item_name, ROUND(AVG(d.item_score), 2) AS avg_score FROM t_score_detail d JOIN t_score_item i ON d.item_id i.item_id WHERE d.record_id IN ( SELECT record_id FROM t_score_record WHERE target_id #{targetId} ) GROUP BY d.item_id;注意这里用了子查询如果评分记录量很大十万条以上性能会有问题可以考虑先按target_idJOIN。但评分类项目通常数据量不会特别大子查询写法够用SQL 也容易懂。如果用户量真的上来了再考虑用定时任务把统计结果提前算好而不是每次实时查。说句实在话这套项目做完你的收获不只是“会做一个评分小程序”而是把 微信小程序前端、Java 后端、MySQL 数据库这一条完整链路彻底打通。我自己每次做类似课题都会先把“数据结构怎么设计、防重复怎么做、统计怎么高效”这三个问题想清楚后面所有代码都是往上填肉。文档方面我的建议是画两张图一张系统架构图小程序-后端-数据库的请求流向一张数据库 ER 图五张表的关系再把每个接口的请求参数和响应示例写进接口文档这份文档的完成度直接影响整体评分。如果你正在做这个题目按照上面的思路一步步走把每个细节都弄明白答辩时被问“为什么这样设计”能答出所以然这个项目就算真正吃透了。