☰
Spring Boot+微信小程序英语互助平台全栈开发与积分内容审核实战
2026/9/27 1:04:36 网站建设 项目流程

1. 项目整体设计与技术选型思路拆解

英语互助类小程序这两年一直有人做,但真正把"互助"这个动作落地的并不多。大部分产品最后都退化成了单词打卡工具,本质上是单人任务,跟"互相帮助"四个字没什么关系。我这次拿到的这套东西,标题是"英语互助小程序 + Spring Boot",附带完整文档和源码,核心就是把"提问—解答—积分激励"这条链路跑通,让用户在学英语的过程中互相拉一把。它解决的不是"背单词"的问题,而是"没人陪、没人问、坚持不下去"的问题,适合想练手小程序全栈的开发者,也适合想拿它当毕业设计或课程作业的同学参考。

这套东西的技术底色是微信小程序原生 + Spring Boot 后端 + MySQL。小程序端负责所有跟用户直接打交道的界面,后端负责业务逻辑、数据存储和身份校验,两边通过 HTTP 接口通信。之所以选这套组合,是因为它对个人开发者和小团队最友好:小程序不用管安装包分发,Spring Boot 不用管繁琐的容器配置,MySQL 装完就能用。整套跑下来,一台入门配置的云服务器就够,成本压得很低。

下面我把这个项目的设计思路、后端细节、实操落地和踩坑经验一层层拆开讲,中间会给出可以直接抄走的表结构、接口代码和配置片段,也会说明每一处选择背后的原因,避免你只是"照着敲了一遍"却不知道为什么这么敲。

1.1 为什么"小程序 + Spring Boot"是这个场景的合理组合

先说客户端为什么是小程序,而不是 App 或者网页。学英语的用户使用场景很碎:等车的五分钟、午休的十分钟,掏出手机就想刷两道题、发一个提问。这种"高频、短时、随时随地"的诉求,原生 App 太重——下载安装这一步就会劝退一大半人;网页又缺少系统级的入口和消息触达能力。小程序的定位刚好卡在中间:扫码或搜索即用,用完即走,还能挂载在对话、群聊里被转发,这对"互助"这种社交属性强的玩法非常关键。你发一个问题到班级群,同学点开就能答,这种传播链路是 App 很难比的。

再说后端为什么用 Spring Boot,而不是 Node、Python 那些更轻的框架。坦白讲,如果只是做个玩具项目,Flask 或者 Express 也能跑。但只要涉及用户体系、积分账目、事务一致性,Java 生态的成熟度优势就体现出来了。积分这种东西最怕算错,一次扣重、一次加错,用户立刻就不信任你了。Spring 的声明式事务加上 MyBatis 的精细 SQL 控制,处理"答题加积分"这种需要原子性的操作很稳。而且 Spring Boot 的 starter 机制让整合 MyBatis、Redis、定时任务都只需要加依赖、写几行配置,省下来的时间可以花在业务本身。

这里有一个容易被忽略的点:文档和源码同时交付,意味着这个项目的价值不只是"能跑",还有"能讲清楚为什么这么写"。很多网上流传的课程设计源码,代码能跑但注释缺失、结构混乱,读完不知道自己学了什么。这套东西附带文档,说明作者是按"可复现、可讲解"的标准组织的,对学习者而言这个附加价值甚至超过代码本身。

1.2 英语互助场景的核心需求拆解

要把"互助"做成真的互助,而不是空喊口号,需求层面至少要覆盖四件事:

  • 身份与关系:谁在问、谁在答、答得好不好,都得能追溯。没有稳定的用户身份,互助就退化成匿名灌水。
  • 内容载体:提问要能带文字、图片,回答要能带文字,最好还能追评和点赞。英语场景里经常有"拍照问题目"的需求,图片上传几乎是刚需。
  • 激励闭环:光靠热情没人能坚持。答题给积分、积分换权益(比如解锁精讲、置顶提问),这套虚拟经济是维持活跃的核心。
  • 学习沉淀:问答结束后,优质内容要能被收藏、被检索,否则优质回答会被信息流淹没,用户下次遇到同样问题还得重问一遍。

除了这四块,还有一个现实约束:内容必须可审。用户生成内容是最大的不确定性来源,必须在发布环节做基本过滤,把明显不合规的文本拦在入库之前。这不是可选项,是必选项。

把这五点想清楚,项目的模块划分基本就定下来了:用户模块、问答模块、积分模块、内容模块、审核模块。每个模块对应一组接口和几张表,后面的工作就是把它们串起来。

1.3 工程目录结构与模块划分

看一个后端项目靠不靠谱,先看包结构。这套代码采用的是经典的分层结构,没有过度设计,对初学者友好,维护起来也不费劲。大致长这样:

english-help-server ├── src/main/java/com/kaic/help │ ├── HelpApplication.java // 启动类 │ ├── common/ // 通用组件 │ │ ├── Result.java // 统一返回体 │ │ ├── ResultCode.java // 状态码枚举 │ │ ├── BusinessException.java // 业务异常 │ │ └── GlobalExceptionHandler.java │ ├── config/ // 配置类 │ │ ├── WebMvcConfig.java // 拦截器注册 │ │ ├── MybatisConfig.java │ │ └── SwaggerConfig.java │ ├── interceptor/ │ │ └── AuthInterceptor.java // 登录态校验 │ ├── controller/ // 接口层 │ ├── service/ // 业务层 │ │ └── impl/ │ ├── mapper/ // 数据访问层 │ ├── entity/ // 数据库实体 │ ├── dto/ // 传输对象 │ └── utils/ // 工具类 │ ├── JwtUtil.java │ ├── WxUtil.java │ └── SensitiveWordUtil.java └── src/main/resources ├── application.yml ├── mapper/ // XML 映射文件 └── static/

这套结构的核心思想是职责单一:Controller 只做参数接收和结果返回,不写业务;Service 负责编排和事务;Mapper 只管 SQL。很多人写课程设计喜欢把逻辑全塞进 Controller,图一时省事,等接口一多就彻底失控。分层不是为了显得专业,是为了让你三个月后回来改代码时还能看懂。

具体到接口划分,我给一张对照表,方便你理解每个模块对外暴露了什么:

模块核心接口说明
用户/user/login、/user/info、/user/update登录换取身份、读取和修改资料
问答/question/publish、/question/list、/question/detail发布提问、分页列表、详情
回答/answer/add、/answer/list、/answer/accept提交回答、回答列表、采纳
积分/point/log、/point/rank积分流水、排行榜
打卡/checkin/do、/checkin/calendar每日打卡、日历视图
收藏/collect/add、/collect/cancel、/collect/list收藏与取消

接口命名统一用"模块/动作"的形式,动词在后,这是 REST 风格里比较常见的做法,好处是一眼能看出这个接口属于哪个业务域。

2. 后端核心细节解析与实操要点

后端是这个项目真正的骨架。前端界面再漂亮,如果身份体系混乱、积分算错、并发下数据打架,整个产品就站不住。这一章我把几个关键细节拆开讲,包括表设计背后的思考、登录态的处理方式、积分结算的原子性保证,以及内容审核的落点。

2.1 数据库表设计:从打卡到积分

表设计是后端的地基。我见过不少同类项目,表字段随手加,最后出现"用户积分对不上流水"这种没法解释的问题。这套代码的表设计比较克制,核心几张表职责清晰。我把关键字段列出来,你可以对照着自己建表。

用户表t_user存的是最基础的身份信息,注意这里是自家用户体系,微信侧的 openid 只是身份标识之一,不直接当主键用:

CREATE TABLE `t_user` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `openid` VARCHAR(64) NOT NULL COMMENT '微信openid', `unionid` VARCHAR(64) DEFAULT NULL, `nickname` VARCHAR(64) DEFAULT NULL, `avatar` VARCHAR(255) DEFAULT NULL, `points` INT NOT NULL DEFAULT 0 COMMENT '当前积分余额', `level` TINYINT NOT NULL DEFAULT 1, `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0禁用', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_openid` (`openid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

uk_openid这个唯一索引非常关键。同一时间可能有多个请求同时进来要"首次登录建号",如果没有唯一约束,极端情况下会插出两条同一个人的记录,后面所有积分、收藏都会分裂到两个账号上。数据库层面的唯一约束是最后一道防线,绝对不要只靠代码里的 if 判断。

积分表t_point_log是只记账不修改的思路,每一次积分变动都插一条流水,用户表的points字段只是流水的一个汇总缓存:

CREATE TABLE `t_point_log` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `change_num` INT NOT NULL COMMENT '正数为加,负数为扣', `biz_type` VARCHAR(32) NOT NULL COMMENT 'BUSINESS类型:answer/accept/checkin', `biz_id` BIGINT DEFAULT NULL COMMENT '关联业务id,用于幂等', `remark` VARCHAR(128) DEFAULT NULL, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_time` (`user_id`, `create_time`), UNIQUE KEY `uk_biz` (`biz_type`, `biz_id`, `user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

uk_biz这个联合唯一索引是防重复发放积分的核心。比如"采纳一次回答加 20 分",只要业务 id 和用户 id 固定,重复调用插入就会直接抛唯一键冲突,业务层捕获后当作"已经奖励过"处理即可。这比先查后插的两步操作安全得多。

问答相关的表t_question和t_answer结构不复杂,重点在两个字段:一是status(待解决/已解决/已关闭),二是accept_answer_id(被采纳的回答 id)。状态机清晰,前端展示才不会有歧义。打卡表t_checkin_record则用(user_id, checkin_date)做唯一索引,天然保证一天只能打一次。

提示:建表时统一用 utf8mb4 字符集,英语场景里会混入大量特殊符号、音标转写,用 utf8 有时会出问题。

2.2 登录态与用户身份识别:code2Session 与 JWT

小程序的登录流程跟普通网页不一样,它没有传统意义的 Cookie 会话。标准做法是:前端调wx.login()拿到一个临时code,把 code 发给你的后端,后端拿 code 加上 appid、secret 去请求微信的服务端接口,换回openid和session_key,然后你自己生成一个登录凭证(通常是 token)返回给前端保存。

这里有个细节很多人会踩:code 是一次性的,五分钟内有效,用一次就废。所以后端拿到 code 之后要立刻去换,换不到就返回登录失败,不要让前端反复重试同一个 code。

WxUtil里换 openid 的核心逻辑大概是这样:

public WxSessionDto code2Session(String code) { String url = String.format( "https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code", appId, appSecret, code); ResponseEntity<String> resp = restTemplate.getForEntity(url, String.class); JSONObject json = JSON.parseObject(resp.getBody()); if (json.containsKey("errcode") && json.getIntValue("errcode") != 0) { throw new BusinessException("登录凭证校验失败:" + json.getString("errmsg")); } WxSessionDto dto = new WxSessionDto(); dto.setOpenid(json.getString("openid")); dto.setSessionKey(json.getString("session_key")); return dto; }

拿到 openid 之后,先查库,没有就建档,有就更新一下昵称头像,然后签发 JWT:

public String createToken(Long userId, String openid) { Date now = new Date(); Date expire = new Date(now.getTime() + tokenExpireSeconds * 1000L); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("openid", openid) .setIssuedAt(now) .setExpiration(expire) .signWith(SignatureAlgorithm.HS256, jwtSecret) .compact(); }

为什么用 JWT 而不是自己生成一个随机串存 Redis?两个原因。第一,JWT 自包含,服务端不需要额外存储就能校验,水平扩容时非常省事;第二,它天然带过期时间,不用自己写清理逻辑。缺点是签发后没法主动失效,但对这种学习类应用,token 有效期设个 7 天,到期重新登录就够了,没有必要引入复杂机制。

校验的工作交给拦截器AuthInterceptor,它拦下所有需要登录的路径,从请求头里取 token,解析成功就把 userId 塞进 ThreadLocal,供后续业务读取:

@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; // 放行预检请求 } String token = request.getHeader("Authorization"); if (StringUtils.isBlank(token)) { writeUnauthorized(response); return false; } try { Claims claims = JwtUtil.parse(token.replace("Bearer ", "")); UserContext.set(Long.valueOf(claims.getSubject())); return true; } catch (Exception e) { writeUnauthorized(response); return false; } }

注意:ThreadLocal 用完一定要在afterCompletion里remove()。线程池复用线程,不清理会导致下一个请求读到上一个用户的 id,这是非常隐蔽的越权事故。

2.3 互助问答与积分结算的实现要点

问答模块表面简单,真正难的是三件事:回答奖励的幂等、采纳的唯一性、以及问答列表的性能。

先说回答奖励。用户提交一条回答,给他加 5 分,这件事必须保证"一条回答只奖励一次"。做法是:在同一个事务里插入回答记录、插入积分流水、更新用户余额,三步要么全成功要么全回滚。积分流水表上的uk_biz唯一索引会替我们挡住重复。

@Transactional(rollbackFor = Exception.class) public void addAnswer(Long userId, Long questionId, String content) { Question q = questionMapper.selectById(questionId); if (q == null || q.getStatus() == 2) { throw new BusinessException("该问题已关闭,无法回答"); } Answer answer = new Answer(); answer.setQuestionId(questionId); answer.setUserId(userId); answer.setContent(content); answerMapper.insert(answer); pointService.changePoint(userId, 5, "answer", answer.getId(), "回答问题奖励"); questionMapper.incrAnswerCount(questionId); }

pointService.changePoint里面用INSERT ...流水 +UPDATE t_user SET points = points + #{num}的写法。注意这里的加积分一定要用SQL 层面的自增,不要先查出来、加完再写回,那种写法在并发下会丢更新。

再说采纳。一个提问只能采纳一条回答,这个约束建议直接建在表上:t_question上加一个字段accept_answer_id,采纳时用带条件的更新:

UPDATE t_question SET accept_answer_id = #{answerId}, status = 1, update_time = NOW() WHERE id = #{questionId} AND user_id = #{userId} AND accept_answer_id IS NULL;

看这个accept_answer_id IS NULL,它就是防止重复采纳的闸门。返回的影响行数如果是 0,说明要么不是提问者本人,要么已经被采纳过了。用一条 SQL 完成权限校验和状态互斥,比在 Java 里查一遍再判断要安全。

最后是列表性能。问答列表是最容易被翻到底的接口,分页查询一定要命中索引。列表页通常只需要 id、标题、摘要、作者昵称、回答数这些字段,没必要SELECT *把正文一起捞出来——正文可能很长,白白增加 IO。详情页再单独查完整内容。

2.4 内容审核与敏感词过滤的落点

用户生成内容是风险最高的部分,发布前必须过滤。这套代码给的做法是用 DFA(确定有限状态自动机)构建敏感词前缀树,把待检测文本扫一遍,命中就拦下。相比逐个关键词contains,DFA 的时间复杂度只跟文本长度有关,跟词库大小无关,词库上到几万条也不会拖慢接口。

public boolean containsSensitive(String text) { if (StringUtils.isBlank(text)) { return false; } Map<Character, Object> node = root; for (int i = 0; i < text.length(); i++) { char c = text.charAt(i); Object next = node.get(c); if (next == null) { node = root; continue; } node = (Map<Character, Object>) next; if (Boolean.TRUE.equals(node.get("isEnd"))) { return true; } } return false; }

词库建议放独立文件,启动时加载到内存,后续要更新可以加个管理接口热加载,不要硬编码在 Java 里。

除了本地词库,文本和图片还可以接入平台提供的内容安全检测能力,在发布接口里同步调用一次再落库。这里要提醒的是:检测接口不要放在事务里面,它是一次外部网络调用,耗时不可控,放在事务里会长时间占着数据库连接。正确顺序是先检测、通过后再进事务写库。

3. 实操过程与核心环节落地

这一章讲怎么真正把它跑起来。我按从环境到部署的顺序,把关键步骤和可复制的代码贴出来,你对照着做基本能少走一大半弯路。

3.1 环境准备与依赖配置

需要准备的东西不多,列个清单:

组件版本建议用途
JDK8 或 11后端运行环境,8 兼容性最好
Maven3.6+依赖管理与打包
MySQL5.7 或 8.0数据存储
Redis5.0+可选,用于缓存与排行榜
微信开发者工具最新稳定版小程序端调试
Node.js14+小程序 npm 构建(用组件库时需要)

后端pom.xml的核心依赖就几个,没必要堆一堆用不上的:

<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.2.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.83</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

配置文件application.yml里把数据库和业务参数分开写,方便切换环境:

server: port: 8080 servlet: context-path: /api spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/english_help?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false username: root password: your_password servlet: multipart: max-file-size: 10MB max-request-size: 20MB mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.kaic.help.entity configuration: map-underscore-to-camel-case: true wx: app-id: 你的小程序AppID app-secret: 你的小程序AppSecret jwt: secret: kaic-english-help-secret-key-please-change expire-seconds: 604800

这里有几个配置细节值得说。serverTimezone必须指定,否则连 MySQL 8 的时候驱动会报时区错误;map-underscore-to-camel-case打开之后,数据库的create_time会自动映射到实体的createTime,省掉大量手动映射;jwt.secret上线前一定要改,且长度要够,太短的密钥容易被暴力撞出来。

3.2 登录接口与统一返回体

统一返回体看起来是小事,实际上是前后端协作效率的关键。前端最怕的就是这个接口返回{code:0},那个接口返回{success:true},处理逻辑写得乱七八糟。

@Data public class Result<T> { private Integer code; private String msg; private T data; public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.setCode(200); r.setMsg("ok"); r.setData(data); return r; } public static <T> Result<T> fail(Integer code, String msg) { Result<T> r = new Result<>(); r.setCode(code); r.setMsg(msg); return r; } }

登录接口本体:

@PostMapping("/login") public Result<LoginVo> login(@RequestBody LoginDto dto) { WxSessionDto session = wxUtil.code2Session(dto.getCode()); User user = userService.findOrCreateByOpenid(session.getOpenid(), dto.getNickname(), dto.getAvatar()); String token = JwtUtil.createToken(user.getId(), user.getOpenid()); LoginVo vo = new LoginVo(); vo.setToken(token); vo.setUserId(user.getId()); vo.setPoints(user.getPoints()); vo.setNewUser(user.getCreateTime().after(DateUtil.offsetMinute(new Date(), -1))); return Result.success(vo); }

newUser这个字段是我后来加的一个小细节:用创建时间判断是否一分钟内新注册的账号,前端拿到就弹一次引导弹窗,告诉新用户"完成一次提问可以领积分"。这个小改动让新用户首次互动率明显提升,成本极低,值得留着。

3.3 回答列表与分页查询的实现

分页是查询接口里最容易写出性能问题的地方。先说结论:不要用LIMIT n, m做大偏移量的分页,页码越深扫的行越多,翻到第几百页的时候接口就开始卡。这个项目数据量不大,简单的LIMIT足够,但写法上要做对。

Mapper 层:

<select id="pageByQuestion" resultType="com.kaic.help.dto.AnswerItemVo"> SELECT a.id, a.content, a.create_time, u.nickname, u.avatar, (SELECT COUNT(*) FROM t_answer_like l WHERE l.answer_id = a.id) AS like_count FROM t_answer a LEFT JOIN t_user u ON u.id = a.user_id WHERE a.question_id = #{questionId} AND a.status = 1 ORDER BY a.is_accept DESC, a.create_time DESC LIMIT #{offset}, #{size} </select>

注意排序规则里is_accept DESC排在最前,这样采纳的回答永远置顶,用户不用翻到底。这个排序比"最近回答优先"更好用,因为提问者采纳的那条才是最有价值的。

Service 层要做一个分页参数的保护,把 size 卡在 1 到 50 之间:

int safeSize = Math.min(Math.max(dto.getSize(), 1), 50); int offset = (Math.max(dto.getPage(), 1) - 1) * safeSize;

为什么必须卡上限?因为前端传参是可以被篡改的。不限制 size,恶意请求一次拉十万条,数据库和内存都会被拖垮。接口的参数边界校验是基本功,不是可选项。

3.4 小程序端请求封装与登录态续期

小程序端最常见的问题是每个页面都自己写一遍wx.request,导致 token 处理、错误提示到处重复。正确做法是封装一层。

const BASE_URL = 'https://your-domain.com/api'; function request(options) { const token = wx.getStorageSync('token'); return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data || {}, header: { 'content-type': 'application/json', 'Authorization': token ? 'Bearer ' + token : '' }, success(res) { if (res.statusCode === 200 && res.data.code === 200) { resolve(res.data.data); } else if (res.data.code === 401) { wx.removeStorageSync('token'); login().then(() => resolve(request(options))).catch(reject); } else { wx.showToast({ title: res.data.msg || '请求失败', icon: 'none' }); reject(res.data); } }, fail(err) { wx.showToast({ title: '网络异常', icon: 'none' }); reject(err); } }); }); }

这段代码里最关键的是401 自动重新登录并重放请求。用户 token 过期是必然会发生的,如果处理不当,体验就是"点了没反应,要退出重进"。自动续期的逻辑写一次,全站受益。要注意的是重放前必须加一个重试次数限制,避免 token 拿不到时无限递归。

登录函数本身:

function login() { return new Promise((resolve, reject) => { wx.login({ success(res) { wx.request({ url: BASE_URL + '/user/login', method: 'POST', data: { code: res.code }, success(r) { if (r.data.code === 200) { wx.setStorageSync('token', r.data.data.token); resolve(r.data.data); } else { reject(r.data); } }, fail: reject }); }, fail: reject }); }); }

3.5 打包部署与配置分离

本地跑通之后就是上线。后端打包直接mvn clean package -DskipTests,产出target/*.jar,然后:

nohup java -jar english-help-server-1.0.0.jar \ --spring.profiles.active=prod \ --wx.app-id=正式AppID \ --wx.app-secret=正式密钥 > app.log 2>&1 &

这里用的是命令行参数覆盖配置文件的方式,好处是敏感信息不用写进仓库。更好的做法是把这些放到环境变量里,启动脚本读环境变量。无论如何,AppSecret 这种东西绝不能提交到代码仓库,这是底线。

小程序端上线前要在开发者后台把后端域名加入服务器域名白名单,注意必须是 HTTPS,且域名要已完成备案。这一步没做的话,本地调试一切正常,真机一跑全是"不在以下 request 合法域名列表中",很多人第一次上线都在这里卡住。

4. 常见问题与排查技巧实录

这部分是我在实际调试和参考同类项目时遇到最多的坑,按类型整理出来,附带排查思路,能帮你省下不少排查时间。

4.1 登录态失效类问题

现象一:用户明明刚登录,进二级页面就提示未登录。排查顺序建议是这样:先看本地存储里 token 到底有没有写进去,wx.getStorageSync('token')打印一下;再看请求头是否正确带上了Authorization,可以在开发者工具的 Network 面板里直接看请求头。最常见的错误是前端存的是data,而data外面还包了一层Result,导致存进去的是整个返回对象,取 token 时取到undefined。

现象二:登录接口偶发校验失败。十有八九是 code 被重复使用了。用户的网络请求失败后触发了重试,而重试用的还是同一个 code,第二次必然失败。解决办法很简单:登录失败后不要重试同一个 code,而是重新wx.login()拿新的。另外要注意wx.login本身也应当有防抖,避免用户快速点击触发多次调用。

现象三:token 过期后页面卡死。这就是前面说的 401 没有处理。检查你的请求封装里是否处理了业务层的 401,而不只是 HTTP 状态码 401。很多项目后端把所有错误都返回 HTTP 200,真实状态码放在 body 的code里,如果你只判断statusCode,就永远走不到重新登录的分支。

提示:把"登录态失效"当成一等公民来处理。设计之初就想清楚 token 存哪、什么时候校验、失效了怎么办,比事后打补丁强太多。

4.2 跨域与本地联调类问题

本地开发时,小程序开发者工具默认会校验合法域名,直接请求http://localhost:8080会报错。解决办法是勾选"不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书",这只在开发环境生效,上线不受影响。

如果用浏览器直接调后端接口做测试,会遇到跨域。两个方案:一是加一个CorsConfig允许跨域,二是用WebMvcConfig配置代理。我倾向于在开发环境加一个宽松的 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); } }

注意allowedOriginPatterns和allowedOrigins的区别:当allowCredentials(true)时,用allowedOrigins("*")会直接启动报错,必须用allowedOriginPatterns。这个坑我踩过一次,启动日志报得云里雾里,其实原因就这一行。

4.3 数据一致性与并发类问题

问题:积分偶尔多给。八成是"先查余额再更新"的写法导致的丢更新,或者缺少防重。检查两件事:加积分的 SQL 是不是points = points + n的自增写法;积分流水表有没有业务唯一索引。

问题:一天打了两次卡。检查打卡表有没有(user_id, checkin_date)唯一索引。有的话,第二次插入会抛异常,业务层捕获后返回"今天已经打过卡了"即可。没有唯一索引的话,并发点击会插两条记录。这是典型的"代码判断挡不住并发,必须靠数据库约束"的场景。

问题:采纳后回答的置顶没生效。检查查询排序里的is_accept字段有没有在采纳时同步更新。如果采纳只更新了问题的accept_answer_id,而回答表里没有相应的标记字段,列表就没法按采纳状态排序。要么冗余一个标记字段,要么在查询里 join 问题表判断,前者性能更好。

4.4 常见问题速查表

把上面这些整理成一张表,出问题时先在这里找一遍:

现象最可能的原因处理方式
提示未登录token 未写入或请求头未携带检查存储的取值路径和 header 拼装
登录偶发失败code 被重复使用失败后重新调用 wx.login
请求被拦截域名未加白名单后台配置 HTTPS 合法域名
启动即报错CORS 凭证配置冲突改用 allowedOriginPatterns
积分重复发放缺少业务唯一索引给流水表加联合唯一索引
打卡重复缺少日期维度唯一约束加 (user_id, checkin_date) 索引
列表越来越慢偏移分页或未命中索引优化索引,限制分页大小
中文乱码字符集不统一库、表、连接串统一 utf8mb4

5. 二次开发与资料使用建议

源码到手之后,怎么用比有没有更关键。这一章说说这个项目还能往哪些方向扩展,以及那套文档和源码应该怎么读效率最高。

5.1 值得尝试的扩展方向

引入 Redis 做排行榜和热点缓存。积分排行榜如果每次都ORDER BY points DESC全表扫,数据量上来会很吃力。用 Redis 的 ZSet 维护排行榜,按积分设 score,查询用ZREVRANGE,性能提升非常明显。同时可以把热门的问答列表缓存几分钟,扛住突发流量。

给问答加标签和检索。现在只有分类的话,用户找历史内容很难。可以给问题加标签(如"语法""口语""四六级"),前端做标签筛选。数据量再大一些,可以引入全文索引,把问答正文纳入检索范围,让优质回答真正沉淀下来。

把打卡做成日历可视化。现在打卡只有记录,如果能渲染成一个日历热力图,连续打卡多少天一眼可见,用户的坚持感会强很多。前端拿打卡记录数组,根据月份渲染格子,数据接口已经是现成的,改动量不大。

增加学习小组。互助的最小单元其实是"小圈子"。可以加一个小组概念,用户建组、邀请成员,问答和排行在组内闭环。这个改动会涉及表结构,但能显著提升留存,是值得投入的方向。

补充数据看板。如果自己运营,需要知道每天新增多少用户、发帖多少、回答多少。加一个管理端,把关键指标做成图表,运营决策就有依据了。这部分的接口可以复用现有查询,只是换个维度和聚合方式。

5.2 文档加源码这套资料怎么读才不浪费时间

拿到"文档 + 源码"这种组合,很多人第一反应是打开源码从头看到尾,最后看得很累还没抓住重点。我的建议是先文档后代码,先流程后细节。

第一步,花二十分钟把文档里的功能清单和接口说明扫一遍,在脑子里建立"这个系统能做什么"的全局印象。不要一开始就抠代码细节,那会让你迷路。

第二步,挑一条最完整的业务链路跟下去,我一般选"登录到发布提问"这条。从 Controller 进入,一路跟到 Service、Mapper、SQL,把每一层的输入输出都搞清楚。走通一条链路,剩下的都是同类结构,看代码的速度会快很多。

第三步,重点看工具类和配置类。JWT 工具、微信工具、拦截器、全局异常处理,这些是整个项目的骨架,理解了它们,其他业务代码读起来就轻松了。很多人忽略异常处理,结果是调试时看到报错完全不知道从哪来。

第四步,改一个小功能跑一遍。比如把回答奖励的积分从 5 分改成 10 分,改完部署验证一次。这个动作能逼着你把编译、打包、部署的流程真正走通,比看十遍文档都管用。

最后说一点个人体会:这类课程设计性质的项目,代码本身不会特别复杂,真正的价值在于它提供了一个完整可运行的骨架。你要做的不是把它当成标准答案背下来,而是把它当地基,在上面加自己想做的东西。我在实际使用中发现,凡是那些"自己动手改过三处以上"的项目,印象都特别深,改的过程中遇到的问题才是真正学到的东西。改配置、加接口、调前端,哪怕只是把配色换一遍,动手一次胜过看十遍。

还有一个小技巧分享给你:把文档里提到的表结构和接口清单单独复制一份到自己的笔记里,然后关掉源码,试着只根据表结构推测每个接口是怎么实现的。推完再打开源码对照,差距在哪儿一目了然。这个方法用来检验自己是否真的理解了这套系统特别有效,也能顺带发现文档里没写清楚的地方。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询