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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;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%ssecret%sjs_code%sgrant_typeauthorization_code, appId, appSecret, code); ResponseEntityString 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 之后先查库没有就建档有就更新一下昵称头像然后签发 JWTpublic 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确定有限状态自动机构建敏感词前缀树把待检测文本扫一遍命中就拦下。相比逐个关键词containsDFA 的时间复杂度只跟文本长度有关跟词库大小无关词库上到几万条也不会拖慢接口。public boolean containsSensitive(String text) { if (StringUtils.isBlank(text)) { return false; } MapCharacter, 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 (MapCharacter, 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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.2.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency dependency groupIdcom.alibaba/groupId artifactIdfastjson/artifactId version1.2.83/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/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?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/ShanghaiuseSSLfalse 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 ResultT { private Integer code; private String msg; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.setCode(200); r.setMsg(ok); r.setData(data); return r; } public static T ResultT fail(Integer code, String msg) { ResultT r new Result(); r.setCode(code); r.setMsg(msg); return r; } }登录接口本体PostMapping(/login) public ResultLoginVo 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 idpageByQuestion resultTypecom.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.activeprod \ --wx.app-id正式AppID \ --wx.app-secret正式密钥 app.log 21 这里用的是命令行参数覆盖配置文件的方式好处是敏感信息不用写进仓库。更好的做法是把这些放到环境变量里启动脚本读环境变量。无论如何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) 索引列表越来越慢偏移分页或未命中索引优化索引限制分页大小中文乱码字符集不统一库、表、连接串统一 utf8mb45. 二次开发与资料使用建议源码到手之后怎么用比有没有更关键。这一章说说这个项目还能往哪些方向扩展以及那套文档和源码应该怎么读效率最高。5.1 值得尝试的扩展方向引入 Redis 做排行榜和热点缓存。积分排行榜如果每次都ORDER BY points DESC全表扫数据量上来会很吃力。用 Redis 的 ZSet 维护排行榜按积分设 score查询用ZREVRANGE性能提升非常明显。同时可以把热门的问答列表缓存几分钟扛住突发流量。给问答加标签和检索。现在只有分类的话用户找历史内容很难。可以给问题加标签如语法口语四六级前端做标签筛选。数据量再大一些可以引入全文索引把问答正文纳入检索范围让优质回答真正沉淀下来。把打卡做成日历可视化。现在打卡只有记录如果能渲染成一个日历热力图连续打卡多少天一眼可见用户的坚持感会强很多。前端拿打卡记录数组根据月份渲染格子数据接口已经是现成的改动量不大。增加学习小组。互助的最小单元其实是小圈子。可以加一个小组概念用户建组、邀请成员问答和排行在组内闭环。这个改动会涉及表结构但能显著提升留存是值得投入的方向。补充数据看板。如果自己运营需要知道每天新增多少用户、发帖多少、回答多少。加一个管理端把关键指标做成图表运营决策就有依据了。这部分的接口可以复用现有查询只是换个维度和聚合方式。5.2 文档加源码这套资料怎么读才不浪费时间拿到文档 源码这种组合很多人第一反应是打开源码从头看到尾最后看得很累还没抓住重点。我的建议是先文档后代码先流程后细节。第一步花二十分钟把文档里的功能清单和接口说明扫一遍在脑子里建立这个系统能做什么的全局印象。不要一开始就抠代码细节那会让你迷路。第二步挑一条最完整的业务链路跟下去我一般选登录到发布提问这条。从 Controller 进入一路跟到 Service、Mapper、SQL把每一层的输入输出都搞清楚。走通一条链路剩下的都是同类结构看代码的速度会快很多。第三步重点看工具类和配置类。JWT 工具、微信工具、拦截器、全局异常处理这些是整个项目的骨架理解了它们其他业务代码读起来就轻松了。很多人忽略异常处理结果是调试时看到报错完全不知道从哪来。第四步改一个小功能跑一遍。比如把回答奖励的积分从 5 分改成 10 分改完部署验证一次。这个动作能逼着你把编译、打包、部署的流程真正走通比看十遍文档都管用。最后说一点个人体会这类课程设计性质的项目代码本身不会特别复杂真正的价值在于它提供了一个完整可运行的骨架。你要做的不是把它当成标准答案背下来而是把它当地基在上面加自己想做的东西。我在实际使用中发现凡是那些自己动手改过三处以上的项目印象都特别深改的过程中遇到的问题才是真正学到的东西。改配置、加接口、调前端哪怕只是把配色换一遍动手一次胜过看十遍。还有一个小技巧分享给你把文档里提到的表结构和接口清单单独复制一份到自己的笔记里然后关掉源码试着只根据表结构推测每个接口是怎么实现的。推完再打开源码对照差距在哪儿一目了然。这个方法用来检验自己是否真的理解了这套系统特别有效也能顺带发现文档里没写清楚的地方。
