使用 websocket 实现基于 springboot 的 web 管理端与微信公众号用户的消息会话(ws/wss连接)
一、需求:
小程序用户在进入小程序进行下单消费,然后关注公众号,在公众号里发消息到微信公众号服务器,此时web端需要展示该用户发过来的消息,web后台管理系统的管理人员收到消息可以进行回复,本功能暂时只支持文本消息交互,后期会扩展到音视频、图片等消息交互
二、前期准备工作以及注意的地方
1、用户需要进入过小程序,并且授权登录小程序,微信小程序关联微信公众号,并且微信公众号在微信开放平台绑定认证,后台服务器将获取openId
以及unionId
,并且将unionId
和小程序用户的openid
(本文标记为openidA
)关联在一起缓存在redis
2、开发者在微信公众号平台后台配置接受微信消息的业务后台路径(注意路径不可被拦截,该路径不需要登录或者业务后台自定义的token
令牌验证)
3、业务服务器根据公众号的 Appid
以及 Appsecret
获取 access_token
,并且需要过期刷新,公众号的access_token
只能在一个服务换取,如果有两个服务获取,第一个服务调用会报错:access_token
失效但是不过期
4、进入小程序的微信用户关注公众号,当后台服务器收到微信公众号发来的 {CreateTime=1545034549, EventKey=, Event=subscribe, ToUserName=XXXXXXXX, FromUserName=XXXXXXXXXX, MsgType=event}
ToUserName:微信公众号原始id
FromUserName:微信公众号openid
Event:时间类型
MsgType:消息类型
CreateTime:消息产生时间
消息时,业务后台会根据 FromUserName
(微信用户在微信公众号的openidB
) 来获取微信公众号关注用户在公众号的unionid
,将两者关联起来缓存在redis
,并且给微信公众号用户推送小程序文字链
5、websocket连接需要在nginx配置协议转换,wss需要在nginx配置ssl证书以及路径
6、本文小程序用户openid
用 openidA
表示,微信公众号用户 openid
用openidB
表示
三、流程简介:
四、代码:
本文主要讲述大概的业务流程以及思路,代码没有一 一贴出,读者有需要可以留言联系
首先要加入websocket必须的依赖
WebSocket配置:
@Component
public class WebSocketConfig {
//在本地调试时需要加上配置,部署到服务器注释掉此段配置,使用外部tomcat的包,不需要这个配置
@Bean
public ServerEndpointExporter serverEndpointExporter(){
return new ServerEndpointExporter();
}
}
SocketServer(webSocket服务类)
@Slf4j
@ServerEndpoint(value = "/socketServer")
@Component
public class SocketServer {
private static Logger logger = LoggerFactory.getLogger(SocketServer.class);
/**
* 客服接口发送消息
*/
private static final String CLIENT_SENT_MESSAGE = "https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=ACCESS_TOKEN";
private Session session;
private static Map<Session, String> sessionPool = new HashMap<>();
private static Map<String, String> sessionIds = new HashMap<>();
//concurrent包的线程安全Set,用来存放每个客户端对应的SocketServer对象。若要实现服务端与单一客户端通信的话,可以使用Map来存放,其中Key可以为用户标识
private static CopyOnWriteArraySet<SocketServer> socketServers = new CopyOnWriteArraySet<SocketServer>();
@Autowired
private ImService imService;
private static RedisService redisService;
private static RedisUtil redisUtil;
private static UserService userService;
/**
* 这里需要注意的是,websocket的对象是不受spring管理的
* 在websocket连接的时候新建一个websocket对象,这个新建的对象不受spring管理,所以会注入不进去,所以通过如下方法来注入,
* 用受管理的对象,注入依赖对象,并且将其放到websocket的static成员种,这样其他对象就可以调用了
*/
@Autowired
private void setRedisUtil(RedisUtil redisUtil, RedisService redisService, UserService userService) {
SocketServer.redisUtil = redisUtil;
SocketServer.redisService = redisService;
SocketServer.userService = userService;
}
//连接时存放当前的websocket对象
@OnOpen
public void open(Session session) {
this.session = session;
socketServers.add(this);
logger.info("========>【IM会话连接成功,有新的客户端 sessionId:{} 加入】<========", session.getId());
try {
logger.info("Connection Successfully:Ready Send Msg");
//异步
this.session.getAsyncRemote().sendText("【From Server】:Connection Successfully!");
} catch (Exception e) {
logger.error("Client {} Connection Successfully,CallBack Msg Exception",session.getId());
e.printStackTrace();
}
logger.info("检查缓存中所有微信公众号发过来的消息(单个用户最多缓存十条,可在后台系统配置条数) ");
List<User> userList = userService.selectList(new EntityWrapper<User>().eq("is_deleted", DeleteStatus.NOT_DELETED.getCode()));
//查询所有用户,在websocket建立连接时把redis缓存的所有用户消息都发送到前端
if(!ParamUtil.isNullOrEmptyOrZero(userList)){
for (User user : userList) {
String openid = user.getOpenid();
List userMsgsFromWx = redisService.getUserMsgsFromWx(openid);
//该用户没有消息缓存
if (ParamUtil.isNullOrEmptyOrZero(userMsgsFromWx)) {
continue;
}
logger.info("用户 {} 缓存的消息条数 {},消息内容 【{}】", openid, userMsgsFromWx.size(), userMsgsFromWx);
//发送该用户的所有缓存消息到所有 websocket 客户端
for (Object msgsFromWx : userMsgsFromWx) {
sendMessage(msgsFromWx.toString());
}
//发送完消息,销毁该用户在缓存中的消息
redisService.deleteUserMsgsFromWx(openid);
}
}
}
//接收前端(客户端)的消息
@OnMessage
public void onMessage(String message) {
JSONObject messageObj = JSONObject.parseObject(message);
String content = messageObj.getString("content");
String msgType = messageObj.getString("msgType");
logger.info("========>【来自客户端 sessionid {} 的消息 {}<========】", session.getId(), message);
//维持心跳连接(系统消息)
if ("ping".equals(content) && MsgType.SYSTEM_MSG.getCode().equals(Integer.valueOf(msgType)) && !ParamUtil.isNullOrEmptyOrZero(session)) {
try {
logger.info("from client request : ping");
session.getAsyncRemote().sendText("from server response : pong");
return;
} catch (Exception e) {
e.printStackTrace();
}
}
String openid = messageObj.getString("openid");
//通过客服接口发送前端输入的信息给微信关注公众号的用户
// step1:获取token
String wxAppId = SysConfigUtils.get(CommonSystemConfig.class).getWxAppId();
//从redis获取微信公众号的access_token
BasicAccessToken wxAccessToken = redisUtil.getWxAccessToken(wxAppId);
if (ParamUtil.isNullOrEmptyOrZero(wxAccessToken )) {
LeaseException.throwSystemException(LeaseExceEnums.WX_TOKEN_EXCEPTION);
}
String accessToken = wxAccessToken .getAccess_token();
if (ParamUtil.isNullOrEmptyOrZero(accessToken)) {
LeaseException.throwSystemException(LeaseExceEnums.WX_TOKEN_EXCEPTION);
}
logger.info("微信公众号 accessToken:{}", accessToken);
String client_url = CLIENT_SENT_MESSAGE.replace("ACCESS_TOKEN", accessToken);
//step2:通过小程序的openid获取unionid,
String openIdToUnionId = redisService.getOpenIdToUnionId(openid);
//step3:通过unionid获取公众号openid
String wxUnionIdToOpenid = redisUtil.getWxUnionIdToOpenid(openIdToUnionId );
logger.info("小程序用户 openid {},微信公众号 unionid {},微信公众号 openid {}", openid, openIdToUnionId, wxUnionIdToOpenid);
//文本消息,msgType:text
String result = "{\n" +
" \"touser\":\"" + wxUnionIdToOpenid + "\",\n" +
" \"msgtype\":\"text\",\n" +
" \"text\":\n" +
" {\n" +
" \"content\":\"" + content + "\"\n" +
" }\n" +
"}";
logger.info("调用客服接口发送消息给微信公众号的用户 {}", wxUnionIdToOpenid);
//step4:通过微信公众号客服接口发送消息给小程序用户
String responseStr = null;
try {
responseStr = HttpUtil.executePost(client_url, result);
}catch (Exception e){
e.printStackTrace();
}
JSONObject jsonObject = JSONObject.parseObject(responseStr);
Integer errcode = jsonObject.getInteger("errcode");
logger.info("调用微信客服接口发送消息响应 :{}", jsonObject);
//这里后期会将消息体定义一个结构体,统一调用
if (errcode != 0) {
messageObj.put("content", "发送给微信公众号用户消息失败,请重试!");
messageObj.put("msgType",MsgType.SYSTEM_MSG.getCode());
sendMessage(messageObj.toString());
} else {
messageObj.put("content", "发送给微信公众号用户消息成功!");
messageObj.put("msgType",MsgType.SYSTEM_MSG.getCode());
sendMessage(messageObj.toString());
}
}
@OnClose
public void onClose( ) {
//移除当前的websocket对象
socketServers.remove(this);
logger.info("========>【关闭与客户端 {} 的websocket连接】<========", session.getId());
}
@OnError
public void onError(Session session, Throwable error) {
session.getRequestParameterMap();
logger.error("error message: {}", error.getMessage());
logger.error("error error msg:{}", error.toString());
logger.error("========>【客户端 {} websocket 连接 发生未知错误】<========", session.getId());
error.printStackTrace();
}
//发送消息给前端的每一个websocket连接
public static void sendMessage(String message) {
for (SocketServer socketSet : socketServers) {
try {
logger.info("========>【收到微信公众号消息 {},发送给客户端 sessionId {}】<========", message, socketSet.session.getId());
socketSet.session.getAsyncRemote().sendText(message);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
redisService:
//缓存微信公众号发过来的十条消息到redis缓存,防止websocket断开重连的时候接收不到消息
public void cacheFromWxUserMsg(String openid, JSONObject msgObj) {
if (ParamUtil.isNullOrEmptyOrZero(openid) || ParamUtil.isNullOrEmptyOrZero(msgObj)) {
return;
}
//添加在当前元素的右边
redisTemplate.opsForList().rightPush(openid, msgObj);
}
//更新用户的消息内容,超过十条就把最开始的那条删除
public void updateUserMsgFromWx(String openid) {
if (ParamUtil.isNullOrEmptyOrZero(openid)) {
return;
}
//获取配置的消息条数,可在后台修改配置增加或者减少
String cacheUserMsgSize = SysConfigUtils.get(CommonSystemConfig.class).getCacheUserMsgSize();
if (redisTemplate.opsForList().size(openid) > Long.valueOf(cacheUserMsgSize)) {
//弹出list左边的元素,也就是时间最久的消息
redisTemplate.opsForList().leftPop(openid);
}
}
//获取指定openid缓存的消息总数
public List getUserMsgsFromWx(String openid) {
List msgList = redisTemplate.opsForList().range(openid, 0, -1);
if (msgList.size() <= 0) {
return null;
}
return msgList;
}
//在websocket链接成功,发送到web前端以后,销毁消息
public void deleteUserMsgsFromWx(String openid) {
if (ParamUtil.isNullOrEmptyOrZero(openid)) {
return;
}
redisTemplate.delete(openid);
logger.info("============【删除用户 {} 的消息内容缓存】============", openid);
}
WechatController(微信消息处理类):
@Api(description = "微信服务器校验接口")
@RestController
@EnableSwagger2
@RequestMapping("/wx")
@Slf4j
public class WeChatController extends BaseController {
/*
* 微信公众号服务器
*/
@Autowired
private ImService imService;
@Autowired
private RedisUtil redisUtil;
@Autowired
private RedisService redisService;
@Autowired
private UserService userService;
//通过微信公众号openid获取微信公众号UnionId
private static final String GET_WX_UNIONID_URL = "https://api.weixin.qq.com/cgi-bin/user/info?access_token=ACCESS_TOKEN&openid=OPENID&lang=zh_CN";
/**
* 客服发送消息
*/
private static final String CLIENT_SENT_MESSAGE = "https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=ACCESS_TOKEN";
private static final String WX_SEND_TEMPLATE_MSG = "https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=ACCESS_TOKEN";
//微信公众号验证
@IgnoreLogin//无需登录
@GetMapping("/system")
public void doGet(HttpServletRequest request, HttpServletResponse response) throws AesException {
//获取参数值
String signature = request.getParameter("signature");
String timeStamp = request.getParameter("timestamp");
String nonce = request.getParameter("nonce");
String echostr = request.getParameter("echostr");
log.info("微信公众号服务器验证请求:signature {},timestamp {},nonce {},echostr {}", signature, timeStamp, nonce, echostr);
//微信服务器校验的token,可在系统配置
String token = SysConfigUtils.get(CommonSystemConfig.class).getWxToken();
log.info("服务器保存的token:{}", token);
PrintWriter out = null;
try {
out = response.getWriter();
//开发者获得加密后的字符串可与signature对比,标识该请求来源于微信
if (WXPublicUtils.verifyUrl(signature, timeStamp, nonce)) {
out.println(echostr);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
out.close();
}
}
//接收公众号post请求消息,
//接收到的消息直接返回给前端页面
@IgnoreLogin
@PostMapping("/system")
public void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException, DocumentException {
JSONObject messageObj = new JSONObject();
response.setCharacterEncoding("utf-8");
PrintWriter out = null;
//将微信请求xml转为map格式,获取所需的参数
Map<String, String> map = XMLUtil.xml2Map(request);
String ToUserName = map.get("ToUserName");//开发者
String FromUserName = map.get("FromUserName");//微信用户openID
String MsgType = map.get("MsgType");//消息类型
String event = map.get("Event");//事件类型,subscribe :关注事件,unsubscribe:取消事件
String createTime = map.get("CreateTime");//消息的创建时间
BasicAccessToken wxAccessToken= redisUtil.getWxAccessToken(SysConfigUtils.get(CommonSystemConfig.class).getWxAppId());
String accessToken = null;
if (!ParamUtil.isNullOrEmptyOrZero(wxAccessToken)) {
accessToken = wxAccessToken.getAccess_token();
log.info("微信公众号的 accessToken :{}", accessToken);
}
String wxUnionIdUrl = GET_WX_UNIONID_URL.replace("ACCESS_TOKEN", accessToken).replace("OPENID", FromUserName);
String wxUnionIdRes = null;
try {
wxUnionIdRes = HttpClientUtil.httpRequest(wxUnionIdUrl, null, "", HttpClientUtil.MethodType.METHOD_GET);
}catch (Exception e){
e.printStackTrace();
}
log.info("通过微信公众号 openId 获取 unionId响应 :", wxUnionIdRes);
JSONObject jsonObject = JSONObject.parseObject(wxUnionIdRes);
Object unionid = jsonObject.get("unionid");
String unionIdToOpenid = null;
if (!ParamUtil.isNullOrEmptyOrZero(unionid)) {
log.info("通过微信公众号 openId 获取 unionId {}", unionid);
//缓存获取到的unionId与微信公众号openid的关联
redisUtil.cacheWxUnionIdToOpenid(unionid.toString(), FromUserName);
//通过unionid获取缓存里的小程序openid
unionIdToOpenid = redisService.getUnionIdToOpenid(unionid.toString());
}
String message = null;
log.info("map:{}", map);
log.info("----接收微信公众号的消息 end:------");
//判断接收的内容类型
switch (MsgType) {
case "event":
if (event.equals("subscribe")) {
//如果是用户的关注事件,就发送消息到用户进入小程序
String client_url = CLIENT_SENT_MESSAGE.replace("ACCESS_TOKEN", accessToken);
message = SysConfigUtils.get(CommonSystemConfig.class).getMiniProgramSubscribeText();
log.info("小程序用户关注公众号,小程序推送文本:{} ", message);
//文本消息json
HashMap<Object, Object> contentMap = new HashMap<>();
JSONObject msgJson = new JSONObject();
msgJson.put("touser", FromUserName);
msgJson.put("msgtype", "text");
contentMap.put("content", message);
msgJson.put("text", contentMap);
String responseStr = null;
try {
responseStr = HttpClientUtil.httpRequest(client_url, null, msgJson.toString(), HttpClientUtil.MethodType.METHOD_POST);
} catch (Exception e) {
e.printStackTrace();
}
JSONObject sendMsgRes = JSONObject.parseObject(responseStr);
Integer errcode = sendMsgRes.getInteger("errcode");
log.info("用户关注微信公众号,发送小程序文字链给微信公众号的用户 {},响应:{}", FromUserName, responseStr);
if (errcode != 0) {
messageObj.put("content", "发送小程序文字链给微信公众号用户失败,请重试!");
messageObj.put("openid", bshUnionIdToOpenid);
messageObj.put("msgType", com.gizwits.lease.bsenums.MsgType.SYSTEM_MSG.getCode());
SocketServer.sendMessage(messageObj.toString());
} else {
messageObj.put("content", "发送小程序文字链给微信公众号用户成功!");
messageObj.put("openid", bshUnionIdToOpenid);
messageObj.put("msgType", com.gizwits.lease.bsenums.MsgType.SYSTEM_MSG.getCode());
SocketServer.sendMessage(messageObj.toString());
}
}
break;
case "text":
String Content = map.get("Content");//文本内容
String textMsg = Content + bshUnionIdToOpenid;
//发送文本给前端
messageObj.put("openid", bshUnionIdToOpenid);
messageObj.put("content", Content);
messageObj.put("msgType", com.gizwits.lease.bsenums.MsgType.USER_MSG.getCode());
messageObj.put("msgTime",createTime);
User user = userService.selectOne(new EntityWrapper<User>()
.eq("openid", bshUnionIdToOpenid).eq("is_deleted", DeleteStatus.NOT_DELETED.getCode()));
if(!ParamUtil.isNullOrEmptyOrZero(user)){
String username = user.getUsername();
log.info("当前发送消息到服务端的微信用户 :{}", username);
messageObj.put("fromUser",username);
}
//保存微信公众号的openid以及对应的消息,最多十条
redisService.cacheFromWxUserMsg(unionIdToOpenid, messageObj);
//更新缓存里当前用户的消息内容,最多十条
redisService.updateUserMsgFromWx(unionIdToOpenid);
SocketServer.sendMessage(messageObj.toString());
log.info("接收来自微信公众号用户 {} 的消息 {} 发送文本消息给前端展示", unionIdToOpenid, Content);
break;
case "image"://图片
break;
case "voice"://音频
break;
}
}
微信校验
public class WXPublicUtils {
private static String WX_TOKEN = "6FwrtuhdgMiGfe4ZoSjbOyWpE0XINmUk";
/**
* 验证Token
*
* @param msgSignature 签名串,对应URL参数的signature
* @param timeStamp 时间戳,对应URL参数的timestamp
* @param nonce 随机串,对应URL参数的nonce
* @return 是否为安全签名
* @throws AesException 执行失败,请查看该异常的错误码和具体的错误信息
*/
public static boolean verifyUrl(String msgSignature, String timeStamp, String nonce)
throws AesException {
String signature = SHA1.getSHA1(WX_TOKEN, timeStamp, nonce);
if (!signature.equals(msgSignature)) {
throw new AesException(AesException.ValidateSignatureError);
}
return true;
}
}