当前位置:   article > 正文

Spring boot接入腾讯云IM实现通信功能

Spring boot接入腾讯云IM实现通信功能

前言

最近有一个需求是在小程序上开发一个在线聊天的功能,调研了一下觉得腾讯云的IM服务比较合适。

IM介绍

简介

腾讯云IM与应用之间的交互逻辑如图所示
在这里插入图片描述

架构

IM 提供全球接入、单聊、群聊、消息推送、资料关系链托管、账号鉴权等全方位解决方案,并提供完备的 App 接入、后台管理接口。
在这里插入图片描述

应用场景

包括了社交沟通,互动直播,智能客服,物联网通信、企业通讯等等。

IM服务端接入(spring boot)

接口类

用的是Feign接入

@FeignClient(name = "tencentIMService", url = "${application.tencent-im.request-url}",
        configuration = OpenFeignConfig.class, fallback = TencentIMServiceFallback.class)
public interface TencentIMService {


    /**
     * 查询账号
     *
     * @param sdkappid    sdkApId
     * @param identifier  必须为 App 管理员账号
     * @param contenttype 请求格式固定值为json
     * @param usersig     App 管理员账号生成的签名
     * @param random      请输入随机的32位无符号整数,取值范围0 - 4294967295
     * @param accounts    用户列表
     * @return 响应体
     */
    @PostMapping("/v4/im_open_login_svc/account_check")
    JSONObject checkAccount(@RequestParam("sdkappid") Long sdkappid, @RequestParam("identifier") String identifier, @RequestParam("contenttype") String contenttype,
                            @RequestParam("usersig") String usersig, @RequestParam("random") Integer random,
                            @RequestBody IMCheckAccountVM accounts);

    /**
     * 导入单个账号
     *
     * @param sdkappid    sdkApId
     * @param identifier  必须为 App 管理员账号
     * @param contenttype 请求格式固定值为json
     * @param usersig     App 管理员账号生成的签名
     * @param random      请输入随机的32位无符号整数,取值范围0 - 4294967295
     * @param account     用户
     * @return 响应体
     */
    @PostMapping("/v4/im_open_login_svc/account_import")
    JSONObject importSingleAccount(@RequestParam("sdkappid") Long sdkappid, @RequestParam("identifier") String identifier, @RequestParam("contenttype") String contenttype,
                                   @RequestParam("usersig") String usersig, @RequestParam("random") Integer random,
                                   @RequestBody IMSingleAccountVM account);

    /**
     * 设置账号资料
     *
     * @param sdkappid    sdkApId
     * @param identifier  必须为 App 管理员账号
     * @param contenttype 请求格式固定值为json
     * @param usersig     App 管理员账号生成的签名
     * @param random      请输入随机的32位无符号整数,取值范围0 - 4294967295
     * @param account     用户
     * @return 响应体
     */
    @PostMapping("/v4/profile/portrait_set")
    JSONObject setAccount(@RequestParam("sdkappid") Long sdkappid, @RequestParam("identifier") String identifier, @RequestParam("contenttype") String contenttype,
                          @RequestParam("usersig") String usersig, @RequestParam("random") Integer random,
                          @RequestBody IMProfileVM account);

    /**
     * 导入多个账号
     *
     * @param sdkappid    sdkApId
     * @param identifier  必须为 App 管理员账号
     * @param contenttype 请求格式固定值为json
     * @param usersig     App 管理员账号生成的签名
     * @param random      请输入随机的32位无符号整数,取值范围0 - 4294967295
     * @param accounts    用户账号列表
     * @return 响应体
     */
    @PostMapping("/v4/im_open_login_svc/multiaccount_import")
    JSONObject importMultiAccount(@RequestParam("sdkappid") Long sdkappid, @RequestParam("identifier") String identifier, @RequestParam("contenttype") String contenttype,
                                  @RequestParam("usersig") String usersig, @RequestParam("random") Integer random,
                                  @RequestBody IMMultiAccountVM accounts);

    /**
     * 创建群组
     *
     * @param sdkappid    sdkApId
     * @param identifier  必须为 App 管理员账号
     * @param contenttype 请求格式固定值为json
     * @param usersig     App 管理员账号生成的签名
     * @param random      请输入随机的32位无符号整数,取值范围0 - 4294967295
     * @param group       群组
     * @return 响应体
     */
    @PostMapping("/v4/group_open_http_svc/create_group")
    JSONObject createGroup(@RequestParam("sdkappid") Long sdkappid, @RequestParam("identifier") String identifier, @RequestParam("contenttype") String contenttype,
                           @RequestParam("usersig") String usersig, @RequestParam("random") Integer random,
                           @RequestBody IMGroupVM group);

    /**
     * 导入群组成员
     *
     * @param sdkappid    sdkApId
     * @param identifier  必须为 App 管理员账号
     * @param contenttype 请求格式固定值为json
     * @param usersig     App 管理员账号生成的签名
     * @param random      请输入随机的32位无符号整数,取值范围0 - 4294967295
     * @param group       群组
     * @return 响应体
     */
    @PostMapping("/v4/group_open_http_svc/import_group_member")
    JSONObject importGroupMember(@RequestParam("sdkappid") Long sdkappid, @RequestParam("identifier") String identifier, @RequestParam("contenttype") String contenttype,
                                 @RequestParam("usersig") String usersig, @RequestParam("random") Integer random,
                                 @RequestBody IMGroupVM group);

    /**
     * 在群组中发送普通消息
     *
     * @param sdkappid    sdkApId
     * @param identifier  必须为 App 管理员账号
     * @param contenttype 请求格式固定值为json
     * @param usersig     App 管理员账号生成的签名
     * @param random      请输入随机的32位无符号整数,取值范围0 - 4294967295
     * @param msg         普通消息
     * @return 响应体
     */
    @PostMapping("/v4/group_open_http_svc/send_group_msg")
    JSONObject sendGroupMsg(@RequestParam("sdkappid") Long sdkappid, @RequestParam("identifier") String identifier, @RequestParam("contenttype") String contenttype,
                            @RequestParam("usersig") String usersig, @RequestParam("random") Integer random,
                            @RequestBody IMGroupMsgVM msg);

    /**
     * 在群组中发送系统通知
     *
     * @param sdkappid    sdkApId
     * @param identifier  必须为 App 管理员账号
     * @param contenttype 请求格式固定值为json
     * @param usersig     App 管理员账号生成的签名
     * @param random      请输入随机的32位无符号整数,取值范围0 - 4294967295
     * @param msg         系统消息
     * @return 响应体
     */
    @PostMapping("/v4/group_open_http_svc/send_group_system_notification")
    JSONObject sendGroupSystemNotification(@RequestParam("sdkappid") Long sdkappid, @RequestParam("identifier") String identifier, @RequestParam("contenttype") String contenttype,
                                           @RequestParam("usersig") String usersig, @RequestParam("random") Integer random,
                                           @RequestBody IMGroupSystemMsgVM msg);

    /**
     * 创建机器人账号
     *
     * @param sdkappid    sdkApId
     * @param identifier  必须为 App 管理员账号
     * @param contenttype 请求格式固定值为json
     * @param usersig     App 管理员账号生成的签名
     * @param random      请输入随机的32位无符号整数,取值范围0 - 4294967295
     * @param msg         系统消息
     * @return 响应体
     */
    @PostMapping("/v4/openim_robot_http_svc/create_robot")
    JSONObject createRobot(@RequestParam("sdkappid") Long sdkappid, @RequestParam("identifier") String identifier, @RequestParam("contenttype") String contenttype,
                           @RequestParam("usersig") String usersig, @RequestParam("random") Integer random,
                           @RequestBody IMSingleAccountVM msg);


    /**
     * 在群组中发送普通消息
     *
     * @param sdkappid    sdkApId
     * @param identifier  必须为 App 管理员账号
     * @param contenttype 请求格式固定值为json
     * @param usersig     App 管理员账号生成的签名
     * @param random      请输入随机的32位无符号整数,取值范围0 - 4294967295
     * @param msg         普通消息
     * @return 响应体
     */
    @PostMapping("/v4/openim/sendmsg")
    JSONObject sendMsg(@RequestParam("sdkappid") Long sdkappid, @RequestParam("identifier") String identifier, @RequestParam("contenttype") String contenttype,
                       @RequestParam("usersig") String usersig, @RequestParam("random") Integer random,
                       @RequestBody IMMsgVM msg);

    /**
     * 在群组中发送普通消息
     *
     * @param sdkappid    sdkApId
     * @param identifier  必须为 App 管理员账号
     * @param contenttype 请求格式固定值为json
     * @param usersig     App 管理员账号生成的签名
     * @param random      请输入随机的32位无符号整数,取值范围0 - 4294967295
     * @param content     内容
     * @return 响应体
     */
    @PostMapping("/v4/openim/admin_set_msg_read")
    JSONObject adminSetMsgRead(@RequestParam("sdkappid") Long sdkappid, @RequestParam("identifier") String identifier, @RequestParam("contenttype") String contenttype,
                               @RequestParam("usersig") String usersig, @RequestParam("random") Integer random,
                               @RequestBody String content);

}

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110
  • 111
  • 112
  • 113
  • 114
  • 115
  • 116
  • 117
  • 118
  • 119
  • 120
  • 121
  • 122
  • 123
  • 124
  • 125
  • 126
  • 127
  • 128
  • 129
  • 130
  • 131
  • 132
  • 133
  • 134
  • 135
  • 136
  • 137
  • 138
  • 139
  • 140
  • 141
  • 142
  • 143
  • 144
  • 145
  • 146
  • 147
  • 148
  • 149
  • 150
  • 151
  • 152
  • 153
  • 154
  • 155
  • 156
  • 157
  • 158
  • 159
  • 160
  • 161
  • 162
  • 163
  • 164
  • 165
  • 166
  • 167
  • 168
  • 169
  • 170
  • 171
  • 172
  • 173
  • 174
  • 175
  • 176
  • 177
  • 178
  • 179
  • 180
  • 181
  • 182
  • 183
  • 184

签名类

@Component
@Slf4j
@RequiredArgsConstructor
public class IMUserSigService {

    private final ApplicationProperties applicationProperties;

    /**
     * 【功能说明】用于签发 TRTC 和 IM 服务中必须要使用的 UserSig 鉴权票据
     * <p>
     * 【参数说明】
     *
     * @param userid - 用户id,限制长度为32字节,只允许包含大小写英文字母(a-zA-Z)、数字(0-9)及下划线和连词符。
     * @param expire - UserSig 票据的过期时间,单位是秒,比如 86400 代表生成的 UserSig 票据在一天后就无法再使用了。
     * @return usersig -生成的签名
     */

    /**
     * Function: Used to issue UserSig that is required by the TRTC and IM services.
     * <p>
     * Parameter description:
     *
     * @param userid - User ID. The value can be up to 32 bytes in length and contain letters (a-z and A-Z), digits (0-9), underscores (_), and hyphens (-).
     * @param expire - UserSig expiration time, in seconds. For example, 86400 indicates that the generated UserSig will expire one day after being generated.
     * @return usersig - Generated signature.
     */
    public String genUserSig(String userid, long expire) {
        return genUserSig(userid, expire, null);
    }

    /**
     * 【功能说明】
     * 用于签发 TRTC 进房参数中可选的 PrivateMapKey 权限票据。
     * PrivateMapKey 需要跟 UserSig 一起使用,但 PrivateMapKey 比 UserSig 有更强的权限控制能力:
     * - UserSig 只能控制某个 UserID 有无使用 TRTC 服务的权限,只要 UserSig 正确,其对应的 UserID 可以进出任意房间。
     * - PrivateMapKey 则是将 UserID 的权限控制的更加严格,包括能不能进入某个房间,能不能在该房间里上行音视频等等。
     * 如果要开启 PrivateMapKey 严格权限位校验,需要在【实时音视频控制台】/【应用管理】/【应用信息】中打开“启动权限密钥”开关。
     * <p>
     * 【参数说明】
     *
     * @param userid       - 用户id,限制长度为32字节,只允许包含大小写英文字母(a-zA-Z)、数字(0-9)及下划线和连词符。
     * @param expire       - PrivateMapKey 票据的过期时间,单位是秒,比如 86400 生成的 PrivateMapKey 票据在一天后就无法再使用了。
     * @param roomid       - 房间号,用于指定该 userid 可以进入的房间号
     * @param privilegeMap - 权限位,使用了一个字节中的 8 个比特位,分别代表八个具体的功能权限开关:
     *                     - 第 1 位:0000 0001 = 1,创建房间的权限
     *                     - 第 2 位:0000 0010 = 2,加入房间的权限
     *                     - 第 3 位:0000 0100 = 4,发送语音的权限
     *                     - 第 4 位:0000 1000 = 8,接收语音的权限
     *                     - 第 5 位:0001 0000 = 16,发送视频的权限
     *                     - 第 6 位:0010 0000 = 32,接收视频的权限
     *                     - 第 7 位:0100 0000 = 64,发送辅路(也就是屏幕分享)视频的权限
     *                     - 第 8 位:1000 0000 = 200,接收辅路(也就是屏幕分享)视频的权限
     *                     - privilegeMap == 1111 1111 == 255 代表该 userid 在该 roomid 房间内的所有功能权限。
     *                     - privilegeMap == 0010 1010 == 42  代表该 userid 拥有加入房间和接收音视频数据的权限,但不具备其他权限。
     * @return usersig - 生成带userbuf的签名
     */

    /**
     * Function:
     * Used to issue PrivateMapKey that is optional for room entry.
     * PrivateMapKey must be used together with UserSig but with more powerful permission control capabilities.
     * - UserSig can only control whether a UserID has permission to use the TRTC service. As long as the UserSig is correct, the user with the corresponding UserID can enter or leave any room.
     * - PrivateMapKey specifies more stringent permissions for a UserID, including whether the UserID can be used to enter a specific room and perform audio/video upstreaming in the room.
     * To enable stringent PrivateMapKey permission bit verification, you need to enable permission key in TRTC console > Application Management > Application Info.
     * <p>
     * Parameter description:
     *
     * @param userid       - User ID. The value can be up to 32 bytes in length and contain letters (a-z and A-Z), digits (0-9), underscores (_), and hyphens (-).
     * @param roomid       - ID of the room to which the specified UserID can enter.
     * @param expire       - PrivateMapKey expiration time, in seconds. For example, 86400 indicates that the generated PrivateMapKey will expire one day after being generated.
     * @param privilegeMap - Permission bits. Eight bits in the same byte are used as the permission switches of eight specific features:
     *                     - Bit 1: 0000 0001 = 1, permission for room creation
     *                     - Bit 2: 0000 0010 = 2, permission for room entry
     *                     - Bit 3: 0000 0100 = 4, permission for audio sending
     *                     - Bit 4: 0000 1000 = 8, permission for audio receiving
     *                     - Bit 5: 0001 0000 = 16, permission for video sending
     *                     - Bit 6: 0010 0000 = 32, permission for video receiving
     *                     - Bit 7: 0100 0000 = 64, permission for substream video sending (screen sharing)
     *                     - Bit 8: 1000 0000 = 200, permission for substream video receiving (screen sharing)
     *                     - privilegeMap == 1111 1111 == 255: Indicates that the UserID has all feature permissions of the room specified by roomid.
     *                     - privilegeMap == 0010 1010 == 42: Indicates that the UserID has only the permissions to enter the room and receive audio/video data.
     * @return usersig - Generate signature with userbuf
     */
    public String genPrivateMapKey(String userid, long expire, long roomid, long privilegeMap) {
        byte[] userbuf = genUserBuf(userid, roomid, expire, privilegeMap, 0, "");  //生成userbuf
        return genUserSig(userid, expire, userbuf);
    }

    /**
     * 【功能说明】
     * 用于签发 TRTC 进房参数中可选的 PrivateMapKey 权限票据。
     * PrivateMapKey 需要跟 UserSig 一起使用,但 PrivateMapKey 比 UserSig 有更强的权限控制能力:
     * - UserSig 只能控制某个 UserID 有无使用 TRTC 服务的权限,只要 UserSig 正确,其对应的 UserID 可以进出任意房间。
     * - PrivateMapKey 则是将 UserID 的权限控制的更加严格,包括能不能进入某个房间,能不能在该房间里上行音视频等等。
     * 如果要开启 PrivateMapKey 严格权限位校验,需要在【实时音视频控制台】/【应用管理】/【应用信息】中打开“启动权限密钥”开关。
     * <p>
     * 【参数说明】
     *
     * @param userid       - 用户id,限制长度为32字节,只允许包含大小写英文字母(a-zA-Z)、数字(0-9)及下划线和连词符。
     * @param expire       - PrivateMapKey 票据的过期时间,单位是秒,比如 86400 生成的 PrivateMapKey 票据在一天后就无法再使用了。
     * @param roomstr      - 字符串房间号,用于指定该 userid 可以进入的房间号
     * @param privilegeMap - 权限位,使用了一个字节中的 8 个比特位,分别代表八个具体的功能权限开关:
     *                     - 第 1 位:0000 0001 = 1,创建房间的权限
     *                     - 第 2 位:0000 0010 = 2,加入房间的权限
     *                     - 第 3 位:0000 0100 = 4,发送语音的权限
     *                     - 第 4 位:0000 1000 = 8,接收语音的权限
     *                     - 第 5 位:0001 0000 = 16,发送视频的权限
     *                     - 第 6 位:0010 0000 = 32,接收视频的权限
     *                     - 第 7 位:0100 0000 = 64,发送辅路(也就是屏幕分享)视频的权限
     *                     - 第 8 位:1000 0000 = 200,接收辅路(也就是屏幕分享)视频的权限
     *                     - privilegeMap == 1111 1111 == 255 代表该 userid 在该 roomid 房间内的所有功能权限。
     *                     - privilegeMap == 0010 1010 == 42  代表该 userid 拥有加入房间和接收音视频数据的权限,但不具备其他权限。
     * @return usersig - 生成带userbuf的签名
     */

    /**
     * Function:
     * Used to issue PrivateMapKey that is optional for room entry.
     * PrivateMapKey must be used together with UserSig but with more powerful permission control capabilities.
     * - UserSig can only control whether a UserID has permission to use the TRTC service. As long as the UserSig is correct, the user with the corresponding UserID can enter or leave any room.
     * - PrivateMapKey specifies more stringent permissions for a UserID, including whether the UserID can be used to enter a specific room and perform audio/video upstreaming in the room.
     * To enable stringent PrivateMapKey permission bit verification, you need to enable permission key in TRTC console > Application Management > Application Info.
     * <p>
     * Parameter description:
     *
     * @param userid       - User ID. The value can be up to 32 bytes in length and contain letters (a-z and A-Z), digits (0-9), underscores (_), and hyphens (-).
     * @param roomstr      - ID of the room to which the specified UserID can enter.
     * @param expire       - PrivateMapKey expiration time, in seconds. For example, 86400 indicates that the generated PrivateMapKey will expire one day after being generated.
     * @param privilegeMap - Permission bits. Eight bits in the same byte are used as the permission switches of eight specific features:
     *                     - Bit 1: 0000 0001 = 1, permission for room creation
     *                     - Bit 2: 0000 0010 = 2, permission for room entry
     *                     - Bit 3: 0000 0100 = 4, permission for audio sending
     *                     - Bit 4: 0000 1000 = 8, permission for audio receiving
     *                     - Bit 5: 0001 0000 = 16, permission for video sending
     *                     - Bit 6: 0010 0000 = 32, permission for video receiving
     *                     - Bit 7: 0100 0000 = 64, permission for substream video sending (screen sharing)
     *                     - Bit 8: 1000 0000 = 200, permission for substream video receiving (screen sharing)
     *                     - privilegeMap == 1111 1111 == 255: Indicates that the UserID has all feature permissions of the room specified by roomid.
     *                     - privilegeMap == 0010 1010 == 42: Indicates that the UserID has only the permissions to enter the room and receive audio/video data.
     * @return usersig - Generate signature with userbuf
     */
    public String genPrivateMapKeyWithStringRoomID(String userid, long expire, String roomstr, long privilegeMap) {
        byte[] userbuf = genUserBuf(userid, 0, expire, privilegeMap, 0, roomstr);  //生成userbuf
        return genUserSig(userid, expire, userbuf);
    }

    private String hmacsha256(String identifier, long currTime, long expire, String base64Userbuf) {
        ApplicationProperties.TencentIm tencentIm = applicationProperties.getTencentIm();
        String contentToBeSigned = "TLS.identifier:" + identifier + "\n"
                + "TLS.sdkappid:" + tencentIm.getSdkAppId() + "\n"
                + "TLS.time:" + currTime + "\n"
                + "TLS.expire:" + expire + "\n";
        if (null != base64Userbuf) {
            contentToBeSigned += "TLS.userbuf:" + base64Userbuf + "\n";
        }
        try {
            byte[] byteKey = tencentIm.getKey().getBytes(StandardCharsets.UTF_8);
            Mac hmac = Mac.getInstance("HmacSHA256");
            SecretKeySpec keySpec = new SecretKeySpec(byteKey, "HmacSHA256");
            hmac.init(keySpec);
            byte[] byteSig = hmac.doFinal(contentToBeSigned.getBytes(StandardCharsets.UTF_8));
            return (Base64.getEncoder().encodeToString(byteSig)).replaceAll("\\s*", "");
        } catch (NoSuchAlgorithmException | InvalidKeyException e) {
            return "";
        }
    }

    private String genUserSig(String userid, long expire, byte[] userbuf) {
        ApplicationProperties.TencentIm tencentIm = applicationProperties.getTencentIm();
        long currTime = System.currentTimeMillis() / 1000;

        JSONObject sigDoc = new JSONObject();
        sigDoc.put("TLS.ver", "2.0");
        sigDoc.put("TLS.identifier", userid);
        sigDoc.put("TLS.sdkappid", tencentIm.getSdkAppId());
        sigDoc.put("TLS.expire", expire);
        sigDoc.put("TLS.time", currTime);

        String base64UserBuf = null;
        if (null != userbuf) {
            base64UserBuf = Base64.getEncoder().encodeToString(userbuf).replaceAll("\\s*", "");
            sigDoc.put("TLS.userbuf", base64UserBuf);
        }
        String sig = hmacsha256(userid, currTime, expire, base64UserBuf);
        if (sig.length() == 0) {
            return "";
        }
        sigDoc.put("TLS.sig", sig);
        Deflater compressor = new Deflater();
        compressor.setInput(sigDoc.toString().getBytes(StandardCharsets.UTF_8));
        compressor.finish();
        byte[] compressedBytes = new byte[2048];
        int compressedBytesLength = compressor.deflate(compressedBytes);
        compressor.end();
        return (new String(base64EncodeUrl(Arrays.copyOfRange(compressedBytes,
                0, compressedBytesLength)))).replaceAll("\\s*", "");
    }

    public byte[] genUserBuf(String account, long dwAuthID, long dwExpTime,
                             long dwPrivilegeMap, long dwAccountType, String RoomStr) {
        //视频校验位需要用到的字段,按照网络字节序放入buf中
        /*
         cVer    unsigned char/1 版本号,填0
         wAccountLen unsigned short /2   第三方自己的帐号长度
         account wAccountLen 第三方自己的帐号字符
         dwSdkAppid  unsigned int/4  sdkappid
         dwAuthID    unsigned int/4  群组号码
         dwExpTime   unsigned int/4  过期时间 ,直接使用填入的值
         dwPrivilegeMap  unsigned int/4  权限位,主播0xff,观众0xab
         dwAccountType   unsigned int/4  第三方帐号类型
         */

        //The fields required for the video check digit are placed in buf according to the network byte order.
        /*
         cVer    unsigned char/1 Version number, fill in 0
         wAccountLen unsigned short /2   Third party's own account length
         account wAccountLen Third party's own account characters
         dwSdkAppid  unsigned int/4  sdkappid
         dwAuthID    unsigned int/4  group number
         dwExpTime   unsigned int/4  Expiration time , use the filled value directly
         dwPrivilegeMap  unsigned int/4  Permission bits, host 0xff, audience 0xab
         dwAccountType   unsigned int/4  Third-party account type
        */
        long sdkAppId = applicationProperties.getTencentIm().getSdkAppId();
        int accountLength = account.length();
        int roomStrLength = RoomStr.length();
        int offset = 0;
        int bufLength = 1 + 2 + accountLength + 20;
        if (roomStrLength > 0) {
            bufLength = bufLength + 2 + roomStrLength;
        }
        byte[] userbuf = new byte[bufLength];

        //cVer
        if (roomStrLength > 0) {
            userbuf[offset++] = 1;
        } else {
            userbuf[offset++] = 0;
        }

        //wAccountLen
        userbuf[offset++] = (byte) ((accountLength & 0xFF00) >> 8);
        userbuf[offset++] = (byte) (accountLength & 0x00FF);

        //account
        for (; offset < 3 + accountLength; ++offset) {
            userbuf[offset] = (byte) account.charAt(offset - 3);
        }

        //dwSdkAppid
        userbuf[offset++] = (byte) ((sdkAppId & 0xFF000000) >> 24);
        userbuf[offset++] = (byte) ((sdkAppId & 0x00FF0000) >> 16);
        userbuf[offset++] = (byte) ((sdkAppId & 0x0000FF00) >> 8);
        userbuf[offset++] = (byte) (sdkAppId & 0x000000FF);

        //dwAuthId,房间号
        //dwAuthId, room number
        userbuf[offset++] = (byte) ((dwAuthID & 0xFF000000) >> 24);
        userbuf[offset++] = (byte) ((dwAuthID & 0x00FF0000) >> 16);
        userbuf[offset++] = (byte) ((dwAuthID & 0x0000FF00) >> 8);
        userbuf[offset++] = (byte) (dwAuthID & 0x000000FF);

        //expire,过期时间,当前时间 + 有效期(单位:秒)
        //expire,Expiration time, current time + validity period (unit: seconds)
        long currTime = System.currentTimeMillis() / 1000;
        long expire = currTime + dwExpTime;
        userbuf[offset++] = (byte) ((expire & 0xFF000000) >> 24);
        userbuf[offset++] = (byte) ((expire & 0x00FF0000) >> 16);
        userbuf[offset++] = (byte) ((expire & 0x0000FF00) >> 8);
        userbuf[offset++] = (byte) (expire & 0x000000FF);

        //dwPrivilegeMap,权限位
        //dwPrivilegeMap,Permission bits
        userbuf[offset++] = (byte) ((dwPrivilegeMap & 0xFF000000) >> 24);
        userbuf[offset++] = (byte) ((dwPrivilegeMap & 0x00FF0000) >> 16);
        userbuf[offset++] = (byte) ((dwPrivilegeMap & 0x0000FF00) >> 8);
        userbuf[offset++] = (byte) (dwPrivilegeMap & 0x000000FF);

        //dwAccountType,账户类型
        //dwAccountType,account type
        userbuf[offset++] = (byte) ((dwAccountType & 0xFF000000) >> 24);
        userbuf[offset++] = (byte) ((dwAccountType & 0x00FF0000) >> 16);
        userbuf[offset++] = (byte) ((dwAccountType & 0x0000FF00) >> 8);
        userbuf[offset++] = (byte) (dwAccountType & 0x000000FF);


        if (roomStrLength > 0) {
            //roomStrLen
            userbuf[offset++] = (byte) ((roomStrLength & 0xFF00) >> 8);
            userbuf[offset++] = (byte) (roomStrLength & 0x00FF);

            //roomStr
            for (; offset < bufLength; ++offset) {
                userbuf[offset] = (byte) RoomStr.charAt(offset - (bufLength - roomStrLength));
            }
        }
        return userbuf;
    }

    public static byte[] base64EncodeUrl(byte[] input) {
        byte[] base64 = Base64.getEncoder().encode(input);
        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 base64;
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110
  • 111
  • 112
  • 113
  • 114
  • 115
  • 116
  • 117
  • 118
  • 119
  • 120
  • 121
  • 122
  • 123
  • 124
  • 125
  • 126
  • 127
  • 128
  • 129
  • 130
  • 131
  • 132
  • 133
  • 134
  • 135
  • 136
  • 137
  • 138
  • 139
  • 140
  • 141
  • 142
  • 143
  • 144
  • 145
  • 146
  • 147
  • 148
  • 149
  • 150
  • 151
  • 152
  • 153
  • 154
  • 155
  • 156
  • 157
  • 158
  • 159
  • 160
  • 161
  • 162
  • 163
  • 164
  • 165
  • 166
  • 167
  • 168
  • 169
  • 170
  • 171
  • 172
  • 173
  • 174
  • 175
  • 176
  • 177
  • 178
  • 179
  • 180
  • 181
  • 182
  • 183
  • 184
  • 185
  • 186
  • 187
  • 188
  • 189
  • 190
  • 191
  • 192
  • 193
  • 194
  • 195
  • 196
  • 197
  • 198
  • 199
  • 200
  • 201
  • 202
  • 203
  • 204
  • 205
  • 206
  • 207
  • 208
  • 209
  • 210
  • 211
  • 212
  • 213
  • 214
  • 215
  • 216
  • 217
  • 218
  • 219
  • 220
  • 221
  • 222
  • 223
  • 224
  • 225
  • 226
  • 227
  • 228
  • 229
  • 230
  • 231
  • 232
  • 233
  • 234
  • 235
  • 236
  • 237
  • 238
  • 239
  • 240
  • 241
  • 242
  • 243
  • 244
  • 245
  • 246
  • 247
  • 248
  • 249
  • 250
  • 251
  • 252
  • 253
  • 254
  • 255
  • 256
  • 257
  • 258
  • 259
  • 260
  • 261
  • 262
  • 263
  • 264
  • 265
  • 266
  • 267
  • 268
  • 269
  • 270
  • 271
  • 272
  • 273
  • 274
  • 275
  • 276
  • 277
  • 278
  • 279
  • 280
  • 281
  • 282
  • 283
  • 284
  • 285
  • 286
  • 287
  • 288
  • 289
  • 290
  • 291
  • 292
  • 293
  • 294
  • 295
  • 296
  • 297
  • 298
  • 299
  • 300
  • 301
  • 302
  • 303
  • 304
  • 305
  • 306
  • 307
  • 308
  • 309
  • 310
  • 311
  • 312
  • 313
  • 314
  • 315
  • 316
  • 317
  • 318

服务类

@Component
@Slf4j
@RequiredArgsConstructor
public class IMService {

    private final IMUserSigService userSigService;

    private final TencentIMService tencentIMService;

    private final ApplicationProperties applicationProperties;

    /**
     * 生成腾讯云用户的UserSig
     *
     * @param userid 腾讯IM用户ID
     * @return UserSig
     */
    public String genUserSig(String userid) {
        return userSigService.genUserSig(userid, 30 * 24 * 3600);
    }

    /**
     * 查询用户
     *
     * @param users 用户列表
     * @return List 不存在的IM用户
     */
    public List<String> checkAccount(List<String> users) {
        ApplicationProperties.TencentIm tencentIm = applicationProperties.getTencentIm();
        String userSig = genUserSig(tencentIm.getIdentifier());

        List<IMCheckAccountItemVM> accountItems = users.stream().map(u -> {
            IMCheckAccountItemVM single = new IMCheckAccountItemVM();
            single.setUserId(u);
            return single;
        }).toList();
        IMCheckAccountVM accounts = IMCheckAccountVM.builder().items(accountItems).build();
        int random = RandomUtil.randomInt(0, Integer.MAX_VALUE);
        JSONObject result = tencentIMService.checkAccount(tencentIm.getSdkAppId(), tencentIm.getIdentifier(), tencentIm.getContenttype(), userSig, random,
                accounts);
        log.info("im -> account-> import -> result:[{}],[{}]", result.getStr("ErrorCode"), result.getStr("ErrorInfo"));
        if (!CharSequenceUtil.equals("OK", result.getStr("ActionStatus"))) {
            throw new SystemProblem(AppStatus.FAILURE, "查询IM账号出现错误!");
        }
        List<String> noExistAccounts = new ArrayList<>();
        JSONArray resultItem = result.getJSONArray("ResultItem");
        resultItem.forEach(item -> {
            JSONObject itemObj = JSONUtil.parseObj(item);
            if (itemObj.getInt("ResultCode") == 0 && CharSequenceUtil.equals("NotImported", itemObj.getStr("AccountStatus"))) {
                noExistAccounts.add(itemObj.getStr("UserID"));
            }
        });
        return noExistAccounts;
    }

    /**
     * 导入单个用户
     *
     * @param userId  用户ID
     * @param nick    名称
     * @param faceUrl 头像
     * @return boolean
     */
    public boolean importSingleAccount(String userId, String nick, String faceUrl) {
        ApplicationProperties.TencentIm tencentIm = applicationProperties.getTencentIm();
        String userSig = genUserSig(tencentIm.getIdentifier());
        IMSingleAccountVM singleAccount = IMSingleAccountVM.builder().userId(userId).nick(nick).faceUrl(faceUrl).build();
        int random = RandomUtil.randomInt(0, Integer.MAX_VALUE);
        JSONObject result = tencentIMService.importSingleAccount(tencentIm.getSdkAppId(), tencentIm.getIdentifier(), tencentIm.getContenttype(), userSig, random,
                singleAccount);
        log.info("im -> account-> import -> result:[{}],[{}]", result.getStr("ErrorCode"), result.getStr("ErrorInfo"));
        return CharSequenceUtil.equals("OK", result.getStr("ActionStatus"));
    }


    /**
     * 导入多个用户
     *
     * @param userIds 用户ID列表
     * @return boolean
     */
    public boolean importMultiAccount(List<IMUserInfo> userIds) {
        ApplicationProperties.TencentIm tencentIm = applicationProperties.getTencentIm();
        String userSig = genUserSig(tencentIm.getIdentifier());
        List<IMSingleAccountVM> accounts = userIds.stream().map(u -> {
            IMSingleAccountVM single = new IMSingleAccountVM();
            single.setUserId(u.getUserId());
            single.setNick(u.getNick());
            single.setFaceUrl(u.getFaceUrl());
            return single;
        }).toList();
        IMMultiAccountVM multiAccount = IMMultiAccountVM.builder().accounts(accounts).build();
        int random = RandomUtil.randomInt(0, Integer.MAX_VALUE);
        JSONObject result = tencentIMService.importMultiAccount(tencentIm.getSdkAppId(), tencentIm.getIdentifier(), tencentIm.getContenttype(), userSig, random,
                multiAccount);
        log.info("im -> account-> import -> result:[{}],[{}]", result.getStr("ErrorCode"), result.getStr("ErrorInfo"));
        return CharSequenceUtil.equals("OK", result.getStr("ActionStatus"));
    }

    /**
     * 创建群组
     *
     * @param owner     群主
     * @param groupName 群组名称
     * @param type      群组类型:Private/Public/ChatRoom/Community(不支持AVChatRoom)(必填)
     * @param members   成员列表
     * @return boolean
     */
    public String createGroup(String owner, String groupName, String type, List<String> members) {
        ApplicationProperties.TencentIm tencentIm = applicationProperties.getTencentIm();
        String userSig = genUserSig(tencentIm.getIdentifier());
        //群组信息
        List<IMGroupMemberVM> memberList = members.stream().map(
                m -> {
                    IMGroupMemberVM member = new IMGroupMemberVM();
                    member.setMemberAccount(m);
                    return member;
                }).collect(Collectors.toList());
        IMGroupVM group = IMGroupVM.builder().ownerAccount(owner).name(groupName).type(type).memberList(memberList).build();
        int random = RandomUtil.randomInt(0, Integer.MAX_VALUE);
        JSONObject result = tencentIMService.createGroup(tencentIm.getSdkAppId(), tencentIm.getIdentifier(), tencentIm.getContenttype(), userSig, random,
                group);
        String errorInfo = result.getStr("ErrorInfo");
        log.info("im -> create-> group -> result:[{}],[{}]", result.getStr("ErrorCode"), result.getStr("ErrorInfo"));
        if (!CharSequenceUtil.equals("OK", result.getStr("ActionStatus"))) {
            throw new SystemProblem(AppStatus.FAILURE, "create group occurs to exception : " + errorInfo);
        }
        return result.getStr("GroupId");
    }

    /**
     * 导入群组成员
     *
     * @param groupId 群组ID
     * @param members 群组成员
     * @return boolean
     */
    public String importGroupMember(String groupId, List<String> members) {
        ApplicationProperties.TencentIm tencentIm = applicationProperties.getTencentIm();
        String userSig = genUserSig(tencentIm.getIdentifier());
        //群组信息
        List<IMGroupMemberVM> memberList = members.stream().map(
                m -> {
                    IMGroupMemberVM member = new IMGroupMemberVM();
                    member.setMemberAccount(m);
                    return member;
                }).collect(Collectors.toList());
        IMGroupVM group = IMGroupVM.builder().groupId(groupId).memberList(memberList).build();
        int random = RandomUtil.randomInt(0, Integer.MAX_VALUE);
        JSONObject result = tencentIMService.importGroupMember(tencentIm.getSdkAppId(), tencentIm.getIdentifier(), tencentIm.getContenttype(), userSig, random,
                group);
        String errorInfo = result.getStr("ErrorInfo");
        log.info("im -> import-> group-member -> result:[{}],[{}]", result.getStr("ErrorCode"), result.getStr("ErrorInfo"));
        if (!CharSequenceUtil.equals("OK", result.getStr("ActionStatus"))) {
            throw new SystemProblem(AppStatus.FAILURE, "create group occurs to exception : " + errorInfo);
        }
        return result.getStr("GroupId");
    }

    /**
     * 向群组发送普通消息
     *
     * @param groupId     群组ID
     * @param fromAccount 指定账号发送
     * @param msgBodyList 消息体
     * @return boolean
     */
    public boolean sendGroupMsg(String groupId, String fromAccount, List<IMGroupMsgBodyVM> msgBodyList) {
        ApplicationProperties.TencentIm tencentIm = applicationProperties.getTencentIm();
        String userSig = genUserSig(tencentIm.getIdentifier());
        //群组信息
        IMGroupMsgVM groupMsg = IMGroupMsgVM.builder().groupId(groupId).fromAccount(fromAccount)
                .msgBody(msgBodyList).build();
        int random = RandomUtil.randomInt(0, Integer.MAX_VALUE);
        JSONObject result = tencentIMService.sendGroupMsg(tencentIm.getSdkAppId(), tencentIm.getIdentifier(), tencentIm.getContenttype(), userSig, random,
                groupMsg);
        log.info("im -> send-> group-msg -> result:[{}],[{}]", result.getStr("ErrorCode"), result.getStr("ErrorInfo"));
        return CharSequenceUtil.equals("OK", result.getStr("ActionStatus"));
    }

    /**
     * 向群组发送系统消息
     *
     * @param groupId 群组ID
     * @param content 系统消息
     * @return boolean
     */
    public boolean sendGroupSystemMsg(String groupId, String content) {
        ApplicationProperties.TencentIm tencentIm = applicationProperties.getTencentIm();
        String userSig = genUserSig(tencentIm.getIdentifier());
        //系统信息
        IMGroupSystemMsgVM groupSystemMsg = IMGroupSystemMsgVM.builder().groupId(groupId).content(content).build();

        int random = RandomUtil.randomInt(0, Integer.MAX_VALUE);
        JSONObject result = tencentIMService.sendGroupSystemNotification(tencentIm.getSdkAppId(), tencentIm.getIdentifier(), tencentIm.getContenttype(), userSig, random,
                groupSystemMsg);
        log.info("im -> send-> system-msg -> result:[{}],[{}]", result.getStr("ErrorCode"), result.getStr("ErrorInfo"));
        return CharSequenceUtil.equals("OK", result.getStr("ActionStatus"));
    }

    /**
     * 创建机器人用户
     *
     * @param userId  用户ID
     * @param nick    名称
     * @param faceUrl 头像
     * @return boolean
     */
    public boolean createRobot(String userId, String nick, String faceUrl) {
        ApplicationProperties.TencentIm tencentIm = applicationProperties.getTencentIm();
        String userSig = genUserSig(tencentIm.getIdentifier());
        IMSingleAccountVM singleAccount = IMSingleAccountVM.builder().userId(userId).nick(nick).faceUrl(faceUrl).build();
        int random = RandomUtil.randomInt(0, Integer.MAX_VALUE);
        JSONObject result = tencentIMService.createRobot(tencentIm.getSdkAppId(), tencentIm.getIdentifier(), tencentIm.getContenttype(), userSig, random,
                singleAccount);
        log.info("im -> create-> robot -> result:[{}],[{}]", result.getStr("ErrorCode"), result.getStr("ErrorInfo"));
        return CharSequenceUtil.equals("OK", result.getStr("ActionStatus"));
    }

    /**
     * 发送单聊消息
     *
     * @param fromAccount 指定账号发送
     * @param toAccount   指定账号接受
     * @param msgBodyList 消息体
     * @return boolean
     */
    public boolean sendMsg(String fromAccount, String toAccount, List<IMGroupMsgBodyVM> msgBodyList) {
        ApplicationProperties.TencentIm tencentIm = applicationProperties.getTencentIm();
        String userSig = genUserSig(tencentIm.getIdentifier());
        int random = RandomUtil.randomInt(0, Integer.MAX_VALUE);
        //群组信息
        IMMsgVM msgVM = IMMsgVM.builder().fromAccount(fromAccount).toAccount(toAccount)
                .msgBody(msgBodyList).random(random).build();
        JSONObject result = tencentIMService.sendMsg(tencentIm.getSdkAppId(), tencentIm.getIdentifier(), tencentIm.getContenttype(), userSig, random,
                msgVM);
        log.info("im -> send-> msg -> result:[{}],[{}]", result.getStr("ErrorCode"), result.getStr("ErrorInfo"));
        return CharSequenceUtil.equals("OK", result.getStr("ActionStatus"));
    }

    /**
     * 设置用户资料
     *
     * @param userId  用户ID
     * @param nick    昵称
     * @param faceUrl 头像
     */
    public void setAccount(Long userId, String nick, String faceUrl) {
        List<String> accounts = new ArrayList<>();
        accounts.add("PATIENT_" + userId);
        //不存在账号
        List<String> noExists = checkAccount(accounts);
        accounts.removeAll(noExists);
        if (CollUtil.isNotEmpty(accounts)) {
            List<IMProfileItemVM> profileItems = new ArrayList<>();
            profileItems.add(IMProfileItemVM.builder().tag("Tag_Profile_IM_Nick").value(nick).build());
//            profileItems.add(IMProfileItemVM.builder().tag("Tag_Profile_IM_Image").value(faceUrl).build());
            for (String account : accounts) {
                ApplicationProperties.TencentIm tencentIm = applicationProperties.getTencentIm();
                String userSig = genUserSig(tencentIm.getIdentifier());
                int random = RandomUtil.randomInt(0, Integer.MAX_VALUE);
                IMProfileVM profileVM = IMProfileVM.builder().fromAccount(account).profileItem(profileItems).build();
                JSONObject result = tencentIMService.setAccount(tencentIm.getSdkAppId(), tencentIm.getIdentifier(), tencentIm.getContenttype(), userSig, random,
                        profileVM);
                log.info("im -> send-> msg -> result:[{}],[{}]", result.getStr("ErrorCode"), result.getStr("ErrorInfo"));
            }
        }

    }

    /**
     * 管理员指定 reportAccount 将会话 peerAccount 的单聊未读计数清除。
     *
     * @param reportAccount 进行会话未读计数清理的用户 UserId
     * @param peerAccount   单聊会话的另一方用户 UserId
     */
    public void setMsgRead(String reportAccount, String peerAccount) {
        ApplicationProperties.TencentIm tencentIm = applicationProperties.getTencentIm();
        String userSig = genUserSig(tencentIm.getIdentifier());
        int random = RandomUtil.randomInt(0, Integer.MAX_VALUE);
        Map<String, String> content = new HashMap<>();
        content.put("Report_Account", reportAccount);
        content.put("Peer_Account", peerAccount);
        JSONObject result = tencentIMService.adminSetMsgRead(tencentIm.getSdkAppId(), tencentIm.getIdentifier(), tencentIm.getContenttype(), userSig, random,
                JSONUtil.toJsonStr(content));
        log.info("im -> send-> msg -> result:[{}],[{}]", result.getStr("ErrorCode"), result.getStr("ErrorInfo"));
    }
}

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110
  • 111
  • 112
  • 113
  • 114
  • 115
  • 116
  • 117
  • 118
  • 119
  • 120
  • 121
  • 122
  • 123
  • 124
  • 125
  • 126
  • 127
  • 128
  • 129
  • 130
  • 131
  • 132
  • 133
  • 134
  • 135
  • 136
  • 137
  • 138
  • 139
  • 140
  • 141
  • 142
  • 143
  • 144
  • 145
  • 146
  • 147
  • 148
  • 149
  • 150
  • 151
  • 152
  • 153
  • 154
  • 155
  • 156
  • 157
  • 158
  • 159
  • 160
  • 161
  • 162
  • 163
  • 164
  • 165
  • 166
  • 167
  • 168
  • 169
  • 170
  • 171
  • 172
  • 173
  • 174
  • 175
  • 176
  • 177
  • 178
  • 179
  • 180
  • 181
  • 182
  • 183
  • 184
  • 185
  • 186
  • 187
  • 188
  • 189
  • 190
  • 191
  • 192
  • 193
  • 194
  • 195
  • 196
  • 197
  • 198
  • 199
  • 200
  • 201
  • 202
  • 203
  • 204
  • 205
  • 206
  • 207
  • 208
  • 209
  • 210
  • 211
  • 212
  • 213
  • 214
  • 215
  • 216
  • 217
  • 218
  • 219
  • 220
  • 221
  • 222
  • 223
  • 224
  • 225
  • 226
  • 227
  • 228
  • 229
  • 230
  • 231
  • 232
  • 233
  • 234
  • 235
  • 236
  • 237
  • 238
  • 239
  • 240
  • 241
  • 242
  • 243
  • 244
  • 245
  • 246
  • 247
  • 248
  • 249
  • 250
  • 251
  • 252
  • 253
  • 254
  • 255
  • 256
  • 257
  • 258
  • 259
  • 260
  • 261
  • 262
  • 263
  • 264
  • 265
  • 266
  • 267
  • 268
  • 269
  • 270
  • 271
  • 272
  • 273
  • 274
  • 275
  • 276
  • 277
  • 278
  • 279
  • 280
  • 281
  • 282
  • 283
  • 284
  • 285
  • 286
  • 287
  • 288
  • 289
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/笔触狂放9/article/detail/653301
推荐阅读
相关标签
  

闽ICP备14008679号