概述
陌生人问题是一道"破冰题":每个用户预设一道自己的问题,陌生人查看资料时看到它,回答后经环信 IM 以私信形式送到提问者手里,回答本身不落库。本文按工程实读把三条代码链路走通:查询自己的预设问题、保存预设问题(一人一行的 upsert)、陌生人回答的环信转发。
纲要
- 需求与接口边界(先纠正一个常见误解:保存接口不是
/mf/strangerQuestions) - 文件清单(表格 + 目录树)
- 完整代码链路
- 领域模型 Question 与 Dubbo 接口 QuestionApi / QuestionApiImpl / QuestionMapper
- 查询自己的预设问题(随通用设置带出,无记录时的默认文案)
- 保存预设问题:先查后写的 upsert + 并发插重风险与唯一索引兜底
- 陌生人回答的转发:replyQuestions → HuanXinTemplate.sendMsg
- 时序图:保存预设问题(含并发分支)、回答转发链路
- 踩坑清单(upsert 并发插重、越权、超长、环信发送失败语义)
- 可复现的验证步骤
- 常见误解澄清 + 总结
先说接口边界:四个入口各管一件事
陌生人问题相关的接口入口一共有四个,容易混:
| 接口 | 位置 | 作用 |
|---|---|---|
GET /users/settings | SettingsController | 查询通用设置,自己的预设问题随 SettingsVo 一并返回(设置页展示/编辑用) |
POST /users/questions | SettingsController | 保存自己的预设问题(body 是 {"content": "..."}) |
GET /mf/strangerQuestions?userId= | mfController | 查看他人的陌生人问题(查看佳人详情场景) |
POST /mf/strangerQuestions | mfController | 回复陌生人问题(body 是 {"userId": 提问人, "reply": "回答"}),回答经环信转发,不落库 |
很多资料把"保存预设问题"写成 POST /mf/strangerQuestions,这是错的——那个路径是回复接口。保存走的是 /users/questions,挂在通用设置(SettingsController,/users 前缀)下面,因为入口在 App 的"通用设置"页。
回答经环信转发这点是产品语义的关键:回答不存 MySQL,直接以私信形式送达提问人。聊天记录由环信托管,服务端只做"组装消息 + 调环信 REST 接口"这一跳。
文件清单
| 文件 | 模块 | 职责 |
|---|---|---|
Question.java | mf-model | 领域模型,对应表 tb_question |
QuestionApi.java | mf-dubbo-interface | Dubbo 接口:findByUserId / save / update |
QuestionApiImpl.java | mf-dubbo-db | Dubbo 实现,操作 MySQL |
QuestionMapper.java | mf-dubbo-db | MyBatis-Plus BaseMapper |
SettingsController.java | mf-app-server | POST /users/questions 保存入口 |
SettingsService.java | mf-app-server | saveQuestion 的先查后写逻辑 + settings 带出问题 |
mfController.java | mf-app-server | 查看他人问题 / 回复陌生人问题两个入口 |
mfService.java | mf-app-server | replyQuestions 组装消息并发环信 |
HuanXinTemplate.java | mf-autoconfig | 环信 REST 封装(sendMsg) |
Constants.java | mf-commons | HX_USER_PREFIX = "hx" 环信用户名前缀 |
BasePojo.java | mf-model | created/updated 自动填充基类 |
涉及模块的结构:
mf
├── mf-app-server # 应用层(18080),Controller + Service
│ └── src/main/java/com/mf/server
│ ├── controller
│ │ ├── SettingsController.java # POST /users/questions
│ │ └── mfController.java # GET/POST /mf/strangerQuestions
│ └── service
│ ├── SettingsService.java # saveQuestion:先查后写
│ └── mfService.java # replyQuestions:转发环信
├── mf-dubbo
│ ├── mf-dubbo-interface
│ │ └── src/main/java/com/mf/dubbo/api/QuestionApi.java
│ └── mf-dubbo-db
│ └── src/main/java/com/mf/dubbo
│ ├── api/QuestionApiImpl.java
│ └── mappers/QuestionMapper.java
├── mf-model
│ └── src/main/java/com/mf/model/domain
│ ├── Question.java
│ └── BasePojo.java
├── mf-autoconfig
│ └── src/main/java/com/mf/autoconfig/template/HuanXinTemplate.java
└── mf-commons
└── src/main/java/com/mf/commons/utils/Constants.java
领域模型与数据表
Question 对应 MySQL 表 tb_question(id / user_id / txt / created / updated),继承 BasePojo 后 created/updated 由 MyBatis-Plus 自动填充,业务代码不用手动 set 时间:
package com.mf.model.domain;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
public abstract class BasePojo implements Serializable {
@TableField(fill = FieldFill.INSERT) //自动填充
private Date created;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updated;
}
package com.mf.model.domain;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
//陌生人问题
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Question extends BasePojo {
private Long id;
private Long userId;
//问题内容
private String txt;
}
数据访问层是标准的 MyBatis-Plus 写法,一行 SQL 都不用手写:
package com.mf.dubbo.mappers;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mf.model.domain.Question;
public interface QuestionMapper extends BaseMapper<Question> {
}
Dubbo 接口与实现。注意接口只暴露了 findByUserId / save / update 三个方法,"有则更新、无则保存"的判断放在应用层 Service 里做(这个决定有并发隐患,后面单独说):
package com.mf.dubbo.api;
import com.mf.model.domain.Question;
public interface QuestionApi {
//根据用户id查询陌生人问题
Question findByUserId(Long userId);
//保存
void save(Question question);
//更新
void update(Question question);
}
package com.mf.dubbo.api;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.mf.dubbo.mappers.QuestionMapper;
import com.mf.model.domain.Question;
import org.apache.dubbo.config.annotation.DubboService;
import org.springframework.beans.factory.annotation.Autowired;
@DubboService
public class QuestionApiImpl implements QuestionApi{
@Autowired
private QuestionMapper questionMapper;
@Override
public Question findByUserId(Long userId) {
QueryWrapper<Question> qw = new QueryWrapper<>();
qw.eq("user_id",userId);
return questionMapper.selectOne(qw);
}
@Override
public void save(Question question) {
questionMapper.insert(question);
}
@Override
public void update(Question question) {
questionMapper.updateById(question);
}
}
链路一:查询自己的预设问题
设置页打开时调 GET /users/settings,问题文本随 SettingsVo.strangerQuestion 带出。核心点在新用户没有记录时返回什么:工程里没有报错也没有返回 null,而是兜底了一句默认文案,保证前端永远有东西可展示、可编辑:
//查询通用设置
public SettingsVo settings() {
SettingsVo vo = new SettingsVo();
//1、获取用户id
Long userId = UserHolder.getUserId();
vo.setId(userId);
//2、获取用户的手机号码
vo.setPhone(UserHolder.getMobile());
//3、获取用户的陌生人问题
Question question = questionApi.findByUserId(userId);
String txt = question == null ? "你喜欢java吗?" : question.getTxt();
vo.setStrangerQuestion(txt);
//4、获取用户的APP通知开关数据
Settings settings = settingsApi.findByUserId(userId);
if(settings != null) {
vo.setGonggaoNotification(settings.getGonggaoNotification());
vo.setPinglunNotification(settings.getPinglunNotification());
vo.setLikeNotification(settings.getLikeNotification());
}
return vo;
}
UserHolder.getUserId() 从 ThreadLocal 里取当前登录用户 id(登录校验在拦截器里完成,见前面拦截器),所以这个接口天然只能查到自己的问题,不存在越权。
链路二:保存预设问题(本文核心:upsert)
Controller 入口
保存接口挂在 SettingsController 上,接收一个 content 参数。用 Map 接参是为了和接口文档的字段名对齐,k 忘了可以回 YApi 接口文档里查(这里是 content):
/**
* 设置陌生人问题
*/
@PostMapping("/questions")
public ResponseEntity questions(@RequestBody Map map) {
//获取参数
String content = (String) map.get("content");
settingsService.saveQuestion(content);
return ResponseEntity.ok(null);
}
Service:先查后写
业务规则是一人一行:第一次设置是 insert,之后再改是 update。实现就是朴素的"先查后写":
//设置陌生人问题
public void saveQuestion(String content) {
//1、获取当前用户id
Long userId = UserHolder.getUserId();
//2、调用api查询当前用户的陌生人问题
Question question = questionApi.findByUserId(userId);
//3、判断问题是否存在
if(question == null) {
//3.1 如果不存在,保存
question = new Question();
question.setUserId(userId);
question.setTxt(content);
questionApi.save(question);
}else {
//3.2 如果存在,更新
question.setTxt(content);
questionApi.update(question);
}
}
created/updated 不用手动设置——BasePojo 上标了 FieldFill.INSERT / INSERT_UPDATE,MyBatis-Plus 的自动填充处理器会在 insert/update 时统一补时间。
并发风险:没有唯一索引的"一人一行"守不住
先查后写在单线程下没问题,但两个请求并发时就露馅了。用一个时序图看坏情形(自己快速双击保存按钮,或者两个端同时登录):
后果不只是脏数据:findByUserId 用的 selectOne 查到多行时 MyBatis-Plus 会直接抛 TooManyResultsException,用户的设置页从此打不开。这类"查不到再插"的逻辑,判断依据和写入之间必须有一个数据库层面的硬约束兜底——给 user_id 加唯一索引。这也是本系列反复踩的"缺唯一索引"教训的又一次应验:凡是有"一人一行 / 一单一行"语义的表,唯一索引是底线配置。
工程快照里没有带 tb_question 的建表 SQL,无法确认线上表有没有这个索引,保险起见手动补上:
ALTER TABLE tb_question ADD UNIQUE INDEX uk_question_user_id (user_id);
加了唯一索引后,Dubbo 实现里的 save 可以捕获 DuplicateKeyException 转成 update,把"先查后写"从两步竞态变成数据库仲裁的原子操作。在 QuestionApiImpl 上的增强写法:
package com.mf.dubbo.api;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.mf.dubbo.mappers.QuestionMapper;
import com.mf.model.domain.Question;
import org.apache.dubbo.config.annotation.DubboService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
@DubboService
public class QuestionApiImpl implements QuestionApi{
@Autowired
private QuestionMapper questionMapper;
@Override
public Question findByUserId(Long userId) {
QueryWrapper<Question> qw = new QueryWrapper<>();
qw.eq("user_id",userId);
return questionMapper.selectOne(qw);
}
@Override
public void save(Question question) {
try {
questionMapper.insert(question);
} catch (DuplicateKeyException e) {
//并发下另一个请求已经插入了同一 user_id,转为更新
QueryWrapper<Question> qw = new QueryWrapper<>();
qw.eq("user_id", question.getUserId());
Question db = questionMapper.selectOne(qw);
question.setId(db.getId());
questionMapper.updateById(question);
}
}
@Override
public void update(Question question) {
questionMapper.updateById(question);
}
}
这样即使两个请求同时过了"查无记录"的判断,也只有一个 insert 能成功,另一个落到 catch 里走 update,最终仍然是一行。应用层的 saveQuestion 代码不用动。
链路三:陌生人回答的转发
两个入口
mfController 里与陌生人问题相关的两个方法:
/**
* 查看陌生人问题
*/
@GetMapping("/strangerQuestions")
public ResponseEntity strangerQuestions(Long userId) {
String questions = mfService.strangerQuestions(userId);
return ResponseEntity.ok(questions);
}
/**
* 回复陌生人问题
*/
@PostMapping("/strangerQuestions")
public ResponseEntity replyQuestions(@RequestBody Map map) {
//前端传递的userId:是Integer类型的
String obj = map.get("userId").toString();
Long userId = Long.valueOf(obj);
String reply = map.get("reply").toString();
mfService.replyQuestions(userId,reply);
return ResponseEntity.ok(null);
}
GET 带 userId 参数,查的是别人的问题(陌生人点开佳人详情后看到的破冰题);POST 带 userId + reply,是回答这道题。一个路径两个动词、两种语义,联调时别拿错。
Service:组装消息发环信
//查看陌生人问题
public String strangerQuestions(Long userId) {
Question question = questionApi.findByUserId(userId);
return question == null ? "你喜欢java编程吗?" : question.getTxt();
}
//回复陌生人问题
public void replyQuestions(Long userId, String reply) {
//1、构造消息数据
Long currentUserId = UserHolder.getUserId();
UserInfo userInfo = userInfoApi.findById(currentUserId);
Map map = new HashMap();
map.put("userId",currentUserId);
map.put("huanXinId", Constants.HX_USER_PREFIX+currentUserId);
map.put("nickname",userInfo.getNickname());
map.put("strangerQuestion",strangerQuestions(userId));
map.put("reply",reply);
String message = JSON.toJSONString(map);
//2、调用template对象,发送消息
Boolean aBoolean = template.sendMsg(Constants.HX_USER_PREFIX + userId, message);//1、接受方的环信id,2、消息
if(!aBoolean) {
throw new BusinessException(ErrorResult.error());
}
}
这段逻辑做了三件事:
- 组装消息体。把回答者的 userId、环信 id(
hx+ 用户 id,见Constants.HX_USER_PREFIX = "hx")、昵称、原问题和回答内容打包成一个 JSON。原问题也塞进去,是因为提问人收到的私信卡片要能显示"谁回答了我的什么问题"。 - 发送。收件人是提问者的环信账号(
hx + userId),调用HuanXinTemplate.sendMsg。 - 失败处理。
sendMsg返回 false 时抛BusinessException,由统一异常处理转成错误响应。
注意一个细节:reply 的回答内容没有任何 insert 操作——回答不落库,送达即结束,聊天记录由环信托管。这就是产品上"回答即私信"的语义。
HuanXinTemplate.sendMsg 的实现已经在 mf-autoconfig 模块里封装好了(本快照已有,走的是环信 Java SDK 的 REST 发送,from 固定为 admin 管理员账号):
//发送消息
public Boolean sendMsg(String username,String content) {
try {
//接收人用户列表
Set<String> set = CollUtil.newHashSet(username);
//文本消息
EMTextMessage message = new EMTextMessage().text(content);
//发送消息 from:admin是管理员发送
service.message().send("admin","users",
set,message,null).block();
return true;
}catch (Exception e) {
log.error("删除联系人失败~");
}
return false;
}
顺带一提,catch 里的日志文案"删除联系人失败~"是工程里的原样代码——典型的复制粘贴没改文案,排查环信问题时别被这行日志误导,它其实是发送消息失败打出来的。
环信账号注册、联系人关系的封装细节在后续"即时通信"展开,本文只用到 sendMsg(收件人环信id, 消息文本) 这一个方法。
回答转发的完整链路
"回答不落库"是刻意的:回答的唯一价值就是送达,一旦发出,后续沟通自然转入环信的会话流。服务端不存回答,就少了一张注定只会写一次、再也不会读的表。
API 速览
| 方法 | 路径 | 参数 | 返回 | 说明 |
|---|---|---|---|---|
| GET | /users/settings | 无(token 识别用户) | SettingsVo(含 strangerQuestion) | 自己的预设问题随通用设置带出,无记录返回默认文案 |
| POST | /users/questions | {"content": "问题文本"} | 空 | 保存/更新自己的预设问题,一人一行 |
| GET | /mf/strangerQuestions | ?userId= 对方id | 问题文本字符串 | 查看他人的破冰题,无记录返回默认文案 |
| POST | /mf/strangerQuestions | {"userId": 提问人, "reply": "回答"} | 空 | 回答转发环信私信,不落库 |
踩坑清单
| 坑 | 现象 | 处理 |
|---|---|---|
| upsert 并发插重 | user_id 无唯一索引时,并发保存插出多行,selectOne 抛 TooManyResultsException,设置页打不开 | ALTER TABLE tb_question ADD UNIQUE INDEX uk_question_user_id (user_id),insert 捕获 DuplicateKeyException 转 update |
| user_id 覆写缺失 | 保存时若用前端传来的 userId 构造 Question,可以被改成别人的 id,越权改写他人问题 | userId 一律取 UserHolder.getUserId(),前端传参只信 content;replyQuestions 里前端传的 userId 是"提问人",语义不同别混用 |
| txt 超长 | 表字段一般是 varchar(200),超长文本 insert 直接报 Data too long for column | 服务端保存前校验长度(或数据库改 text 类型),前端输入框同步 maxlength |
| 环信发送失败但接口已返回成功 | 若把 sendMsg 返回值忽略,回答者以为送达了,提问人却永远收不到——"接口成功"和"消息送达"是两码事 | 工程代码是 !aBoolean 即抛 BusinessException,回答失败用户立刻看到报错、可重发;代价是环信抖动时回答者会感知失败。取舍上宁可让回答者重发,也不能静默丢失 |
| 回复无自问自答校验 | 自己回答自己的问题也会照发环信,给自己发一条私信 | 可在 replyQuestions 前加 userId.equals(currentUserId) 拦截;课程代码从简未做,生产建议补上 |
| 前端 userId 类型是 Integer | map.get("userId") 直接强转 Long 会抛 ClassCastException | 先 toString() 再 Long.valueOf(),如 Controller 里所写 |
验证:可复现步骤
环境:MySQL + Dubbo(mf-dubbo-db 提供方)+ mf-app-server(18080)都已启动,App 端登录一个账号(如 13800013800),token 放进请求头。
验证保存与一人一行:
# 第一次保存(insert)
curl -X POST "http://localhost:18080/users/questions" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"content":"我喜欢扎马尾的男生,你呢?"}'
# 查库:一行
mysql> SELECT id, user_id, txt FROM tb_question WHERE user_id = <当前用户id>;
+----+---------+----------------------------------------+
| id | user_id | txt |
+----+---------+----------------------------------------+
| 1 | 106 | 我喜欢扎马尾的男生,你呢? |
+----+---------+----------------------------------------+
# 重复保存(应为 update,不新增行)
curl -X POST "http://localhost:18080/users/questions" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"content":"我喜欢编程,你喜欢java吗?"}'
# 再查:仍是同一行,id 不变、txt 已更新、updated 时间刷新
再退出 App 重新进设置页,展示的已是新文案——机翻演示环节里"设置后查库有值、修改后刷新更新"就是这条链路。
验证查询默认文案:用一个从未设置过问题的新账号调 GET /users/settings,返回的 strangerQuestion 应为"你喜欢java吗?"。
验证回答转发:换另一个账号(陌生人视角)调:
curl -X POST "http://localhost:18080/mf/strangerQuestions" \
-H "Authorization: Bearer <陌生人token>" \
-H "Content-Type: application/json" \
-d '{"userId":106,"reply":"喜欢,正好我也在学java"}'
返回 200 后,到环信管理后台(或看 mf-app-server 日志、提问者 App 的消息列表)确认:hx106 这个账号收到了一条来自 admin 发出的私信,内容包含回答者昵称、原问题和 reply 文本。数据库各表都不会新增回答记录——这是预期行为。
常见误解澄清
| 误解 | 事实 |
|---|---|
| 陌生人问题是"允许陌生人提问"的开关 | 不是开关,是一道预设的问题。一人一行存 tb_question,陌生人必须回答这道题才能发起私聊 |
保存接口是 POST /mf/strangerQuestions | 保存自己的问题是 POST /users/questions;/mf/strangerQuestions 的 POST 是回答别人的问题,GET 是查看别人的问题 |
| 回答内容会存进数据库 | 不落库。回答组装成环信私信发给提问人,聊天记录在环信侧 |
| 每个回答者的回答都要逐条存储供提问人浏览 | 提问人是在环信会话里看回答,不是在服务端列表页。服务端只负责转发这一跳 |
| upsert 用"先查后写"就够安全 | 单线程够用,并发下会插重。必须 user_id 唯一索引 + DuplicateKeyException 兜底 |
| 环信消息发出前接口就返回成功会更好(异步化) | 对"回答"这个场景相反:发送失败必须让回答者知道,否则破冰永远石沉大海。工程选择同步发送、失败即报错 |
| 新用户查不到问题要返回 null 让前端判空 | 工程在服务端兜底默认文案,前端永远拿到可展示的字符串,省掉一批前端判空逻辑 |
总结
- 陌生人问题的数据面只有一张
tb_question,一人一行;接口面有四处:设置页带出(GET /users/settings)、保存(POST /users/questions)、查看他人(GET /mf/strangerQuestions)、回答转发(POST /mf/strangerQuestions)。 - 保存的 upsert 是"先查后写 + 唯一索引兜底"的组合:判断逻辑写在应用层,数据一致性交给数据库的唯一索引和
DuplicateKeyException,两层缺一不可。 - 回答链路的本质是"服务端做一次消息组装,然后借环信的手送达",回答不落库。接收人身份统一用
hx + userId约定,和环信侧账号体系对齐。 - “接口成功"不等于"消息送达”,同步发送 + 失败抛异常是这类破冰消息的合理取舍。
后续规划《黑名单:需求分析》(分页查询黑名单、取消黑名单都在其后),和本文共用 SettingsController / SettingsService 这套代码骨架,读起来会很顺。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/Tyro_java/article/details/168450928




