diff --git a/broker/src/main/java/com/xiaoleilu/loServer/action/admin/GetUploadUrlAction.java b/broker/src/main/java/com/xiaoleilu/loServer/action/admin/GetUploadUrlAction.java new file mode 100755 index 00000000..cacc4674 --- /dev/null +++ b/broker/src/main/java/com/xiaoleilu/loServer/action/admin/GetUploadUrlAction.java @@ -0,0 +1,126 @@ +/* + * This file is part of the Wildfire Chat package. + * (c) Heavyrain2012 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +package com.xiaoleilu.loServer.action.admin; + +import cn.wildfirechat.common.APIPath; +import cn.wildfirechat.common.ErrorCode; +import cn.wildfirechat.pojos.InputGetPresignedUploadUrl; +import cn.wildfirechat.pojos.OutputPresignedUploadUrl; +import com.qiniu.util.Auth; +import com.xiaoleilu.loServer.RestResult; +import com.xiaoleilu.loServer.action.robot.RobotAction; +import com.xiaoleilu.loServer.annotation.HttpMethod; +import com.xiaoleilu.loServer.annotation.Route; +import com.xiaoleilu.loServer.handler.Request; +import com.xiaoleilu.loServer.handler.Response; +import io.moquette.server.config.MediaServerConfig; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.util.internal.StringUtil; + +import java.util.UUID; + +@Route(APIPath.Get_Presigned_Upload_Url) +@HttpMethod("POST") +public class GetUploadUrlAction extends AdminAction { + + @Override + public boolean action(Request request, Response response) { + if (request.getNettyRequest() instanceof FullHttpRequest) { + if(!MediaServerConfig.USER_QINIU) { + setResponseContent(RestResult.resultOf(ErrorCode.INVALID_PARAMETER), response); + return true; + } + + InputGetPresignedUploadUrl inputUserId = getRequestBody(request.getNettyRequest(), InputGetPresignedUploadUrl.class); + + if (StringUtil.isNullOrEmpty(inputUserId.contentType)) { + inputUserId.contentType = "application/octet-stream"; + } + String uuid = UUID.randomUUID().toString().replace("-", ""); + inputUserId.fileName = uuid + inputUserId.fileName; + + int type = inputUserId.mediaType; + String bucketName; + String bucketDomain; + switch (type) { + case 0: + default: + bucketName = MediaServerConfig.QINIU_BUCKET_GENERAL_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_GENERAL_DOMAIN; + break; + case 1: + bucketName = MediaServerConfig.QINIU_BUCKET_IMAGE_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_IMAGE_DOMAIN; + break; + case 2: + bucketName = MediaServerConfig.QINIU_BUCKET_VOICE_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_VOICE_DOMAIN; + break; + case 3: + bucketName = MediaServerConfig.QINIU_BUCKET_VIDEO_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_VIDEO_DOMAIN; + break; + case 4: + bucketName = MediaServerConfig.QINIU_BUCKET_FILE_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_FILE_DOMAIN; + break; + case 5: + bucketName = MediaServerConfig.QINIU_BUCKET_PORTRAIT_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_PORTRAIT_DOMAIN; + break; + case 6: + bucketName = MediaServerConfig.QINIU_BUCKET_FAVORITE_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_FAVORITE_DOMAIN; + break; + case 7: + bucketName = MediaServerConfig.QINIU_BUCKET_STICKER_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_STICKER_DOMAIN; + break; + case 8: + bucketName = MediaServerConfig.QINIU_BUCKET_MOMENTS_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_MOMENTS_DOMAIN; + break; + case 9: + bucketName = MediaServerConfig.QINIU_BUCKET_CUSTOM1_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_CUSTOM1_DOMAIN; + break; + case 10: + bucketName = MediaServerConfig.QINIU_BUCKET_CUSTOM2_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_CUSTOM2_DOMAIN; + break; + case 11: + bucketName = MediaServerConfig.QINIU_BUCKET_CUSTOM3_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_CUSTOM3_DOMAIN; + break; + case 12: + bucketName = MediaServerConfig.QINIU_BUCKET_PAN_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_PAN_DOMAIN; + break; + } + + if(StringUtil.isNullOrEmpty(bucketName)) { + bucketName = MediaServerConfig.QINIU_BUCKET_GENERAL_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_GENERAL_DOMAIN; + } + + String key = inputUserId.fileName; + Auth auth = Auth.create(MediaServerConfig.QINIU_ACCESS_KEY, MediaServerConfig.QINIU_SECRET_KEY); + String token = auth.uploadToken(bucketName, key); + String downloadUrl = bucketDomain + "/" + key; + String uploadUrl = "https://" + MediaServerConfig.QINIU_SERVER_URL + "?" + token + "?" + key; + + OutputPresignedUploadUrl outputPresignedUploadUrl = new OutputPresignedUploadUrl(); + outputPresignedUploadUrl.uploadUrl = uploadUrl; + outputPresignedUploadUrl.downloadUrl = downloadUrl; + outputPresignedUploadUrl.type = 1; + setResponseContent(RestResult.ok(outputPresignedUploadUrl), response); + } + return true; + } +} diff --git a/broker/src/main/java/com/xiaoleilu/loServer/action/robot/GetUploadUrlAction.java b/broker/src/main/java/com/xiaoleilu/loServer/action/robot/GetUploadUrlAction.java new file mode 100755 index 00000000..843b0107 --- /dev/null +++ b/broker/src/main/java/com/xiaoleilu/loServer/action/robot/GetUploadUrlAction.java @@ -0,0 +1,139 @@ +/* + * This file is part of the Wildfire Chat package. + * (c) Heavyrain2012 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +package com.xiaoleilu.loServer.action.robot; + +import cn.wildfirechat.common.APIPath; +import cn.wildfirechat.common.ErrorCode; +import cn.wildfirechat.pojos.InputGetPresignedUploadUrl; +import cn.wildfirechat.pojos.InputGetUserInfo; +import cn.wildfirechat.pojos.InputOutputUserInfo; +import cn.wildfirechat.pojos.OutputPresignedUploadUrl; +import cn.wildfirechat.proto.ProtoConstants; +import cn.wildfirechat.proto.WFCMessage; +import com.qiniu.util.Auth; +import com.xiaoleilu.loServer.RestResult; +import com.xiaoleilu.loServer.annotation.HttpMethod; +import com.xiaoleilu.loServer.annotation.Route; +import com.xiaoleilu.loServer.handler.Request; +import com.xiaoleilu.loServer.handler.Response; +import io.moquette.persistence.MemorySessionStore; +import io.moquette.server.config.MediaServerConfig; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.util.internal.StringUtil; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.io.IOException; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.ZonedDateTime; +import java.util.*; + +@Route(APIPath.Robot_Get_Presigned_Upload_Url) +@HttpMethod("POST") +public class GetUploadUrlAction extends RobotAction { + + @Override + public boolean action(Request request, Response response) { + if (request.getNettyRequest() instanceof FullHttpRequest) { + if(!MediaServerConfig.USER_QINIU) { + setResponseContent(RestResult.resultOf(ErrorCode.INVALID_PARAMETER), response); + return true; + } + + InputGetPresignedUploadUrl inputUserId = getRequestBody(request.getNettyRequest(), InputGetPresignedUploadUrl.class); + + if (StringUtil.isNullOrEmpty(inputUserId.contentType)) { + inputUserId.contentType = "application/octet-stream"; + } + String uuid = UUID.randomUUID().toString().replace("-", ""); + inputUserId.fileName = uuid + inputUserId.fileName; + + int type = inputUserId.mediaType; + String bucketName; + String bucketDomain; + switch (type) { + case 0: + default: + bucketName = MediaServerConfig.QINIU_BUCKET_GENERAL_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_GENERAL_DOMAIN; + break; + case 1: + bucketName = MediaServerConfig.QINIU_BUCKET_IMAGE_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_IMAGE_DOMAIN; + break; + case 2: + bucketName = MediaServerConfig.QINIU_BUCKET_VOICE_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_VOICE_DOMAIN; + break; + case 3: + bucketName = MediaServerConfig.QINIU_BUCKET_VIDEO_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_VIDEO_DOMAIN; + break; + case 4: + bucketName = MediaServerConfig.QINIU_BUCKET_FILE_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_FILE_DOMAIN; + break; + case 5: + bucketName = MediaServerConfig.QINIU_BUCKET_PORTRAIT_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_PORTRAIT_DOMAIN; + break; + case 6: + bucketName = MediaServerConfig.QINIU_BUCKET_FAVORITE_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_FAVORITE_DOMAIN; + break; + case 7: + bucketName = MediaServerConfig.QINIU_BUCKET_STICKER_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_STICKER_DOMAIN; + break; + case 8: + bucketName = MediaServerConfig.QINIU_BUCKET_MOMENTS_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_MOMENTS_DOMAIN; + break; + case 9: + bucketName = MediaServerConfig.QINIU_BUCKET_CUSTOM1_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_CUSTOM1_DOMAIN; + break; + case 10: + bucketName = MediaServerConfig.QINIU_BUCKET_CUSTOM2_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_CUSTOM2_DOMAIN; + break; + case 11: + bucketName = MediaServerConfig.QINIU_BUCKET_CUSTOM3_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_CUSTOM3_DOMAIN; + break; + case 12: + bucketName = MediaServerConfig.QINIU_BUCKET_PAN_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_PAN_DOMAIN; + break; + } + + if(StringUtil.isNullOrEmpty(bucketName)) { + bucketName = MediaServerConfig.QINIU_BUCKET_GENERAL_NAME; + bucketDomain = MediaServerConfig.QINIU_BUCKET_GENERAL_DOMAIN; + } + + String key = inputUserId.fileName; + Auth auth = Auth.create(MediaServerConfig.QINIU_ACCESS_KEY, MediaServerConfig.QINIU_SECRET_KEY); + String token = auth.uploadToken(bucketName, key); + String downloadUrl = bucketDomain + "/" + key; + String uploadUrl = "https://" + MediaServerConfig.QINIU_SERVER_URL + "?" + token + "?" + key; + + OutputPresignedUploadUrl outputPresignedUploadUrl = new OutputPresignedUploadUrl(); + outputPresignedUploadUrl.uploadUrl = uploadUrl; + outputPresignedUploadUrl.downloadUrl = downloadUrl; + outputPresignedUploadUrl.type = 1; + setResponseContent(RestResult.ok(outputPresignedUploadUrl), response); + } + return true; + } +} diff --git a/common/src/main/java/cn/wildfirechat/common/APIPath.java b/common/src/main/java/cn/wildfirechat/common/APIPath.java index 0c500544..bbb7d493 100644 --- a/common/src/main/java/cn/wildfirechat/common/APIPath.java +++ b/common/src/main/java/cn/wildfirechat/common/APIPath.java @@ -127,6 +127,8 @@ public interface APIPath { String Get_User_Files = "/admin/file/user_files"; String Get_Message_File = "/admin/file/message_file"; + String Get_Presigned_Upload_Url = "/admin/oss/get_upload_url"; + String Conference_List = "/admin/conference/list"; String Conference_Exist = "/admin/conference/exist"; String Conference_List_Participant = "/admin/conference/list_participant"; @@ -188,4 +190,6 @@ public interface APIPath { String Robot_Moments_Update_Profiles_Value = "/robot/moments/profiles/value/push"; String Robot_Conference_Request = "/robot/conference/request"; + + String Robot_Get_Presigned_Upload_Url = "/robot/oss/get_upload_url"; } diff --git a/common/src/main/java/cn/wildfirechat/pojos/InputGetPresignedUploadUrl.java b/common/src/main/java/cn/wildfirechat/pojos/InputGetPresignedUploadUrl.java new file mode 100755 index 00000000..6dd48012 --- /dev/null +++ b/common/src/main/java/cn/wildfirechat/pojos/InputGetPresignedUploadUrl.java @@ -0,0 +1,27 @@ +/* + * This file is part of the Wildfire Chat package. + * (c) Heavyrain2012 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +package cn.wildfirechat.pojos; + + +/** + * 请求预签名上传地址 + *

+ * 请求封装预签名上传地址,包括: + *

    + *
  • 文件名
  • + *
  • 文件类型
  • + *
  • 媒体类型
  • + *
+ *

+ */ +public class InputGetPresignedUploadUrl { + public String fileName; + public String contentType; + public int mediaType; +} diff --git a/common/src/main/java/cn/wildfirechat/pojos/OutputPresignedUploadUrl.java b/common/src/main/java/cn/wildfirechat/pojos/OutputPresignedUploadUrl.java new file mode 100755 index 00000000..2a8b09f1 --- /dev/null +++ b/common/src/main/java/cn/wildfirechat/pojos/OutputPresignedUploadUrl.java @@ -0,0 +1,29 @@ +/* + * This file is part of the Wildfire Chat package. + * (c) Heavyrain2012 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +package cn.wildfirechat.pojos; + + +/** + * 预签名上传地址 + *

+ * 封装预签名上传地址,包括: + *

    + *
  • 上传地址
  • + *
  • 备选上传地址
  • + *
  • 下载地址
  • + *
  • 类型
  • + *
+ *

+ */ +public class OutputPresignedUploadUrl { + public String uploadUrl; + public String backupUploadUrl; + public String downloadUrl; + public int type; +} diff --git a/sdk/src/main/java/cn/wildfirechat/sdk/GeneralAdmin.java b/sdk/src/main/java/cn/wildfirechat/sdk/GeneralAdmin.java index b151f82f..594a9ad3 100644 --- a/sdk/src/main/java/cn/wildfirechat/sdk/GeneralAdmin.java +++ b/sdk/src/main/java/cn/wildfirechat/sdk/GeneralAdmin.java @@ -1,9 +1,26 @@ package cn.wildfirechat.sdk; import cn.wildfirechat.common.APIPath; +import cn.wildfirechat.common.ErrorCode; import cn.wildfirechat.pojos.*; +import cn.wildfirechat.proto.ProtoConstants; import cn.wildfirechat.sdk.model.IMResult; import cn.wildfirechat.sdk.utilities.AdminHttpUtils; +import org.apache.http.HttpResponse; +import org.apache.http.HttpStatus; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpPut; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.FileEntity; +import org.apache.http.entity.InputStreamEntity; +import org.apache.http.entity.mime.MultipartEntityBuilder; +import org.apache.http.entity.mime.content.FileBody; +import org.apache.http.entity.mime.content.InputStreamBody; +import org.apache.http.util.EntityUtils; + +import java.io.File; +import java.io.InputStream; +import java.net.URLConnection; /** * 通用管理类 @@ -256,4 +273,331 @@ public class GeneralAdmin { public static IMResult getCustomer() throws Exception { return AdminHttpUtils.httpJsonPost(APIPath.GET_CUSTOMER, null, String.class); } + + private static IMResult getPresignedUploadUrl(String fileName, int/*ProtoConstants.MessageMediaType*/ mediaType, String contentType) throws Exception { + String path = APIPath.Get_Presigned_Upload_Url; + + InputGetPresignedUploadUrl requestPojo = new InputGetPresignedUploadUrl(); + requestPojo.fileName = fileName; + requestPojo.mediaType = mediaType; + requestPojo.contentType = contentType; + return AdminHttpUtils.httpJsonPost(path, requestPojo, OutputPresignedUploadUrl.class); + } + + /** + * 根据文件名获取Content-Type + * + * @param fileName 文件名 + * @return Content-Type,如果无法识别则返回 "application/octet-stream" + */ + private static String getContentTypeByFileName(String fileName) { + if (fileName == null || fileName.isEmpty()) { + return "application/octet-stream"; + } + + // 首先尝试使用 URLConnection 猜测 + String contentType = URLConnection.guessContentTypeFromName(fileName); + + // 如果无法识别,使用常见扩展名映射 + if (contentType == null) { + String lowerName = fileName.toLowerCase(); + if (lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg")) { + contentType = "image/jpeg"; + } else if (lowerName.endsWith(".png")) { + contentType = "image/png"; + } else if (lowerName.endsWith(".gif")) { + contentType = "image/gif"; + } else if (lowerName.endsWith(".bmp")) { + contentType = "image/bmp"; + } else if (lowerName.endsWith(".webp")) { + contentType = "image/webp"; + } else if (lowerName.endsWith(".mp4")) { + contentType = "video/mp4"; + } else if (lowerName.endsWith(".mov")) { + contentType = "video/quicktime"; + } else if (lowerName.endsWith(".avi")) { + contentType = "video/x-msvideo"; + } else if (lowerName.endsWith(".mp3")) { + contentType = "audio/mpeg"; + } else if (lowerName.endsWith(".wav")) { + contentType = "audio/wav"; + } else if (lowerName.endsWith(".pdf")) { + contentType = "application/pdf"; + } else if (lowerName.endsWith(".doc")) { + contentType = "application/msword"; + } else if (lowerName.endsWith(".docx")) { + contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + } else if (lowerName.endsWith(".xls")) { + contentType = "application/vnd.ms-excel"; + } else if (lowerName.endsWith(".xlsx")) { + contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + } else if (lowerName.endsWith(".ppt")) { + contentType = "application/vnd.ms-powerpoint"; + } else if (lowerName.endsWith(".pptx")) { + contentType = "application/vnd.openxmlformats-officedocument.presentationml.presentation"; + } else if (lowerName.endsWith(".txt")) { + contentType = "text/plain"; + } else if (lowerName.endsWith(".html") || lowerName.endsWith(".htm")) { + contentType = "text/html"; + } else if (lowerName.endsWith(".json")) { + contentType = "application/json"; + } else if (lowerName.endsWith(".xml")) { + contentType = "application/xml"; + } else if (lowerName.endsWith(".zip")) { + contentType = "application/zip"; + } else if (lowerName.endsWith(".rar")) { + contentType = "application/x-rar-compressed"; + } else if (lowerName.endsWith(".7z")) { + contentType = "application/x-7z-compressed"; + } else if (lowerName.endsWith(".tar")) { + contentType = "application/x-tar"; + } else if (lowerName.endsWith(".gz")) { + contentType = "application/gzip"; + } else { + contentType = "application/octet-stream"; + } + } + + return contentType; + } + + + /** + * 上传文件 + *

+ * 流程:先调用getPresignedUploadUrl获取预签名上传地址,然后直接上传文件。 + * 上传成功后返回文件的下载地址等信息。 + *

+ * + * @param file 要上传的文件 + * @return 上传结果,包含下载地址 + * @throws Exception 上传失败时抛出异常 + */ + public static IMResult uploadFile(File file) throws Exception { + return uploadFile(file, ProtoConstants.MessageMediaType.FILE, null); + } + /** + * 上传文件 + *

+ * 流程:先调用getPresignedUploadUrl获取预签名上传地址,然后直接上传文件。 + * 上传成功后返回文件的下载地址等信息。 + *

+ * + * @param file 要上传的文件 + * @param mediaType 媒体类型,参考{@link cn.wildfirechat.proto.ProtoConstants.MessageMediaType} + * @param contentType 文件Content-Type,例如 "image/jpeg", "application/octet-stream" 等; + * 如果为null或空,则根据文件名自动识别 + * @return 上传结果,包含下载地址 + * @throws Exception 上传失败时抛出异常 + */ + public static IMResult uploadFile(File file, int mediaType, String contentType) throws Exception { + if (file == null || !file.exists()) { + throw new IllegalArgumentException("文件不能为空或不存在"); + } + + // 如果未指定Content-Type,根据文件名自动获取 + if (contentType == null || contentType.isEmpty()) { + contentType = getContentTypeByFileName(file.getName()); + } + + return doUploadFile(file.getName(), mediaType, contentType, file, null); + } + + /** + * 执行文件上传的公共方法 + *

+ * 根据服务器类型选择不同的上传方式: + *

    + *
  • type = 1(七牛云):使用 POST + multipart/form-data 表单上传
  • + *
  • type = 其他(阿里云、Minio等):使用 PUT + 二进制流上传
  • + *
+ *

+ * + * @param fileName 文件名 + * @param mediaType 媒体类型 + * @param contentType Content-Type + * @param file 文件对象(用于七牛云上传) + * @param inputStream 输入流(用于其他类型上传,可为null) + * @return 上传结果 + * @throws Exception 上传失败时抛出异常 + */ + private static IMResult doUploadFile(String fileName, int mediaType, + String contentType, + File file, + InputStream inputStream) throws Exception { + // 1. 获取预签名上传地址 + IMResult presignedResult = getPresignedUploadUrl( + fileName, mediaType, contentType); + + if (presignedResult.getErrorCode() != ErrorCode.ERROR_CODE_SUCCESS) { + IMResult imResult = new IMResult<>(); + imResult.setCode(presignedResult.code); + imResult.setMsg(presignedResult.msg); + return imResult; + } + + OutputPresignedUploadUrl presignedUrl = presignedResult.getResult(); + if (presignedUrl == null || presignedUrl.uploadUrl == null) { + throw new Exception("预签名上传地址为空"); + } + + // 2. 根据服务器类型选择上传方式 + if (presignedUrl.type == 1) { + // 七牛云:使用 POST + multipart/form-data 表单上传 + return uploadToQiniu(presignedUrl, file, inputStream, fileName, contentType); + } else { + // 其他(阿里云、Minio、腾讯云、华为云、AWS S3等):使用 PUT 上传 + return uploadToOther(presignedUrl, file, inputStream, contentType); + } + } + + /** + * 上传到七牛云(type = 1) + * 使用 POST + multipart/form-data 表单格式 + */ + private static IMResult uploadToQiniu(OutputPresignedUploadUrl presignedUrl, File file, + InputStream inputStream, String fileName, + String contentType) throws Exception { + // 解析七牛云上传地址:格式为 "https://host?token?key" + String uploadUrl = presignedUrl.uploadUrl; + String token; + String key; + + int tokenStart = uploadUrl.indexOf('?'); + int keyStart = uploadUrl.indexOf('?', tokenStart + 1); + + if (tokenStart == -1 || keyStart == -1) { + throw new Exception("七牛云上传地址格式错误"); + } + + String baseUrl = uploadUrl.substring(0, tokenStart); + token = uploadUrl.substring(tokenStart + 1, keyStart); + key = uploadUrl.substring(keyStart + 1); + + HttpPost httpPost = new HttpPost(baseUrl); + try { + MultipartEntityBuilder builder = MultipartEntityBuilder.create(); + + // 添加 token 和 key 字段 + builder.addTextBody("token", token); + builder.addTextBody("key", key); + + // 添加文件字段 + if (file != null) { + builder.addPart("file", new FileBody(file, ContentType.create(contentType), fileName)); + } else if (inputStream != null) { + builder.addPart("file", new InputStreamBody(inputStream, ContentType.create(contentType), fileName)); + } + + httpPost.setEntity(builder.build()); + + HttpResponse response = AdminHttpUtils.getHttpClient().execute(httpPost); + int statusCode = response.getStatusLine().getStatusCode(); + + // 消耗响应体 + if (response.getEntity() != null) { + EntityUtils.consumeQuietly(response.getEntity()); + } + + // 七牛云返回 200 表示成功 + if (statusCode != HttpStatus.SC_OK && statusCode != HttpStatus.SC_CREATED) { + throw new Exception("文件上传到七牛云失败,HTTP状态码:" + statusCode); + } + + IMResult imResult = new IMResult<>(); + imResult.setCode(0); + imResult.setResult(presignedUrl.downloadUrl); + return imResult; + } finally { + httpPost.releaseConnection(); + } + } + + /** + * 上传到其他对象存储(type != 1) + * 使用 PUT + 二进制流 + */ + private static IMResult uploadToOther(OutputPresignedUploadUrl presignedUrl, File file, + InputStream inputStream, String contentType) throws Exception { + HttpPut httpPut = new HttpPut(presignedUrl.uploadUrl); + try { + // 设置请求实体 + if (file != null) { + FileEntity fileEntity = new FileEntity(file); + fileEntity.setContentType(contentType); + httpPut.setEntity(fileEntity); + } else if (inputStream != null) { + InputStreamEntity streamEntity = new InputStreamEntity(inputStream); + streamEntity.setContentType(contentType); + httpPut.setEntity(streamEntity); + } + + HttpResponse response = AdminHttpUtils.getHttpClient().execute(httpPut); + int statusCode = response.getStatusLine().getStatusCode(); + + // 消耗响应体,确保连接可以被复用 + if (response.getEntity() != null) { + EntityUtils.consumeQuietly(response.getEntity()); + } + + if (statusCode != HttpStatus.SC_OK && statusCode != HttpStatus.SC_CREATED) { + throw new Exception("文件上传失败,HTTP状态码:" + statusCode); + } + + IMResult imResult = new IMResult<>(); + imResult.setCode(0); + imResult.setResult(presignedUrl.downloadUrl); + return imResult; + } finally { + httpPut.releaseConnection(); + } + } + + /** + * 上传文件(通过输入流) + *

+ * 流程:先调用getPresignedUploadUrl获取预签名上传地址,然后直接上传文件。 + * 上传成功后返回文件的下载地址等信息。 + *

+ * + * @param inputStream 文件输入流 + * @param fileName 文件名 + * @return 上传结果,包含下载地址 + * @throws Exception 上传失败时抛出异常 + */ + public static IMResult uploadFile(InputStream inputStream, String fileName) throws Exception { + return uploadFile(inputStream, fileName, ProtoConstants.MessageMediaType.FILE, null); + } + /** + * 上传文件(通过输入流) + *

+ * 流程:先调用getPresignedUploadUrl获取预签名上传地址,然后直接上传文件。 + * 上传成功后返回文件的下载地址等信息。 + *

+ *

+ * 注意:无论上传成功还是失败,此方法都会关闭输入流。 + *

+ * + * @param inputStream 文件输入流(此方法会关闭该流) + * @param fileName 文件名 + * @param mediaType 媒体类型,参考{@link cn.wildfirechat.proto.ProtoConstants.MessageMediaType} + * @param contentType 文件Content-Type,例如 "image/jpeg", "application/octet-stream" 等; + * 如果为null或空,则根据文件名自动识别 + * @return 上传结果,包含下载地址 + * @throws Exception 上传失败时抛出异常 + */ + public static IMResult uploadFile(InputStream inputStream, String fileName, + int mediaType, String contentType) throws Exception { + if (inputStream == null) { + throw new IllegalArgumentException("输入流不能为空"); + } + + // 如果未指定Content-Type,根据文件名自动获取 + if (contentType == null || contentType.isEmpty()) { + contentType = getContentTypeByFileName(fileName); + } + + return doUploadFile(fileName, mediaType, contentType, null, inputStream); + // 注意:正常流程下的流关闭在 uploadToQiniu 或 uploadToOther 的 finally 块中处理 + } } diff --git a/sdk/src/main/java/cn/wildfirechat/sdk/RobotService.java b/sdk/src/main/java/cn/wildfirechat/sdk/RobotService.java index 1430245d..d12401bd 100644 --- a/sdk/src/main/java/cn/wildfirechat/sdk/RobotService.java +++ b/sdk/src/main/java/cn/wildfirechat/sdk/RobotService.java @@ -4,12 +4,29 @@ import cn.wildfirechat.common.APIPath; import cn.wildfirechat.common.ErrorCode; import cn.wildfirechat.pojos.*; import cn.wildfirechat.pojos.moments.*; +import cn.wildfirechat.proto.ProtoConstants; import cn.wildfirechat.sdk.model.IMResult; import cn.wildfirechat.sdk.utilities.RobotHttpUtils; import org.apache.commons.codec.digest.DigestUtils; +import org.apache.http.HttpResponse; +import org.apache.http.HttpStatus; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpPut; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.FileEntity; +import org.apache.http.entity.InputStreamEntity; +import org.apache.http.entity.mime.MultipartEntityBuilder; +import org.apache.http.entity.mime.content.FileBody; +import org.apache.http.entity.mime.content.InputStreamBody; +import org.apache.http.util.EntityUtils; import java.io.Closeable; +import java.io.File; import java.io.IOException; +import java.io.InputStream; +import java.net.URLConnection; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import static cn.wildfirechat.proto.ProtoConstants.ApplicationType.ApplicationType_Robot; @@ -476,6 +493,333 @@ public class RobotService implements Closeable { return robotHttpUtils.httpJsonPost(path, requestPojo, Void.class); } + private IMResult getPresignedUploadUrl(String fileName, int/*ProtoConstants.MessageMediaType*/ mediaType, String contentType) throws Exception { + String path = APIPath.Robot_Get_Presigned_Upload_Url; + + InputGetPresignedUploadUrl requestPojo = new InputGetPresignedUploadUrl(); + requestPojo.fileName = fileName; + requestPojo.mediaType = mediaType; + requestPojo.contentType = contentType; + return robotHttpUtils.httpJsonPost(path, requestPojo, OutputPresignedUploadUrl.class); + } + + /** + * 根据文件名获取Content-Type + * + * @param fileName 文件名 + * @return Content-Type,如果无法识别则返回 "application/octet-stream" + */ + private String getContentTypeByFileName(String fileName) { + if (fileName == null || fileName.isEmpty()) { + return "application/octet-stream"; + } + + // 首先尝试使用 URLConnection 猜测 + String contentType = URLConnection.guessContentTypeFromName(fileName); + + // 如果无法识别,使用常见扩展名映射 + if (contentType == null) { + String lowerName = fileName.toLowerCase(); + if (lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg")) { + contentType = "image/jpeg"; + } else if (lowerName.endsWith(".png")) { + contentType = "image/png"; + } else if (lowerName.endsWith(".gif")) { + contentType = "image/gif"; + } else if (lowerName.endsWith(".bmp")) { + contentType = "image/bmp"; + } else if (lowerName.endsWith(".webp")) { + contentType = "image/webp"; + } else if (lowerName.endsWith(".mp4")) { + contentType = "video/mp4"; + } else if (lowerName.endsWith(".mov")) { + contentType = "video/quicktime"; + } else if (lowerName.endsWith(".avi")) { + contentType = "video/x-msvideo"; + } else if (lowerName.endsWith(".mp3")) { + contentType = "audio/mpeg"; + } else if (lowerName.endsWith(".wav")) { + contentType = "audio/wav"; + } else if (lowerName.endsWith(".pdf")) { + contentType = "application/pdf"; + } else if (lowerName.endsWith(".doc")) { + contentType = "application/msword"; + } else if (lowerName.endsWith(".docx")) { + contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + } else if (lowerName.endsWith(".xls")) { + contentType = "application/vnd.ms-excel"; + } else if (lowerName.endsWith(".xlsx")) { + contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + } else if (lowerName.endsWith(".ppt")) { + contentType = "application/vnd.ms-powerpoint"; + } else if (lowerName.endsWith(".pptx")) { + contentType = "application/vnd.openxmlformats-officedocument.presentationml.presentation"; + } else if (lowerName.endsWith(".txt")) { + contentType = "text/plain"; + } else if (lowerName.endsWith(".html") || lowerName.endsWith(".htm")) { + contentType = "text/html"; + } else if (lowerName.endsWith(".json")) { + contentType = "application/json"; + } else if (lowerName.endsWith(".xml")) { + contentType = "application/xml"; + } else if (lowerName.endsWith(".zip")) { + contentType = "application/zip"; + } else if (lowerName.endsWith(".rar")) { + contentType = "application/x-rar-compressed"; + } else if (lowerName.endsWith(".7z")) { + contentType = "application/x-7z-compressed"; + } else if (lowerName.endsWith(".tar")) { + contentType = "application/x-tar"; + } else if (lowerName.endsWith(".gz")) { + contentType = "application/gzip"; + } else { + contentType = "application/octet-stream"; + } + } + + return contentType; + } + + + /** + * 上传文件 + *

+ * 流程:先调用getPresignedUploadUrl获取预签名上传地址,然后直接上传文件。 + * 上传成功后返回文件的下载地址等信息。 + *

+ * + * @param file 要上传的文件 + * @return 上传结果,包含下载地址 + * @throws Exception 上传失败时抛出异常 + */ + public IMResult uploadFile(File file) throws Exception { + return uploadFile(file, ProtoConstants.MessageMediaType.FILE, null); + } + /** + * 上传文件 + *

+ * 流程:先调用getPresignedUploadUrl获取预签名上传地址,然后直接上传文件。 + * 上传成功后返回文件的下载地址等信息。 + *

+ * + * @param file 要上传的文件 + * @param mediaType 媒体类型,参考{@link cn.wildfirechat.proto.ProtoConstants.MessageMediaType} + * @param contentType 文件Content-Type,例如 "image/jpeg", "application/octet-stream" 等; + * 如果为null或空,则根据文件名自动识别 + * @return 上传结果,包含下载地址 + * @throws Exception 上传失败时抛出异常 + */ + public IMResult uploadFile(File file, int mediaType, String contentType) throws Exception { + if (file == null || !file.exists()) { + throw new IllegalArgumentException("文件不能为空或不存在"); + } + + // 如果未指定Content-Type,根据文件名自动获取 + if (contentType == null || contentType.isEmpty()) { + contentType = getContentTypeByFileName(file.getName()); + } + + return doUploadFile(file.getName(), mediaType, contentType, file, null); + } + + /** + * 执行文件上传的公共方法 + *

+ * 根据服务器类型选择不同的上传方式: + *

    + *
  • type = 1(七牛云):使用 POST + multipart/form-data 表单上传
  • + *
  • type = 其他(阿里云、Minio等):使用 PUT + 二进制流上传
  • + *
+ *

+ * + * @param fileName 文件名 + * @param mediaType 媒体类型 + * @param contentType Content-Type + * @param file 文件对象(用于七牛云上传) + * @param inputStream 输入流(用于其他类型上传,可为null) + * @return 上传结果 + * @throws Exception 上传失败时抛出异常 + */ + private IMResult doUploadFile(String fileName, int mediaType, + String contentType, + File file, + InputStream inputStream) throws Exception { + // 1. 获取预签名上传地址 + IMResult presignedResult = getPresignedUploadUrl( + fileName, mediaType, contentType); + + if (presignedResult.getErrorCode() != ErrorCode.ERROR_CODE_SUCCESS) { + IMResult imResult = new IMResult<>(); + imResult.setCode(presignedResult.code); + imResult.setMsg(presignedResult.msg); + return imResult; + } + + OutputPresignedUploadUrl presignedUrl = presignedResult.getResult(); + if (presignedUrl == null || presignedUrl.uploadUrl == null) { + throw new Exception("预签名上传地址为空"); + } + + // 2. 根据服务器类型选择上传方式 + if (presignedUrl.type == 1) { + // 七牛云:使用 POST + multipart/form-data 表单上传 + return uploadToQiniu(presignedUrl, file, inputStream, fileName, contentType); + } else { + // 其他(阿里云、Minio、腾讯云、华为云、AWS S3等):使用 PUT 上传 + return uploadToOther(presignedUrl, file, inputStream, contentType); + } + } + + /** + * 上传到七牛云(type = 1) + * 使用 POST + multipart/form-data 表单格式 + */ + private IMResult uploadToQiniu(OutputPresignedUploadUrl presignedUrl, File file, + InputStream inputStream, String fileName, + String contentType) throws Exception { + // 解析七牛云上传地址:格式为 "https://host?token?key" + String uploadUrl = presignedUrl.uploadUrl; + String token; + String key; + + int tokenStart = uploadUrl.indexOf('?'); + int keyStart = uploadUrl.indexOf('?', tokenStart + 1); + + if (tokenStart == -1 || keyStart == -1) { + throw new Exception("七牛云上传地址格式错误"); + } + + String baseUrl = uploadUrl.substring(0, tokenStart); + token = uploadUrl.substring(tokenStart + 1, keyStart); + key = uploadUrl.substring(keyStart + 1); + + HttpPost httpPost = new HttpPost(baseUrl); + try { + MultipartEntityBuilder builder = MultipartEntityBuilder.create(); + + // 添加 token 和 key 字段 + builder.addTextBody("token", token); + builder.addTextBody("key", key); + + // 添加文件字段 + if (file != null) { + builder.addPart("file", new FileBody(file, ContentType.create(contentType), fileName)); + } else if (inputStream != null) { + builder.addPart("file", new InputStreamBody(inputStream, ContentType.create(contentType), fileName)); + } + + httpPost.setEntity(builder.build()); + + HttpResponse response = robotHttpUtils.getHttpClient().execute(httpPost); + int statusCode = response.getStatusLine().getStatusCode(); + + // 消耗响应体 + if (response.getEntity() != null) { + EntityUtils.consumeQuietly(response.getEntity()); + } + + // 七牛云返回 200 表示成功 + if (statusCode != HttpStatus.SC_OK && statusCode != HttpStatus.SC_CREATED) { + throw new Exception("文件上传到七牛云失败,HTTP状态码:" + statusCode); + } + + IMResult imResult = new IMResult<>(); + imResult.setCode(0); + imResult.setResult(presignedUrl.downloadUrl); + return imResult; + } finally { + httpPost.releaseConnection(); + } + } + + /** + * 上传到其他对象存储(type != 1) + * 使用 PUT + 二进制流 + */ + private IMResult uploadToOther(OutputPresignedUploadUrl presignedUrl, File file, + InputStream inputStream, String contentType) throws Exception { + HttpPut httpPut = new HttpPut(presignedUrl.uploadUrl); + try { + // 设置请求实体 + if (file != null) { + FileEntity fileEntity = new FileEntity(file); + fileEntity.setContentType(contentType); + httpPut.setEntity(fileEntity); + } else if (inputStream != null) { + InputStreamEntity streamEntity = new InputStreamEntity(inputStream); + streamEntity.setContentType(contentType); + httpPut.setEntity(streamEntity); + } + + HttpResponse response = robotHttpUtils.getHttpClient().execute(httpPut); + int statusCode = response.getStatusLine().getStatusCode(); + + // 消耗响应体,确保连接可以被复用 + if (response.getEntity() != null) { + EntityUtils.consumeQuietly(response.getEntity()); + } + + if (statusCode != HttpStatus.SC_OK && statusCode != HttpStatus.SC_CREATED) { + throw new Exception("文件上传失败,HTTP状态码:" + statusCode); + } + + IMResult imResult = new IMResult<>(); + imResult.setCode(0); + imResult.setResult(presignedUrl.downloadUrl); + return imResult; + } finally { + httpPut.releaseConnection(); + } + } + + /** + * 上传文件(通过输入流) + *

+ * 流程:先调用getPresignedUploadUrl获取预签名上传地址,然后直接上传文件。 + * 上传成功后返回文件的下载地址等信息。 + *

+ * + * @param inputStream 文件输入流 + * @param fileName 文件名 + * @return 上传结果,包含下载地址 + * @throws Exception 上传失败时抛出异常 + */ + public IMResult uploadFile(InputStream inputStream, String fileName) throws Exception { + return uploadFile(inputStream, fileName, ProtoConstants.MessageMediaType.FILE, null); + } + /** + * 上传文件(通过输入流) + *

+ * 流程:先调用getPresignedUploadUrl获取预签名上传地址,然后直接上传文件。 + * 上传成功后返回文件的下载地址等信息。 + *

+ *

+ * 注意:无论上传成功还是失败,此方法都会关闭输入流。 + *

+ * + * @param inputStream 文件输入流(此方法会关闭该流) + * @param fileName 文件名 + * @param mediaType 媒体类型,参考{@link cn.wildfirechat.proto.ProtoConstants.MessageMediaType} + * @param contentType 文件Content-Type,例如 "image/jpeg", "application/octet-stream" 等; + * 如果为null或空,则根据文件名自动识别 + * @return 上传结果,包含下载地址 + * @throws Exception 上传失败时抛出异常 + */ + public IMResult uploadFile(InputStream inputStream, String fileName, + int mediaType, String contentType) throws Exception { + if (inputStream == null) { + throw new IllegalArgumentException("输入流不能为空"); + } + + // 如果未指定Content-Type,根据文件名自动获取 + if (contentType == null || contentType.isEmpty()) { + contentType = getContentTypeByFileName(fileName); + } + + return doUploadFile(fileName, mediaType, contentType, null, inputStream); + // 注意:正常流程下的流关闭在 uploadToQiniu 或 uploadToOther 的 finally 块中处理 + } + @Override public void close() throws IOException { robotHttpUtils.close(); diff --git a/sdk/src/main/java/cn/wildfirechat/sdk/utilities/AdminHttpUtils.java b/sdk/src/main/java/cn/wildfirechat/sdk/utilities/AdminHttpUtils.java index e844c4d0..55c85412 100644 --- a/sdk/src/main/java/cn/wildfirechat/sdk/utilities/AdminHttpUtils.java +++ b/sdk/src/main/java/cn/wildfirechat/sdk/utilities/AdminHttpUtils.java @@ -46,6 +46,11 @@ public class AdminHttpUtils extends HttpUtils { // ======================== 线程安全的全局变量 ======================== private static volatile String adminUrl; private static volatile String adminSecret; + + public static CloseableHttpClient getHttpClient() { + return httpClient; + } + private static volatile CloseableHttpClient httpClient; diff --git a/sdk/src/main/java/cn/wildfirechat/sdk/utilities/RobotHttpUtils.java b/sdk/src/main/java/cn/wildfirechat/sdk/utilities/RobotHttpUtils.java index f554761a..6a66899c 100644 --- a/sdk/src/main/java/cn/wildfirechat/sdk/utilities/RobotHttpUtils.java +++ b/sdk/src/main/java/cn/wildfirechat/sdk/utilities/RobotHttpUtils.java @@ -120,6 +120,10 @@ public class RobotHttpUtils extends HttpUtils implements Closeable { return robotSecret; } + public CloseableHttpClient getHttpClient() { + return httpClient; + } + @Override public void close() throws IOException { httpClient.close();