引言
最近有朋友委托笔者帮忙开发一个简易的聊天系统,简单支持单聊和群聊等功能即可。限于能力也为了节省时间,笔者只好去 gitee 和 github 搜基于Java技术栈的IM通讯系统项目,最后搜到了一个后端基于SpringBoot和Netty技术实现的cim开源项目和另一个后端基于SpringBoot和WebSocket技术,前端基于uniApp和Vue3实现的考拉IM通讯系统项目都做的比较好。虽然笔者本人更喜欢后端采用Netty框架实现的通讯系统,但是奈何cim开源项目大部分功能代码并没有开源出来,很多核心功能都是要收费的,而考拉IM项目则无论后端还是前端大部分功能都开源了出来,于是笔者重点研究了下考拉IM通讯系统的前后端项目源码,并尝试在本地开发环境启动前后端项目。
项目简介
- 考拉IM系统是一个仿照腾讯微信中的通讯功能开发的多端项目,目前已实现支持Androi端 、iOS端 和H5端 ,后期会继续适配
小程序端
、桌面端
(windows、mac)和web端
, 支持单聊、群聊、音视频通话、高德地图定位、搜索附近的人和摇一摇等功能。 - 考拉IM系统采用前后端分离架构模式,前端项目使用uniApp+Vue3开发, 后端项目主要使用SpringBoot+Websocket+Redis+第三方服务等技术栈实现。
- 前端项目源码地址:gitee.com/lakaola/im-...
- 后端项目源码地址:gitee.com/lakaola/im-...
技术栈
- 推送:uniPush + websocket
- 资源:阿里OSS(图片、声音、视频、文件等)
- 音视频:TRTC
- 地图:高德地图
- 短信:阿里云短信
- 后端:Hutool,Mybatis-Plus, Shiro, undertow, sharding-jdbc, 接口版本控制等
- 前端:uniApp + Vue3
后端项目im-platform
1)获取项目源码
使用git将后端项目im-platform从gitee克隆到本地磁盘,克隆完成后使用 IntelliJ IDEA 打开项目并添加Maven依赖。
2)建表与初始化sql脚本
使用root账户登录Mysql数据库客户端Navicat, 然后在命令行控制台中执行建表与添加数据的sql脚本,脚本位置:gitee.com/heshengfu12...
3)修改应用配置文件
修改application-dev.yml
应用配置文件
yaml
spring:
# ShardingSphere 配置项
shardingsphere:
# 数据源配置
datasource:
# 所有数据源的名字
names: master
# 主库的数据源配置
master:
type: com.zaxxer.hikari.HikariDataSource # 使用 Hikari 数据库连接池
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://127.0.0.1:3306/boot_im?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&verifyServerCertificate=false&serverTimezone=GMT%2B8&allowMultiQueries=true
username: boot_im
password: bootim2023admin
# 拓展属性配置
props:
sql:
show: true # 打印 SQL
sharding:
default-data-source-name: master
# 项目相关配置
platform:
# 富文本路径 示例( Windows配置D:/platform/uploadPath,Linux配置 /home/platform/uploadPath)
rootPath: E:/platform/uploadPath
# 系统版本
version: 1.0.0
# 日志地址
logPath: ./logs
# token超时时间(分钟)默认7天
timeout: 10080
# 短信开关(N关Y开)
sms: Y
# oss配置
oss:
serverUrl:
accessKey:
secretKey:
bucketName:
region:
# 实时语音/视频 可接入腾讯或阿里的语音视频服务后获得appId和secret
trtc:
# appId
appId:
# 签名过期时间,单位秒
expire:
# 签名秘钥
secret:
# 推送配置 新建应用并获取配置信息参考文档:https://docs.getui.com/getui/start/devcenter/
push:
appId:
appKey:
appSecret:
masterSecret:
# 腾讯nlp配置
tencent:
appId: 1301260368
appKey: AKID******2nja
appSecret: rb******eB
上面数据库连接池配置只改了jdbc-url, username 与password等变量。
除此之外,我们需要完成一些第三方服务器的访问凭证配置,包括对象云存储OSS配置信息、高德地图密钥配置、腾讯自然语言(NLP)服务访问凭证配置、实时音视频服务(trtc)访问凭证配置等。这些配置信息都需要开通对应的第三方服务拿到对应的访问凭据后才能在项目的配置文件中加以补充,但是它并不影响后台项目的启动。相关的配置项如下,待笔者开通了这些第三方服务并找到了对应的访问凭据信息再来补充这些配置项的value值。
几个重要的配置类
ApplicationConfig
类
java
/**
* 程序注解配置
*/
@Configuration
// 表示通过aop框架暴露该代理对象,AopContext能够访问
@EnableAspectJAutoProxy(exposeProxy = true)
// 指定要扫描的Mapper类的包的路径
@MapperScan({"com.platform.modules.**.dao"})
// 扫描spring工具类
@ComponentScan(basePackages = {"cn.hutool.extra.spring"})
public class ApplicationConfig {
/**
* 时区配置
*/
@Bean
public Jackson2ObjectMapperBuilderCustomizer jacksonObjectMapperCustomization() {
return builder -> builder.timeZone(TimeZone.getDefault());//去系统默认时区
}
/**
* 序列化枚举值为数据库存储值
*
* @return
*/
@Bean
public Jackson2ObjectMapperBuilderCustomizer customizer() {
return builder -> builder.featuresToEnable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING);
}
@Bean
public static MappingJackson2HttpMessageConverter objectMapper() {
final ObjectMapper objectMapper = new ObjectMapper();
// 忽略未知的枚举字段
objectMapper.enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL);
// 忽略多余的字段不参与序列化
objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
// 忽略null属性字段
// objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
// null属性字段转""
objectMapper.getSerializerProvider().setNullValueSerializer(new JsonSerializer<Object>() {
@Override
public void serialize(Object arg0, JsonGenerator arg1, SerializerProvider arg2) throws IOException {
arg1.writeString("");
}
});
SimpleModule simpleModule = new SimpleModule();
// 格式化Long
simpleModule.addSerializer(Long.class, ToStringSerializer.instance);
// 格式化时间
simpleModule.addSerializer(Date.class, new JsonSerializer<Date>() {
@Override
public void serialize(Date date, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
jsonGenerator.writeString(DateUtil.format(date, DatePattern.NORM_DATETIME_FORMAT));
}
});
// 格式化金额
simpleModule.addSerializer(BigDecimal.class, new JsonSerializer<BigDecimal>() {
@Override
public void serialize(BigDecimal decimal, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
jsonGenerator.writeString(decimal.setScale(2, BigDecimal.ROUND_HALF_DOWN).toString());
}
});
// 注册 module
objectMapper.registerModule(simpleModule);
MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
converter.setObjectMapper(objectMapper);
return converter;
}
}
WebsocketConfig
类
java
/*
1、如果使用默认的嵌入式容器 比如Tomcat 则必须手工在上下文提供ServerEndpointExporter。
2、如果使用外部容器部署war包,则不需要提供提供ServerEndpointExporter,因为此时SpringBoot默认将扫描 服务端的行为交给外部容器处理,所以线上部署的时候要把WebSocketConfig中这段注入bean的代码注掉
*/
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Resource
private BootWebSocketHandler bootWebSocketHandler;
@Resource
private BootWebSocketInterceptor bootWebSocketInterceptor;
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
webSocketHandlerRegistry
.addHandler(bootWebSocketHandler, "/ws")
.addInterceptors(bootWebSocketInterceptor)
.setAllowedOrigins("*");
}
}
PlatformConfig
类
java
@Component
@Configuration
@ConfigurationProperties(prefix = "platform")
public class PlatformConfig {
/**
* 上传路径
*/
public static String ROOT_PATH;
/**
* 文件预览
*/
public static String PREVIEW = "/preview/";
/**
* token超时时间(分钟)
*/
public static Integer TIMEOUT;
/**
* 是否开启短信
*/
public static YesOrNoEnum SMS;
@Value("${platform.timeout}")
public void setTokenTimeout(Integer timeout) {
PlatformConfig.TIMEOUT = timeout;
}
@Value("${platform.sms:N}") // 该值为Y表示开启短信服务
public void setSms(String sms) {
PlatformConfig.SMS = EnumUtils.toEnum(YesOrNoEnum.class, sms, YesOrNoEnum.NO);
}
@Value("${platform.rootPath}")
public void setRootPath(String rootPath) {
PlatformConfig.ROOT_PATH = rootPath;
}
}
MybatisPlusConfig
类
java
@EnableTransactionManagement(proxyTargetClass = true)
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 乐观锁插件
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
// 防全表更新与删除插件
interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());
return interceptor;
}
/**
* 批量操作增强
*/
@Bean
public DefaultSqlInjector mybatisSqlInjector() {
return new DefaultSqlInjector();
}
// 源码中使用的mybatis-plus是3.4.3版本,而我们这里它改为了4.4.3版本,可省去一部分代码
}
CorsConfig
跨域配置类
java
@Configuration
public class CorsConfig {
private CorsConfiguration buildConfig() {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.addAllowedOrigin("*");
corsConfiguration.addAllowedHeader("*");
corsConfiguration.addAllowedMethod("*");
corsConfiguration.setAllowCredentials(true);
return corsConfiguration;
}
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", buildConfig());
return new CorsFilter(source);
}
}
该配置类是用于前端向后端访问是由于端口号不同需要支持跨域。
WebMvcConfig
类
java
/**
* 通用配置
*/
@Configuration
public class WebMvcConfig extends WebMvcConfigurationSupport {
@Resource
private VersionInterceptor versionInterceptor;
@Resource
private DeviceInterceptor deviceInterceptor;
@Override
public RequestMappingHandlerMapping createRequestMappingHandlerMapping() {
return new VersionHandlerMapping();
}
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.add(ApplicationConfig.objectMapper());
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
/** 本地文件上传路径 */
// registry.addResourceHandler(PlatformConfig.PREVIEW + "/**").addResourceLocations("file:" + PlatformConfig.ROOT_PATH + "/");
}
/**
* 自定义拦截规则
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(versionInterceptor).addPathPatterns("/**");
registry.addInterceptor(deviceInterceptor).addPathPatterns("/**");
}
}
ShiroConfiguration
类
java
/**
* ShiroConfiguration
*/
@Configuration
public class ShiroConfiguration {
/**
* 下面两个方法对 注解权限起作用有很大的关系,请把这两个方法,放在配置的最上面
*/
@Bean(name = "lifecycleBeanPostProcessor")
public static LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
/**
* 身份认证Realm,此处的注入不可以缺少。否则会在UserRealm中注入对象会报空指针.
* 将自己的验证方式加入容器
*/
@Bean
public ShiroRealm shiroRealm() {
ShiroRealm realm = new ShiroRealm();
realm.setCredentialsMatcher(hashedCredentialsMatcher());
return realm;
}
/**
* 配置shiro session 的一个管理器
*/
@Bean
public DefaultWebSessionManager sessionManager() {
DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
sessionManager.setSessionListeners(new ArrayList<>());
return sessionManager;
}
/**
* 核心的安全事务管理器
* 设置realm、cacheManager等
*/
@Bean
public SecurityManager securityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
//设置realm
securityManager.setRealm(shiroRealm());
// 设置sessionManager
securityManager.setSessionManager(sessionManager());
return securityManager;
}
/**
* 开启shiro aop注解支持.
* 使用代理方式;所以需要开启代码支持;否则@RequiresRoles等注解无法生效
*/
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}
/**
* 哈希密码比较器。在myShiroRealm中作用参数使用
* 登陆时会比较用户输入的密码,跟数据库密码配合盐值salt解密后是否一致。
*/
@Bean
public HashedCredentialsMatcher hashedCredentialsMatcher() {
HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
hashedCredentialsMatcher.setHashAlgorithmName("md5");//散列算法:这里使用md5算法;
hashedCredentialsMatcher.setHashIterations(1);//散列的次数,比如散列两次,相当于 md5( md5(""));
return hashedCredentialsMatcher;
}
@Bean
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
//设置安全管理器
shiroFilterFactoryBean.setSecurityManager(securityManager);
//oauth过滤
Map<String, Filter> filters = new HashMap<>(16);
filters.put("oauth2", new ShiroTokenFilter());
shiroFilterFactoryBean.setFilters(filters);
//权限控制map
Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
// 自定义拦截全部写到下面↓↓↓
// 免登录接口,增加@IgnoreAuth注解
// 自定义拦截全部写到上面↑↑↑
filterChainDefinitionMap.put("/**", "oauth2");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return shiroFilterFactoryBean;
}
}
该配置类主要用于控制系统安全访问与授权,这个类需要关注ShiroRealm
类。ShiroRealm#doGetAuthenticationInfo
方法为获取当前登录用户认证信息, ShiroRealm#isPermitted
方法用于校验当前登录用户是否有某项操作权限。
几个重要的属性配置类
OssConfig
类源码
java
@Component
@Data
public class OssConfig {
@Value("${oss.serverUrl}")
private String serverUrl;
@Value("${oss.accessKey}")
private String accessKey;
@Value("${oss.secretKey}")
private String secretKey;
@Value("${oss.bucketName}")
private String bucketName;
@Value("${oss.region}")
private String region;
}
PushConfig
类源码
java
/**
* 推送配置
*/
@Component
@Data
public class PushConfig {
@Value("${push.appId}")
private String appId;
@Value("${push.appKey}")
private String appKey;
@Value("${push.appSecret}")
private String appSecret;
@Value("${push.masterSecret}")
private String masterSecret;
}
TrtcConfig
类源码
java
/**
* 读取trtc相关配置
*/
@Component
@Data
public class TrtcConfig {
@Value("${trtc.appId}")
private String appId;
@Value("${trtc.expire}")
private String expire;
@Value("${trtc.secret}")
private String secret;
}
TencentConfig
类源码
java
/**
* 腾讯nlp配置
*/
@Component
@Data
public class TencentConfig {
@Value("${tencent.appId}")
private String appId;
@Value("${tencent.appKey}")
private String appKey;
@Value("${tencent.appSecret}")
private String appSecret;
}
SmsConfig
配置类源码
java
@Configuration
public class SmsConfig {
@Bean
public SmsClient smsClient(TencentConfig tencentConfig){
Credential cred = new Credential(tencentConfig.getAppKey(), tencentConfig.getAppSecret());
HttpProfile httpProfile = new HttpProfile();
httpProfile.setReqMethod("POST");
httpProfile.setConnTimeout(60);
httpProfile.setEndpoint("sms.tencentcloudapi.com");
ClientProfile clientProfile = new ClientProfile();
clientProfile.setSignMethod("HmacSHA256");
clientProfile.setHttpProfile(httpProfile);
SmsClient smsClient = new SmsClient(cred, "ap-guangzhou",clientProfile);
return smsClient;
}
}
AmapConfig
类源码
java
/**
* 读取高德地图相关配置
*/
@Component
@Data
public class AmapConfig {
@Value("${amap.key}")
private String key;
}
这几个类在application-dev.yml
文件中都有其对应的配置变量,在IntelliJ IDEA
中打开对应的属性配置类,然后按住Ctrl
键+鼠标单击对应的属性配置类就可以看到该类在那些服务类或工具类中被用到。
几个重要的服务类
文件服务类
文件服务接口类FileService
源码
java
/**
* 文件服务
*/
public interface FileService {
/**
* 普通文件上传
*
* @param file
* @return
*/
UploadFileVo uploadFile(MultipartFile file);
/**
* 视频文件上传
*
* @param file
* @return
*/
UploadVideoVo uploadVideo(MultipartFile file);
/**
* 音频文件上传
*
* @param file
* @return
*/
UploadAudioVo uploadAudio(MultipartFile file);
}
文件服务实现类FileServiceImpl
源码
java
@Service("fileService")
public class FileServiceImpl implements FileService {
@Resource
private OssConfig ossConfig;
@Resource
private TencentConfig tencentConfig;
@Override
public UploadFileVo uploadFile(MultipartFile file) {
String fileType = FileNameUtil.extName(file.getOriginalFilename());
if ("webp".equalsIgnoreCase(fileType)) {
throw new BaseException(StrUtil.format("暂不支持{}格式上传", fileType));
}
// 初始化
BaseUtils.init(BeanUtil.toBean(ossConfig, UploadConfig.class));
// 上传
return OssUtils.uploadFile(file);
}
@Override
public UploadVideoVo uploadVideo(MultipartFile videoFile) {
// 初始化
BaseUtils.init(BeanUtil.toBean(ossConfig, UploadConfig.class));
// 上传视频文件
UploadFileVo videoFileVo = OssUtils.uploadFile(videoFile);
return BeanUtil.toBean(videoFileVo, UploadVideoVo.class)
.setScreenShot(videoFileVo.getFullPath() + AppConstants.VIDEO_PARAM);
}
@Override
public UploadAudioVo uploadAudio(MultipartFile audioFile) {
// 初始化
BaseUtils.init(BeanUtil.toBean(ossConfig, UploadConfig.class));
// 上传音频文件
UploadFileVo audioFileVo = OssUtils.uploadFile(audioFile);
String data;
try {
data = Base64.encode(audioFile.getInputStream());// 字节流编码
} catch (IOException e) {
throw new BaseException("语音识别接口调用异常,请稍后再试");
}
return BeanUtil.toBean(audioFileVo, UploadAudioVo.class).setSourceText(TencentUtils.audio2Text(tencentConfig, data)); // 音频转文本
}
}
实时音视频服务
实时音视频接口类TrtcService
java
/**
* 实时语音/视频
*/
public interface TrtcService {
/**
* 实时语音/视频
*/
TrtcVo getSign();
}
实时音视频接口实现类TrtcServiceImpl
java
@Service("trtcService")
public class TrtcServiceImpl implements TrtcService {
@Resource
private TrtcConfig trtcConfig;
@Resource
private RedisUtils redisUtils;
@Override
public TrtcVo getSign() {
String key = AppConstants.REDIS_TRTC_SIGN + ShiroUtils.getUserId();
if (redisUtils.hasKey(key)) {
return JSONUtil.toBean(redisUtils.get(key), TrtcVo.class);
}
String userId = AppConstants.REDIS_TRTC_USER + ShiroUtils.getUserId();
long currTime = DateUtil.currentSeconds();
Dict doc = Dict.create()
.set("TLS.ver", "2.0")
.set("TLS.identifier", userId)
.set("TLS.sdkappid", trtcConfig.getAppId())
.set("TLS.expire", trtcConfig.getExpire())
.set("TLS.time", currTime)
.set("TLS.sig", hmacsha256(userId, currTime));
Deflater compressor = new Deflater();
compressor.setInput(JSONUtil.toJsonStr(doc).getBytes(StandardCharsets.UTF_8));
compressor.finish();
byte[] bytes = new byte[2048];
int length = compressor.deflate(bytes);
compressor.end();
TrtcVo trtcVo = new TrtcVo().setUserId(userId)
.setAppId(trtcConfig.getAppId())
.setExpire(trtcConfig.getExpire())
.setSign(base64EncodeUrl(ArrayUtil.resize(bytes, length)));
redisUtils.set(key, JSONUtil.toJsonStr(trtcVo), 5, TimeUnit.DAYS);
return trtcVo;
}
private String hmacsha256(String userId, long currTime) {
String contentToBeSigned = "TLS.identifier:" + userId + "\n"
+ "TLS.sdkappid:" + trtcConfig.getAppId() + "\n"
+ "TLS.time:" + currTime + "\n"
+ "TLS.expire:" + trtcConfig.getExpire() + "\n";
HMac mac = new HMac(HmacAlgorithm.HmacSHA256, StrUtil.bytes(trtcConfig.getSecret(), StandardCharsets.UTF_8));
byte[] signed = mac.digest(contentToBeSigned);
return cn.hutool.core.codec.Base64.encode(signed);
}
private String base64EncodeUrl(byte[] input) {
byte[] base64 = Base64.encode(input).getBytes(StandardCharsets.UTF_8);
for (int i = 0; i < base64.length; ++i)
switch (base64[i]) {
case '+':
base64[i] = '*';
break;
case '/':
base64[i] = '-';
break;
case '=':
base64[i] = '_';
break;
default:
break;
}
return new String(base64);
}
}
短信服务接口类SmsService
java
/**
* 短信 服务层
*/
public interface SmsService {
/**
* 发送短信
* @return
*/
Dict sendSms(SmsVo smsVo);
/**
* 验证短信
* @return
*/
void verifySms(String phone, String code, SmsTypeEnum type);
}
短信服务接口实现类SmsServiceImpl
java
/**
* 短信服务类
*/
@Service("smsService")
@Slf4j
public class SmsServiceImpl implements SmsService {
@Resource
private RedisUtils redisUtils;
@Resource
private SmsClient smsClient;
@Value("${tencent.appId}")
private String sdkAppId;
@Override
public Dict sendSms(SmsVo smsVo) {
// 验证手机号
if (!Validator.isMobile(smsVo.getPhone())) {
throw new BaseException("请输入正确的手机号");
}
SmsTypeEnum smsType = smsVo.getType();
String key = smsType.getPrefix().concat(smsVo.getPhone());
// 生成验证码
String code = String.valueOf(RandomUtil.randomInt(1000, 9999));
// 发送短信
if (YesOrNoEnum.YES.equals(PlatformConfig.SMS)) {
Dict dict = Dict.create()
.set("code", code);
SmsTemplateEnum templateEnum;
if(SmsTypeEnum.LOGIN.getCode().equals(smsVo.getType().getCode())){
templateEnum = SmsTemplateEnum.LOGIN_VERIFY_CODE;
}else if(SmsTypeEnum.REGISTERED.getCode().equals(smsVo.getType().getCode())){
templateEnum = SmsTemplateEnum.REGISTER_VERIFY_CODE;
} else {
templateEnum = SmsTemplateEnum.RESET_PASS_VERIFY_CODE;
}
doSendSms(smsVo.getPhone(), templateEnum, dict);
}
// 存入缓存
redisUtils.set(key, code, smsType.getTimeout(), TimeUnit.MINUTES);
return Dict.create().set("expiration", smsType.getTimeout());
}
@Override
public void verifySms(String phone, String code, SmsTypeEnum type) {
// 验证手机号
if (!Validator.isMobile(phone)) {
throw new BaseException("请输入正确的手机号");
}
String key = type.getPrefix().concat(phone);
if (!redisUtils.hasKey(key)) {
throw new BaseException("验证码已过期,请重新获取");
}
String value = redisUtils.get(key);
if (value.equalsIgnoreCase(code)) {
redisUtils.delete(key);
} else {
throw new BaseException("验证码不正确,请重新获取");
}
}
/**
* 执行发送短信
*
* @param phone
* @param templateCode
* @param dict
*/
private void doSendSms(String phone, SmsTemplateEnum templateCode, Dict dict) {
SendSmsRequest req = new SendSmsRequest();
req.setSmsSdkAppId(sdkAppId);
String signName = "你申请的短信签名";
req.setSignName(signName);
req.setTemplateId(templateCode.getCode());
String[] templateParamSet = {dict.getStr("code"), dict.getStr("timeout")};
req.setTemplateParamSet(templateParamSet);
if(!phone.startsWith("+86")){
phone = "+86" + phone;
}
String[] phoneNumberSet = {phone};
req.setPhoneNumberSet(phoneNumberSet);
try {
SendSmsResponse res = smsClient.SendSms(req);
log.info("doSendSms_res:"+ JSONUtil.toJsonStr(res));
} catch (TencentCloudSDKException e) {
log.error("doSendSms_error", e);
}
}
}
这个类笔者作了一些修改,并接入了腾讯云的短信服务,实现了发送短信功能
聊天消息推送服务接口类ChatPushService
java
/**
* 用户推送 服务层
* q3z3
*/
public interface ChatPushService {
/**
* 注册别名
*/
void setAlias(Long userId, String cid);
/**
* 解除别名
*/
void delAlias(Long userId, String cid);
/**
* 发送消息
*/
void pushMsg(PushParamVo from, PushMsgTypeEnum msgType);
/**
* 发送消息
*/
void pushMsg(List<PushParamVo> userList, PushMsgTypeEnum msgType);
/**
* 发送消息
*/
void pushMsg(List<PushParamVo> userList, PushParamVo group, PushMsgTypeEnum msgType);
/**
* 拉取离线消息
*/
void pullOffLine(Long userId);
/**
* 发送通知
*/
void pushNotice(PushParamVo paramVo, PushNoticeTypeEnum pushNoticeType);
/**
* 发送通知
*/
void pushNotice(List<PushParamVo> userList, PushNoticeTypeEnum pushNoticeType);
}
聊天消息推送服务接口实现类ChatPushServiceImpl
java
@Service("chatPushService")
@Slf4j
public class ChatPushServiceImpl implements ChatPushService {
@Resource
private RedisUtils redisUtils;
@Resource
private PushConfig pushConfig;
@Resource
private BootWebSocketHandler bootWebSocketHandler;
/**
* 消息长度
*/
private static final Integer MSG_LENGTH = 2048;
@Override
public void setAlias(Long userId, String cid) {
// 异步注册
PushTokenDto pushTokenDto = initPushToken();
ThreadUtil.execAsync(() -> {
PushAliasVo aliasVo = new PushAliasVo()
.setCid(cid)
.setAlias(NumberUtil.toStr(userId));
PushUtils.setAlias(pushTokenDto, aliasVo);
});
}
@Override
public void delAlias(Long userId, String cid) {
// 异步注册
PushTokenDto pushTokenDto = initPushToken();
ThreadUtil.execAsync(() -> {
PushAliasVo aliasVo = new PushAliasVo()
.setCid(cid)
.setAlias(NumberUtil.toStr(userId));
PushUtils.delAlias(pushTokenDto, aliasVo);
});
}
@Override
public void pushMsg(PushParamVo from, PushMsgTypeEnum msgType) {
PushTokenDto pushTokenDto = initPushToken();
// 异步发送
ThreadUtil.execAsync(() -> {
doMsg(from, null, msgType, pushTokenDto);
});
}
@Override
public void pushMsg(List<PushParamVo> userList, PushMsgTypeEnum msgType) {
PushTokenDto pushTokenDto = initPushToken();
// 异步发送
ThreadUtil.execAsync(() -> {
userList.forEach(e -> {
doMsg(e, e, msgType, pushTokenDto);
});
});
}
@Override
public void pushMsg(List<PushParamVo> userList, PushParamVo group, PushMsgTypeEnum msgType) {
PushTokenDto pushTokenDto = initPushToken();
// 异步发送
ThreadUtil.execAsync(() -> {
userList.forEach(e -> {
doMsg(e, group, msgType, pushTokenDto);
});
});
}
/**
* 发送消息
*/
private void doMsg(PushParamVo from, PushParamVo to, PushMsgTypeEnum msgType, PushTokenDto pushTokenDto) {
Long userId = from.getToId();
// 组装消息体
PushMsgVo pushMsgVo = new PushMsgVo()
.setMsgType(msgType.getCode())
.setContent(from.getContent());
YesOrNoEnum top = from.getTop();
if (top != null) {
pushMsgVo.setTop(top.getCode());
}
YesOrNoEnum disturb = from.getDisturb();
if (disturb != null) {
pushMsgVo.setDisturb(disturb.getCode());
}
Long msgId = from.getMsgId();
if (msgId == null) {
msgId = SnowflakeUtils.getNextId();
}
PushBodyVo pushBodyVo = new PushBodyVo(msgId, PushBodyTypeEnum.MSG, pushMsgVo);
// 发送人
pushBodyVo.setFromInfo(BeanUtil.toBean(from, PushFromVo.class).setUserType(from.getUserType().getCode()));
// 接收人
if (to != null) {
pushBodyVo.setGroupInfo(BeanUtil.toBean(to, PushToVo.class));
}
PushMsgDto pushMsgDto = initTransmission(pushBodyVo);
// 验证消息长度
if (StrUtil.length(from.getContent()) > MSG_LENGTH) {
// 组装消息体
PushMsgDto pushBigDto = initTransmission(new PushBodyVo(msgId, PushBodyTypeEnum.BIG, new PushBigVo().setContent(String.valueOf(msgId))));
// 发送消息
push(userId, pushBigDto, pushTokenDto);
// 存离线消息
String key = AppConstants.REDIS_MSG_BIG + msgId;
redisUtils.set(key, JSONUtil.toJsonStr(pushBodyVo), AppConstants.REDIS_MSG_TIME, TimeUnit.DAYS);
return;
}
// 发送消息
push(userId, pushMsgDto, pushTokenDto);
}
@Override
public void pullOffLine(Long userId) {
// 异步执行
ThreadUtil.execAsync(() -> {
String key = makeMsgKey(userId);
Long size = redisUtils.lLen(key);
if (size.longValue() == 0) {
return;
}
PushTokenDto pushTokenDto = initPushToken();
for (int i = 0; i < size; i++) {
String json = redisUtils.lLeftPop(key);
PushMsgDto pushMsgDto = JSONUtil.toBean(json, PushMsgDto.class);
// 发送消息
push(userId, pushMsgDto, pushTokenDto);
}
redisUtils.delete(key);
});
}
@Override
public void pushNotice(PushParamVo paramVo, PushNoticeTypeEnum pushNoticeType) {
this.pushNotice(Arrays.asList(paramVo), pushNoticeType);
}
@Override
public void pushNotice(List<PushParamVo> userList, PushNoticeTypeEnum pushNoticeType) {
PushTokenDto pushTokenDto = initPushToken();
// 异步发送
ThreadUtil.execAsync(() -> {
userList.forEach(e -> {
this.doNotice(e.getToId(), e, pushTokenDto, pushNoticeType);
});
});
}
/**
* 发送通知
*/
private void doNotice(Long userId, PushParamVo paramVo, PushTokenDto pushTokenDto, PushNoticeTypeEnum pushNoticeType) {
// 组装消息体
PushNoticeVo pushNoticeVo = new PushNoticeVo();
switch (pushNoticeType) {
case TOPIC_RED:
pushNoticeVo.setTopicRed(Dict.create().set("portrait", paramVo.getPortrait()));
break;
case TOPIC_REPLY:
Long topicCount = redisUtils.increment(AppConstants.REDIS_TOPIC_NOTICE + userId, 1);
pushNoticeVo.setTopicReply(Dict.create().set("count", topicCount).set("portrait", paramVo.getPortrait()));
break;
case FRIEND_APPLY:
Long applyCount = redisUtils.increment(AppConstants.REDIS_FRIEND_NOTICE + userId, 1);
pushNoticeVo.setFriendApply(Dict.create().set("count", applyCount));
break;
}
Long msgId = SnowflakeUtils.getNextId();
PushBodyVo pushBodyVo = new PushBodyVo(msgId, PushBodyTypeEnum.NOTICE, pushNoticeVo);
PushMsgDto pushMsgDto = initTransmission(pushBodyVo);
// 发送消息
push(userId, pushMsgDto, pushTokenDto);
}
/**
* 存储离线消息
*/
private void setOffLineMsg(Long userId, PushMsgDto pushMsgDto) {
String key = makeMsgKey(userId);
redisUtils.lRightPush(key, JSONUtil.toJsonStr(pushMsgDto));
redisUtils.expire(key, AppConstants.REDIS_MSG_TIME, TimeUnit.DAYS);
}
/**
* 组装消息前缀
*/
private String makeMsgKey(Long userId) {
return AppConstants.REDIS_MSG + userId;
}
/**
* 组装透传消息
*/
private PushMsgDto initTransmission(PushBodyVo pushBodyVo) {
return new PushMsgDto().setTransmission(Dict.create().parseBean(pushBodyVo));
}
/**
* 初始化token
*/
private PushTokenDto initPushToken() {
String key = AppConstants.REDIS_PUSH_TOKEN + pushConfig.getAppId();
PushTokenDto pushTokenDto;
if (redisUtils.hasKey(key)) {
String json = redisUtils.get(key);
pushTokenDto = JSONUtil.toBean(json, PushTokenDto.class);
} else {
pushTokenDto = PushUtils.createToken(pushConfig);
redisUtils.set(key, JSONUtil.toJsonStr(pushTokenDto), 1, TimeUnit.HOURS);
}
return pushTokenDto;
}
/**
* 推送
*/
private void push(Long userId, PushMsgDto pushMsgDto, PushTokenDto pushTokenDto) {
// 发送推送消息
PushResultVo pushResult1 = PushUtils.pushAlias(userId, pushMsgDto, pushTokenDto);
// 发送ws消息
PushResultVo pushResult2 = bootWebSocketHandler.sendMsg(userId, pushMsgDto.getTransmission());
if (pushResult1.isResult() && pushResult1.isOnline()) {
return;
}
if (pushResult2.isResult() && pushResult2.isOnline()) {
return;
}
// 设置离线消息
setOffLineMsg(userId, pushMsgDto);
}
}
几个重要的工具类
TencentUtils
语音识别工具类
java
/**
* 腾讯工具类
*/
public class TencentUtils {
/**
* 腾讯翻译
*/
public static String translation(TencentConfig tencentConfig, String content) {
String target = "zh";
String source = "auto";
if (ReUtil.contains("[\\u4e00-\\u9fa5]", content)) {
target = "en";
source = "zh";
}
Credential cred = new Credential(tencentConfig.getAppKey(), tencentConfig.getAppSecret());
HttpProfile httpProfile = new HttpProfile();
httpProfile.setEndpoint("tmt.tencentcloudapi.com");
// 实例化一个client选项,可选的,没有特殊需求可以跳过
ClientProfile clientProfile = new ClientProfile();
clientProfile.setHttpProfile(httpProfile);
// 实例化要请求产品的client对象,clientProfile是可选的
TmtClient client = new TmtClient(cred, "ap-beijing", clientProfile);
// 实例化一个请求对象,每个接口都会对应一个request对象
TextTranslateRequest req = new TextTranslateRequest();
req.setSourceText(content);
req.setSource(source);
req.setTarget(target);
req.setProjectId(0L);
TextTranslateResponse resp;
try {
resp = client.TextTranslate(req);
} catch (TencentCloudSDKException e) {
throw new BaseException("翻译机器人接口调用异常,请稍后再试");
}
StringBuilder builder = new StringBuilder();
builder.append("翻译结果:");
builder.append("\n");
builder.append("原文:");
builder.append(content);
builder.append("\n");
builder.append("译文:");
builder.append(resp.getTargetText());
return builder.toString();
}
/**
* 图灵机器人调用
*/
public static String turing(TencentConfig tencentConfig, Long userId, String content) {
try {
Credential cred = new Credential(tencentConfig.getAppKey(), tencentConfig.getAppSecret());
// 实例化一个http选项,可选的,没有特殊需求可以跳过
HttpProfile httpProfile = new HttpProfile();
httpProfile.setEndpoint("nlp.tencentcloudapi.com");
// 实例化一个client选项,可选的,没有特殊需求可以跳过
ClientProfile clientProfile = new ClientProfile();
clientProfile.setHttpProfile(httpProfile);
// 实例化要请求产品的client对象,clientProfile是可选的
NlpClient client = new NlpClient(cred, "ap-guangzhou", clientProfile);
// 实例化一个请求对象,每个接口都会对应一个request对象
ChatBotRequest req = new ChatBotRequest();
req.setOpenId(NumberUtil.toStr(userId));
req.setQuery(content);
// 返回的resp是一个ChatBotResponse的实例,与请求对象对应
ChatBotResponse resp = client.ChatBot(req);
// 输出json格式的字符串回包
return resp.getReply();
} catch (Exception e) {
throw new BaseException("图灵机器人接口调用异常,请稍后再试");
}
}
/**
* 语音识别
*/
public static String audio2Text(TencentConfig tencentConfig, String content) {
try {
Credential cred = new Credential(tencentConfig.getAppKey(), tencentConfig.getAppSecret());
ClientProfile clientProfile = new ClientProfile();
HttpProfile httpProfile = new HttpProfile();
httpProfile.setEndpoint("tmt.tencentcloudapi.com");
clientProfile.setHttpProfile(httpProfile);
// 实例化要请求产品的client对象,clientProfile是可选的
TmtClient client = new TmtClient(cred, "ap-beijing", clientProfile);
// 实例化一个请求对象,每个接口都会对应一个request对象
SpeechTranslateRequest req = new SpeechTranslateRequest();
req.setSessionUuid("1");
req.setSource("zh");
req.setTarget("zh");
req.setAudioFormat(83886080L);
req.setSeq(0L);
req.setIsEnd(1L);
req.setData(content);
SpeechTranslateResponse resp = client.SpeechTranslate(req);
return resp.getSourceText();
} catch (Exception e) {
throw new BaseException("语音识别接口调用异常,请稍后再试");
}
}
}
PushUtils
消息推送工具类
java
/**
* 推送服务
*/
@Slf4j
public class PushUtils {
/**
* 基础URL
*/
private static final String BASE_URL = "https://restapi.getui.com/v2/$appId/";
/**
* 鉴权URL
*/
private static final String AUTH_URL = BASE_URL + "auth";
/**
* 单推alias推送
*/
private static final String PUSH_ALIAS_URL = BASE_URL + "push/single/alias";
/**
* 用户别名
*/
private static final String USER_ALIAS = BASE_URL + "user/alias";
/**
* 常量
*/
private static final String APP_ID = "$appId";
/**
* 超时时间
*/
private static final Integer TIMEOUT = 5000;
/**
* TTL 3 * 86400000 (普通账号3天,VIP7天)
*/
private static final Long TTL = 259200000L;
/**
* 获取鉴权token,获得返回值之后,应存入缓存
*/
public static PushTokenDto createToken(PushConfig pushConfig) {
String url = formatUrl(pushConfig.getAppId(), AUTH_URL);
String timestamp = String.valueOf(DateUtil.current());
String sign = SecureUtil.sha256(pushConfig.getAppKey() + timestamp + pushConfig.getMasterSecret());
Dict dict = Dict.create()
.set("sign", sign)
.set("timestamp", timestamp)
.set("appkey", pushConfig.getAppKey());
String jsonStr = HttpUtil.post(url, JSONUtil.toJsonStr(dict), TIMEOUT);
log.info(jsonStr);
JSONObject data = JSONUtil.parseObj(jsonStr).getJSONObject("data");
return JSONUtil.toBean(data, PushTokenDto.class).setAppId(pushConfig.getAppId());
}
/**
* 设置alias
*/
public static void setAlias(PushTokenDto pushTokenDto, PushAliasVo aliasVo) {
setAlias(pushTokenDto, Arrays.asList(aliasVo));
}
/**
* 设置alias
*/
public static void setAlias(PushTokenDto pushTokenDto, List<PushAliasVo> list) {
String url = formatUrl(pushTokenDto.getAppId(), USER_ALIAS);
String body = JSONUtil.toJsonStr(Dict.create().set("data_list", list));
String jsonStr = HttpUtil.createPost(url)
.body(body)
.setReadTimeout(TIMEOUT)
.header("token", pushTokenDto.getToken())
.execute()
.body();
log.info(jsonStr);
}
/**
* 解除alias
*/
public static void delAlias(PushTokenDto pushTokenDto, PushAliasVo aliasVo) {
delAlias(pushTokenDto, Arrays.asList(aliasVo));
}
/**
* 解除alias
*/
public static void delAlias(PushTokenDto pushTokenDto, List<PushAliasVo> list) {
String url = formatUrl(pushTokenDto.getAppId(), USER_ALIAS);
String body = JSONUtil.toJsonStr(Dict.create().set("data_list", list));
String jsonStr = HttpUtil.createRequest(Method.DELETE, url)
.body(body)
.setReadTimeout(TIMEOUT)
.header("token", pushTokenDto.getToken())
.execute()
.body();
log.info(jsonStr);
}
/**
* 单推alias
*/
public static PushResultVo pushAlias(Long userId, PushMsgDto pushMsgDto, PushTokenDto pushTokenDto) {
String url = formatUrl(pushTokenDto.getAppId(), PUSH_ALIAS_URL);
String body = JSONUtil.toJsonStr(initBody(pushMsgDto, userId));
String jsonStr = HttpUtil.createPost(url)
.header("token", pushTokenDto.getToken())
.body(body)
.timeout(TIMEOUT)
.execute()
.body();
log.info(jsonStr);
boolean result = 0 == JSONUtil.parseObj(jsonStr).getInt("code");
boolean online = jsonStr.contains("online");
return new PushResultVo().setResult(result).setOnline(online);
}
/**
* 组装推送对象
*/
private static Dict initBody(PushMsgDto pushMsgDto, Long userId) {
Dict message = Dict.create();
// 通知
if (pushMsgDto.getTransmission() == null) {
message.set("notification", pushMsgDto.setClick_type(pushMsgDto.getClickType().getCode()));
}
// 透传
else {
message.set("transmission", JSONUtil.toJsonStr(pushMsgDto.getTransmission()));
}
Dict dict = Dict.create()
.set("request_id", IdUtil.objectId())
.set("settings", Dict.create().set("ttl", TTL))
.set("audience", Dict.create().set("alias", Arrays.asList(userId)))
.set("push_message", message);
return dict;
}
/**
* 格式化url
*/
private static String formatUrl(String appId, String url) {
return url.replace(APP_ID, appId);
}
}
测试后端服务
为了控制内容篇幅,本文就只介绍后端部分,前端uniApp项目部分下一篇文章我再继续介绍并演示前后端交互后的效果。首先启动我们本地的Mysql和Redis服务,然后启动AppStartUp
类中的main
函数启动后台服务,控制台出现如下日志说明我们的后台服务启动成功
bash
2023-08-20 22:07:45 [main] INFO com.platform.AppStartUp
- Started AppStartUp in 29.316 seconds (JVM running for 35.849)
(♥◠‿◠)ノ゙ 启动成功 ლ(´ڡ`ლ)゙
测试获取手机验证码接口
在postman中调用发生验证码接口
json
post http://localhost:8080/auth/sendCode
Headers: {
"contentType": "application/json",
"version": "1.0.0",
"device": "Android"
}
// 注意由于MVC拦截器需要校验请求头参数version 和 device,详见VersionInterceptor 和DeviceInterceptor
Body: {
"phone": "18682244076",
"type": "2" // 2 代表获取登录验证码,若是获取注册验证码该参数为1
}
接口响应信息
json
{
"msg": "验证码已发送",
"code": 200,
"data": {
"expiration": 5 // 验证码失效时间,单位分钟
}
}
测试通过手机验证码登录接口
json
post http://localhost:8080/auth/loginByCode
Headers: {
"contentType": "application/json",
"version": "1.0.0",
"device": "Android"
}
Body: {
"phone": "18682244076",
"code": "564897" // 手机收到的验证码,需要在5分钟内登录,否则验证码会失效
}
接口响应信息
json
{
"msg": "操作成功",
"code": 200,
"data": {
"token": "5dwm524muyw130c2hz7jdc3hmjbvhq1j"
}
}
登录成功后会返回一个token, 在调用其他接口时需要请求头的Authorization
参数里带上这个token才能调得通,因为ShiroTokenFilter
对非忽略鉴权的请求都作了检验,该请求头参数不能为空。
测试用户注册接口
若用户尚未注册,可先在postman中调用注册接口
先调用发送注册验证码接口
json
post http://localhost:8080/auth/sendCode
Headers: {
"contentType": "application/json",
"version": "1.0.0",
"device": "Android"
}
Body {
"phone": "18620359579",
"type": "1"
}
发生注册验证码接口返回
json
{
"msg": "验证码已发送",
"code": 200,
"data": {
"expiration": 5 // 验证码失效时间,单位分钟
}
}
然后调用注册接口
json
post http://localhost:8080/auth/register
Headers: {
"contentType": "application/json",
"version": "1.0.0",
"device": "Android"
}
Body: {
"phone": "18620359579",
"password": "ZHANGsan2023",
"code": "336061",
"nickName": "张三"
}
注册接口返回信息
json
{
"msg": "注册成功,请登录",
"code": 200
}
现在我们在MySql数据库客户端中执行查询chat_user表,可以看到新增了一条用户为张三的数据
sql
use boot_im;
select * from chat_user;
好了,本文就介绍到这里,下一篇文章我们继续介绍这个即时通信系统的前端项目im-uniapp
,并实现好友互发消息的功能。
推荐阅读
【1】手把手带你在集成SpringSecurity的SpringBoot应用中添加短信验证码登录认证功能