Wang's Blog头像
Java 项目实战: 交友项目 - 陌生人问题:代码实现封面图

Java 项目实战: 交友项目 - 陌生人问题:代码实现

概述

陌生人问题是一道"破冰题":每个用户预设一道自己的问题,陌生人查看资料时看到它,回答后经环信 IM 以私信形式送到提问者手里,回答本身不落库。本文按工程实读把三条代码链路走通:查询自己的预设问题、保存预设问题(一人一行的 upsert)、陌生人回答的环信转发。

纲要

  • 需求与接口边界(先纠正一个常见误解:保存接口不是 /mf/strangerQuestions)
  • 文件清单(表格 + 目录树)
  • 完整代码链路
    • 领域模型 Question 与 Dubbo 接口 QuestionApi / QuestionApiImpl / QuestionMapper
    • 查询自己的预设问题(随通用设置带出,无记录时的默认文案)
    • 保存预设问题:先查后写的 upsert + 并发插重风险与唯一索引兜底
    • 陌生人回答的转发:replyQuestions → HuanXinTemplate.sendMsg
  • 时序图:保存预设问题(含并发分支)、回答转发链路
  • 踩坑清单(upsert 并发插重、越权、超长、环信发送失败语义)
  • 可复现的验证步骤
  • 常见误解澄清 + 总结

先说接口边界:四个入口各管一件事

陌生人问题相关的接口入口一共有四个,容易混:

接口位置作用
GET /users/settingsSettingsController查询通用设置,自己的预设问题随 SettingsVo 一并返回(设置页展示/编辑用)
POST /users/questionsSettingsController保存自己的预设问题(body 是 {"content": "..."})
GET /mf/strangerQuestions?userId=mfController查看他人的陌生人问题(查看佳人详情场景)
POST /mf/strangerQuestionsmfController回复陌生人问题(body 是 {"userId": 提问人, "reply": "回答"}),回答经环信转发,不落库

很多资料把"保存预设问题"写成 POST /mf/strangerQuestions,这是错的——那个路径是回复接口。保存走的是 /users/questions,挂在通用设置(SettingsController,/users 前缀)下面,因为入口在 App 的"通用设置"页。

回答经环信转发这点是产品语义的关键:回答不存 MySQL,直接以私信形式送达提问人。聊天记录由环信托管,服务端只做"组装消息 + 调环信 REST 接口"这一跳。

文件清单

文件模块职责
Question.javamf-model领域模型,对应表 tb_question
QuestionApi.javamf-dubbo-interfaceDubbo 接口:findByUserId / save / update
QuestionApiImpl.javamf-dubbo-dbDubbo 实现,操作 MySQL
QuestionMapper.javamf-dubbo-dbMyBatis-Plus BaseMapper
SettingsController.javamf-app-serverPOST /users/questions 保存入口
SettingsService.javamf-app-serversaveQuestion 的先查后写逻辑 + settings 带出问题
mfController.javamf-app-server查看他人问题 / 回复陌生人问题两个入口
mfService.javamf-app-serverreplyQuestions 组装消息并发环信
HuanXinTemplate.javamf-autoconfig环信 REST 封装(sendMsg)
Constants.javamf-commonsHX_USER_PREFIX = "hx" 环信用户名前缀
BasePojo.javamf-modelcreated/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 时统一补时间。

并发风险:没有唯一索引的"一人一行"守不住

先查后写在单线程下没问题,但两个请求并发时就露馅了。用一个时序图看坏情形(自己快速双击保存按钮,或者两个端同时登录):

tb_question 表请求B(saveQuestion)请求A(saveQuestion)tb_question 表请求B(saveQuestion)请求A(saveQuestion)两个请求都走进了"不存在→insert"分支两行并存,"一人一行"被破坏select * where user_id = 1null(无记录)select * where user_id = 1null(无记录)insert (user_id=1, txt=问题A)insert (user_id=1, txt=问题B)

后果不只是脏数据: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());
        }
    }

这段逻辑做了三件事:

  1. 组装消息体。把回答者的 userId、环信 id(hx + 用户 id,见 Constants.HX_USER_PREFIX = "hx")、昵称、原问题和回答内容打包成一个 JSON。原问题也塞进去,是因为提问人收到的私信卡片要能显示"谁回答了我的什么问题"。
  2. 发送。收件人是提问者的环信账号(hx + userId),调用 HuanXinTemplate.sendMsg。
  3. 失败处理。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, 消息文本) 这一个方法。

回答转发的完整链路

问题主人(提问者手机)环信服务端mf-app-server陌生人(回答者)问题主人(提问者手机)环信服务端mf-app-server陌生人(回答者)alt[发送成功][发送失败]POST /mf/strangerQuestions {userId, reply}取 UserHolder 当前用户id,查昵称组装 JSON(userId/huanXinId/nickname/strangerQuestion/reply)HuanXinTemplate.sendMsg("hx+提问者id", JSON)true200 OK(回答不落库,到此结束)私信推送,提问人收到回答卡片false抛 BusinessException,返回错误

"回答不落库"是刻意的:回答的唯一价值就是送达,一旦发出,后续沟通自然转入环信的会话流。服务端不存回答,就少了一张注定只会写一次、再也不会读的表。

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 类型是 Integermap.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

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--