diff --git a/common/contrib/files/baidu/baidu.go b/common/contrib/files/baidu/baidu.go new file mode 100644 index 00000000..715eec79 --- /dev/null +++ b/common/contrib/files/baidu/baidu.go @@ -0,0 +1,139 @@ +package baidu + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "strings" + "time" +) + +// 百度图床全局配置 +const ( + // 上传接口地址 + baiduUploadURL = "https://wenku.baidu.com/user/api/editorimg" + // 必需的Referer + baiduReferer = "https://wenku.baidu.com/" + // 固定Cookie(可根据实际情况替换) + baiduCookie = "BDUSS=3plTHA0aHpRNGI3MmIxTkpmNVpWTTYtLXpVWjlaRjdRQzFsNmxQNlNufkRiWGhvSVFBQUFBJCQAAAAAAAAAAAEAAAB5WXC40tfDzsTPs8cAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMPgUGjD4FBoS" + // 请求超时时间 + baiduTimeout = 30 * time.Second +) + +// BaiduUploadResponse 百度图床接口返回JSON结构 +type BaiduUploadResponse struct { + Link string `json:"link"` // 图片链接字段 +} + +// BaiduImgUpload 百度图床上传函数,对齐PHP功能 +// filepath: 本地文件路径 +// filename: 上传时的文件名(对应PHP的setPostFilename) +// 返回值: 处理后的图片URL,错误信息 +func BaiduImgUpload(filepath string, filename string) (string, error) { + // 1. 参数校验 + if filepath == "" { + return "", fmt.Errorf("本地文件路径不能为空") + } + if filename == "" { + return "", fmt.Errorf("上传文件名不能为空") + } + + // 2. 打开本地文件 + file, err := os.Open(filepath) + if err != nil { + return "", fmt.Errorf("打开本地文件失败: %w, 路径: %s", err, filepath) + } + defer file.Close() // 延迟关闭文件,确保资源释放 + + // 3. 构建multipart/form-data请求体 + bodyBuf := &bytes.Buffer{} + bodyWriter := multipart.NewWriter(bodyBuf) + defer bodyWriter.Close() // 延迟关闭writer + + // 3.1 添加文件字段(对应PHP的'file'字段,设置上传文件名) + fileWriter, err := bodyWriter.CreateFormFile("file", filename) + if err != nil { + return "", fmt.Errorf("创建文件表单字段失败: %w", err) + } + // 复制文件内容到请求体 + if _, err := io.Copy(fileWriter, file); err != nil { + return "", fmt.Errorf("复制文件内容到请求体失败: %w", err) + } + + // 4. 创建HTTP POST请求 + contentType := bodyWriter.FormDataContentType() + req, err := http.NewRequest(http.MethodPost, baiduUploadURL, bodyBuf) + if err != nil { + return "", fmt.Errorf("创建HTTP请求失败: %w", err) + } + + // 5. 设置请求头(对齐PHP的请求头配置) + setBaiduRequestHeaders(req, contentType) + + // 6. 发送HTTP请求 + client := &http.Client{Timeout: baiduTimeout} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("发送HTTP请求失败: %w", err) + } + defer resp.Body.Close() // 延迟关闭响应体 + + // 7. 读取响应内容 + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("读取响应内容失败: %w", err) + } + + // 8. 解析JSON响应 + var baiduResp BaiduUploadResponse + if err := json.Unmarshal(respBody, &baiduResp); err != nil { + return "", fmt.Errorf("解析JSON响应失败: %w, 响应内容: %s", err, string(respBody)) + } + + // 9. 校验响应结果(对应PHP的isset($arr['link'])) + if baiduResp.Link == "" { + return "", fmt.Errorf("上传失败!接口错误,响应内容: %s", string(respBody)) + } + + // 10. 替换域名(对应PHP的str_replace操作) + imgURL := strings.Replace(baiduResp.Link, ".cdn.bcebos.com", ".bj.bcebos.com", 1) + + return imgURL, nil +} + +// setBaiduRequestHeaders 设置百度图床必需的请求头 +func setBaiduRequestHeaders(req *http.Request, contentType string) { + // 设置Content-Type(包含multipart boundary) + req.Header.Set("Content-Type", contentType) + // 设置Referer(PHP中的$referer) + req.Header.Set("Referer", baiduReferer) + // 设置Cookie(PHP中的$cookie) + req.Header.Set("Cookie", baiduCookie) + // 补充必要的请求头,提升兼容性 + req.Header.Set("Connection", "keep-alive") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36") + req.Header.Set("Accept", "*/*") + req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9") + req.Header.Set("Accept-Encoding", "gzip, deflate, br") +} + +// 示例调用 +func main() { + // 本地文件路径(请替换为实际的图片路径) + localFilePath := "./test.jpg" + // 上传时的文件名(可自定义,对应PHP的setPostFilename) + uploadFilename := "custom-test.jpg" + + // 调用百度图床上传函数 + imgURL, err := BaiduImgUpload(localFilePath, uploadFilename) + if err != nil { + fmt.Printf("百度图床上传失败: %v\n", err) + return + } + + fmt.Printf("百度图床上传成功,图片URL: %s\n", imgURL) +} diff --git a/common/contrib/files/baidu/go.mod b/common/contrib/files/baidu/go.mod new file mode 100644 index 00000000..43bf9134 --- /dev/null +++ b/common/contrib/files/baidu/go.mod @@ -0,0 +1,3 @@ +module blazing/contrib/files/baidu + +go 1.25.0 diff --git a/common/contrib/files/cdn58/cdn58.go b/common/contrib/files/cdn58/cdn58.go new file mode 100644 index 00000000..c6498da1 --- /dev/null +++ b/common/contrib/files/cdn58/cdn58.go @@ -0,0 +1,329 @@ +package main + +import ( + "bytes" + "crypto/md5" + "crypto/tls" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "math/rand" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// 全局配置(与PHP代码保持一致) +const ( + // 获取上传地址的接口URL + cdn58GetUploadURL = "https://im.58.com/msg/get_pic_upload_url" + // 固定请求头配置 + cdn58Referer = "https://ai.58.com/pc/" + cdn58UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.6261.95 Safari/537.36" + cdn58Origin = "https://ai.58.com" + // 请求超时时间 + cdn58Timeout = 30 * time.Second + // 最终图片URL前缀 + cdn58ImgPrefix = "https://pic%d.58cdn.com.cn/nowater/im/" +) + +// 允许的文件后缀(与PHP代码保持一致) +var allowedFileExts = map[string]bool{ + "jpg": true, + "jpeg": true, + "png": true, + "gif": true, + "bmp": true, +} + +// Cdn58UploadInfo 上传信息结构体(对应PHP响应中的upload_info) +type Cdn58UploadInfo struct { + URL string `json:"url"` // 上传地址 +} + +// Cdn58Data 响应数据结构体(对应PHP响应中的data) +type Cdn58Data struct { + UploadInfo []Cdn58UploadInfo `json:"upload_info"` +} + +// Cdn58GetUploadURLResponse 获取上传地址的响应结构体 +type Cdn58GetUploadURLResponse struct { + ErrorCode int `json:"error_code"` + ErrorMsg string `json:"error_msg"` + Data Cdn58Data `json:"data"` +} + +// -------------------------- 对应PHP的私有方法实现 -------------------------- + +// guid 生成与PHP guid()方法一致的UUID格式字符串 +func guid() string { + // 模拟PHP: md5(uniqid(mt_rand(), true)) + rand.Seed(time.Now().UnixNano()) + uniqID := fmt.Sprintf("%d%d", rand.Int63(), time.Now().UnixNano()) + md5Hash := md5.Sum([]byte(uniqID)) + guidStr := fmt.Sprintf("%x", md5Hash) + + // 拼接为UUID格式:8-4-4-4-12 + return fmt.Sprintf("%s-%s-4%s-%s-%s", + guidStr[:8], + guidStr[8:12], + guidStr[13:16], // 固定第13位为4(符合UUID v4规范) + guidStr[16:20], + guidStr[20:32], + ) +} + +// encrypt 实现与PHP一致的加密逻辑 +func encrypt(data string) string { + // 1. base64编码 + base64Str := base64.StdEncoding.EncodeToString([]byte(data)) + + // 2. 统计等号数量 + equalCount := strings.Count(base64Str, "=") + + // 3. 替换字符并移除等号,拼接等号数量 + encodedStr := strings.ReplaceAll(base64Str, "+", "-") + encodedStr = strings.ReplaceAll(encodedStr, "/", "_") + encodedStr = strings.ReplaceAll(encodedStr, "=", "") + encodedStr += strconv.Itoa(equalCount) + + // 4. 分成两半,交换前后部分 + halfLen := len(encodedStr) / 2 + firstHalf := encodedStr[:halfLen] + secondHalf := encodedStr[halfLen:] + + return secondHalf + firstHalf +} + +// decrypt 实现与PHP一致的解密逻辑(虽然核心流程未使用,保持完整性) +func decrypt(data string) string { + // 1. 分成两半,交换前后部分 + halfLen := (len(data) + 1) / 2 + firstHalf := data[:halfLen] + secondHalf := data[halfLen:] + data = secondHalf + firstHalf + + // 2. 提取末尾的等号数量 + equalCount, _ := strconv.Atoi(string(data[len(data)-1])) + data = data[:len(data)-1] + + // 3. 还原字符 + decodedStr := strings.ReplaceAll(data, "-", "+") + decodedStr = strings.ReplaceAll(data, "_", "/") + + // 4. 补充等号 + decodedStr += strings.Repeat("=", equalCount) + + // 5. base64解码 + raw, _ := base64.StdEncoding.DecodeString(decodedStr) + return string(raw) +} + +// getMIMEType 根据文件后缀获取MIME类型(对应PHP的mime_content_type方法) +func getMIMEType(ext string) string { + mimeMap := map[string]string{ + "png": "image/png", + "jpeg": "image/jpeg", + "jpg": "image/jpeg", + "gif": "image/gif", + "bmp": "image/bmp", + } + if mime, ok := mimeMap[strings.ToLower(ext)]; ok { + return mime + } + return "application/octet-stream" +} + +// -------------------------- 核心上传逻辑 -------------------------- + +// Cdn58ImgUpload cdn58图床上传函数,完全对齐PHP功能 +// filepath: 本地文件路径 +// filename: 上传文件名(包含后缀) +// 返回值: 最终图片URL,错误信息 +func Cdn58ImgUpload(filepath1 string, filename string) (string, error) { + // 1. 提取并校验文件后缀 + fileExt := filepath.Ext(filename) + if fileExt != "" { + fileExt = strings.TrimPrefix(fileExt, ".") // 移除前缀"." + } + fileExt = strings.ToLower(fileExt) + if !allowedFileExts[fileExt] { + return "", errors.New("上传失败!不支持的文件格式,仅支持jpg/jpeg/png/gif/bmp") + } + + // 2. 生成UserID + userID := "58Anonymous" + guid() + + // 3. 构建user_info并加密(对应PHP的user_info数组) + userInfo := map[string]string{ + "user_id": userID, + "source": "14", + "im_token": userID, + "client_version": "1.0", + "client_type": "pcweb", + "os_type": "Chrome", + "os_version": "122.0.6261.95", + "appid": "10140-mcs@jitmouQrcHs", + "extend_flag": "0", + "unread_index": "1", + "sdk_version": "6432", + "device_id": userID, + "xxzl_smartid": "", + "id58": "CkwAd2e0U3tBNxbRAzQ2Ag==", + } + + // 3.1 构建查询字符串(对应PHP的http_build_query) + queryValues := url.Values{} + for k, v := range userInfo { + queryValues.Set(k, v) + } + userInfoQuery := queryValues.Encode() + + // 3.2 加密user_info查询字符串 + encryptedUserInfo := encrypt(userInfoQuery) + + // 4. 构建post数据并加密(对应PHP的post数组) + postData := map[string]interface{}{ + "sender_id": userID, + "sender_source": 14, + "to_id": "10002", + "to_source": 100, + "file_suffixs": []string{fileExt}, + } + // 4.1 JSON编码(对应PHP的json_encode) + postJSON, err := json.Marshal(postData) + if err != nil { + return "", fmt.Errorf("序列化post数据失败: %w", err) + } + // 4.2 加密post数据 + encryptedPost := encrypt(string(postJSON)) + + // 5. 拼接获取上传地址的URL + getUploadURL := fmt.Sprintf("%s?params=%s&version=j1.0", + cdn58GetUploadURL, + url.QueryEscape(encryptedUserInfo), // URL编码参数 + ) + + // 6. 发送请求获取上传地址(对应PHP的get_curl) + client := &http.Client{ + Timeout: cdn58Timeout, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, // 注意:字段名是 InsecureSkipVerify(非 TLSInsecureSkipVerify) + }, + }, + } + + req, err := http.NewRequest("POST", getUploadURL, bytes.NewBufferString(encryptedPost)) + if err != nil { + return "", fmt.Errorf("创建获取上传地址请求失败: %w", err) + } + + // 设置请求头(与PHP保持一致) + req.Header.Set("Content-Type", "text/plain;charset=UTF-8") + req.Header.Set("Referer", cdn58Referer) + req.Header.Set("User-Agent", cdn58UA) + req.Header.Set("Origin", cdn58Origin) + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("发送获取上传地址请求失败: %w", err) + } + defer resp.Body.Close() + + // 解析获取上传地址的响应 + var getUploadResp Cdn58GetUploadURLResponse + if err := json.NewDecoder(resp.Body).Decode(&getUploadResp); err != nil { + return "", fmt.Errorf("解析获取上传地址响应失败: %w", err) + } + + // 校验响应结果 + if getUploadResp.ErrorCode != 0 { + errMsg := "接口错误" + if getUploadResp.ErrorMsg != "" { + errMsg = getUploadResp.ErrorMsg + } + return "", fmt.Errorf("上传失败!%s", errMsg) + } + + // 提取上传URL + if len(getUploadResp.Data.UploadInfo) == 0 || getUploadResp.Data.UploadInfo[0].URL == "" { + return "", errors.New("上传失败!未返回上传地址") + } + uploadURL := getUploadResp.Data.UploadInfo[0].URL + + // 7. 读取本地文件内容(对应PHP的file_get_contents) + fileContent, err := os.ReadFile(filepath1) + if err != nil { + return "", fmt.Errorf("读取本地文件失败: %w, 路径: %s", err, filepath1) + } + + // 8. 获取MIME类型 + mimeType := getMIMEType(fileExt) + + // 9. 使用PUT方法上传文件(对应PHP的curl_upload方法) + putReq, err := http.NewRequest("PUT", uploadURL, bytes.NewBuffer(fileContent)) + if err != nil { + return "", fmt.Errorf("创建文件上传请求失败: %w", err) + } + + // 设置PUT请求头 + putReq.Header.Set("Content-Type", mimeType) + putReq.Header.Set("User-Agent", cdn58UA) + + putResp, err := client.Do(putReq) + if err != nil { + return "", fmt.Errorf("发送文件上传请求失败: %w", err) + } + defer putResp.Body.Close() + + // 校验HTTP状态码 + if putResp.StatusCode != 200 { + return "", fmt.Errorf("上传失败!httpCode=%d", putResp.StatusCode) + } + + // 10. 截取文件名(对应PHP的getSubstr($url, '/nowater/im/', '?')) + filenameStart := strings.Index(uploadURL, "/nowater/im/") + if filenameStart == -1 { + return "", errors.New("上传失败!无法从上传地址中截取文件名") + } + filenameStart += len("/nowater/im/") + filenameEnd := strings.Index(uploadURL[filenameStart:], "?") + if filenameEnd == -1 { + filenameEnd = len(uploadURL) + } else { + filenameEnd += filenameStart + } + croppedFilename := uploadURL[filenameStart:filenameEnd] + if croppedFilename == "" { + return "", errors.New("上传失败!截取的文件名为空") + } + + // 11. 拼接最终图片URL(随机生成1-8的数字) + rand.Seed(time.Now().UnixNano()) + picNum := rand.Intn(8) + 1 // 生成1-8的随机数 + finalImgURL := fmt.Sprintf(cdn58ImgPrefix, picNum) + croppedFilename + + return finalImgURL, nil +} + +// -------------------------- 示例调用 -------------------------- +func main() { + // 配置本地文件路径和上传文件名(请替换为实际值) + localFilePath := "./test.jpg" // 本地图片路径 + uploadFilename := "test.jpg" // 上传文件名(包含后缀) + + // 调用cdn58上传函数 + imgURL, err := Cdn58ImgUpload(localFilePath, uploadFilename) + if err != nil { + fmt.Printf("cdn58图床上传失败: %v\n", err) + return + } + + fmt.Printf("cdn58图床上传成功,最终图片URL: %s\n", imgURL) +} diff --git a/common/contrib/files/cdn58/go.mod b/common/contrib/files/cdn58/go.mod new file mode 100644 index 00000000..b211204a --- /dev/null +++ b/common/contrib/files/cdn58/go.mod @@ -0,0 +1,3 @@ +module blazing/contrib/files/cnd58 + +go 1.25.0 diff --git a/common/contrib/files/img/cdn58.go b/common/contrib/files/img/cdn58.go new file mode 100644 index 00000000..715eec79 --- /dev/null +++ b/common/contrib/files/img/cdn58.go @@ -0,0 +1,139 @@ +package baidu + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "strings" + "time" +) + +// 百度图床全局配置 +const ( + // 上传接口地址 + baiduUploadURL = "https://wenku.baidu.com/user/api/editorimg" + // 必需的Referer + baiduReferer = "https://wenku.baidu.com/" + // 固定Cookie(可根据实际情况替换) + baiduCookie = "BDUSS=3plTHA0aHpRNGI3MmIxTkpmNVpWTTYtLXpVWjlaRjdRQzFsNmxQNlNufkRiWGhvSVFBQUFBJCQAAAAAAAAAAAEAAAB5WXC40tfDzsTPs8cAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMPgUGjD4FBoS" + // 请求超时时间 + baiduTimeout = 30 * time.Second +) + +// BaiduUploadResponse 百度图床接口返回JSON结构 +type BaiduUploadResponse struct { + Link string `json:"link"` // 图片链接字段 +} + +// BaiduImgUpload 百度图床上传函数,对齐PHP功能 +// filepath: 本地文件路径 +// filename: 上传时的文件名(对应PHP的setPostFilename) +// 返回值: 处理后的图片URL,错误信息 +func BaiduImgUpload(filepath string, filename string) (string, error) { + // 1. 参数校验 + if filepath == "" { + return "", fmt.Errorf("本地文件路径不能为空") + } + if filename == "" { + return "", fmt.Errorf("上传文件名不能为空") + } + + // 2. 打开本地文件 + file, err := os.Open(filepath) + if err != nil { + return "", fmt.Errorf("打开本地文件失败: %w, 路径: %s", err, filepath) + } + defer file.Close() // 延迟关闭文件,确保资源释放 + + // 3. 构建multipart/form-data请求体 + bodyBuf := &bytes.Buffer{} + bodyWriter := multipart.NewWriter(bodyBuf) + defer bodyWriter.Close() // 延迟关闭writer + + // 3.1 添加文件字段(对应PHP的'file'字段,设置上传文件名) + fileWriter, err := bodyWriter.CreateFormFile("file", filename) + if err != nil { + return "", fmt.Errorf("创建文件表单字段失败: %w", err) + } + // 复制文件内容到请求体 + if _, err := io.Copy(fileWriter, file); err != nil { + return "", fmt.Errorf("复制文件内容到请求体失败: %w", err) + } + + // 4. 创建HTTP POST请求 + contentType := bodyWriter.FormDataContentType() + req, err := http.NewRequest(http.MethodPost, baiduUploadURL, bodyBuf) + if err != nil { + return "", fmt.Errorf("创建HTTP请求失败: %w", err) + } + + // 5. 设置请求头(对齐PHP的请求头配置) + setBaiduRequestHeaders(req, contentType) + + // 6. 发送HTTP请求 + client := &http.Client{Timeout: baiduTimeout} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("发送HTTP请求失败: %w", err) + } + defer resp.Body.Close() // 延迟关闭响应体 + + // 7. 读取响应内容 + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("读取响应内容失败: %w", err) + } + + // 8. 解析JSON响应 + var baiduResp BaiduUploadResponse + if err := json.Unmarshal(respBody, &baiduResp); err != nil { + return "", fmt.Errorf("解析JSON响应失败: %w, 响应内容: %s", err, string(respBody)) + } + + // 9. 校验响应结果(对应PHP的isset($arr['link'])) + if baiduResp.Link == "" { + return "", fmt.Errorf("上传失败!接口错误,响应内容: %s", string(respBody)) + } + + // 10. 替换域名(对应PHP的str_replace操作) + imgURL := strings.Replace(baiduResp.Link, ".cdn.bcebos.com", ".bj.bcebos.com", 1) + + return imgURL, nil +} + +// setBaiduRequestHeaders 设置百度图床必需的请求头 +func setBaiduRequestHeaders(req *http.Request, contentType string) { + // 设置Content-Type(包含multipart boundary) + req.Header.Set("Content-Type", contentType) + // 设置Referer(PHP中的$referer) + req.Header.Set("Referer", baiduReferer) + // 设置Cookie(PHP中的$cookie) + req.Header.Set("Cookie", baiduCookie) + // 补充必要的请求头,提升兼容性 + req.Header.Set("Connection", "keep-alive") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36") + req.Header.Set("Accept", "*/*") + req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9") + req.Header.Set("Accept-Encoding", "gzip, deflate, br") +} + +// 示例调用 +func main() { + // 本地文件路径(请替换为实际的图片路径) + localFilePath := "./test.jpg" + // 上传时的文件名(可自定义,对应PHP的setPostFilename) + uploadFilename := "custom-test.jpg" + + // 调用百度图床上传函数 + imgURL, err := BaiduImgUpload(localFilePath, uploadFilename) + if err != nil { + fmt.Printf("百度图床上传失败: %v\n", err) + return + } + + fmt.Printf("百度图床上传成功,图片URL: %s\n", imgURL) +} diff --git a/common/contrib/files/img/go.mod b/common/contrib/files/img/go.mod new file mode 100644 index 00000000..b211204a --- /dev/null +++ b/common/contrib/files/img/go.mod @@ -0,0 +1,3 @@ +module blazing/contrib/files/cnd58 + +go 1.25.0 diff --git a/common/contrib/files/imgdd/go.mod b/common/contrib/files/imgdd/go.mod new file mode 100644 index 00000000..6e08f1c0 --- /dev/null +++ b/common/contrib/files/imgdd/go.mod @@ -0,0 +1,3 @@ +module blazing/contrib/files/imgdd + +go 1.25.0 diff --git a/common/contrib/files/imgdd/imgdd.go b/common/contrib/files/imgdd/imgdd.go new file mode 100644 index 00000000..6b79d52c --- /dev/null +++ b/common/contrib/files/imgdd/imgdd.go @@ -0,0 +1,147 @@ +package main + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "time" +) + +// 全局配置(与PHP代码保持一致) +const ( + // imgdd上传接口地址 + imgddUploadURL = "https://imgdd.com/upload" + // 必需的Referer + imgddReferer = "https://imgdd.com/" + // 请求超时时间 + imgddTimeout = 30 * time.Second +) + +// ImgddUploadResponse imgdd接口返回JSON结构 +// 对应PHP响应中的url和message字段 +type ImgddUploadResponse struct { + URL string `json:"url"` // 上传成功返回的图片URL + Message string `json:"message"` // 上传失败返回的提示信息 +} + +// ImgddImgUpload imgdd图床上传函数,完全对齐PHP功能 +// filepath: 本地文件路径 +// filename: 上传时的自定义文件名(对应PHP的setPostFilename) +// 返回值: 图片URL,错误信息 +func ImgddImgUpload(filepath string, filename string) (string, error) { + // 1. 参数校验 + if filepath == "" { + return "", errors.New("本地文件路径不能为空") + } + if filename == "" { + return "", errors.New("上传文件名不能为空") + } + + // 2. 打开本地文件 + file, err := os.Open(filepath) + if err != nil { + return "", fmt.Errorf("打开本地文件失败: %w, 路径: %s", err, filepath) + } + defer file.Close() // 延迟关闭文件,确保资源释放 + + // 3. 构建multipart/form-data请求体 + bodyBuf := &bytes.Buffer{} + bodyWriter := multipart.NewWriter(bodyBuf) + defer bodyWriter.Close() // 延迟关闭writer,确保请求体完整 + + // 3.1 添加文件字段(字段名"image",对应PHP的'image' => $file,设置自定义上传文件名) + fileWriter, err := bodyWriter.CreateFormFile("image", filename) + if err != nil { + return "", fmt.Errorf("创建文件表单字段失败: %w", err) + } + + // 3.2 复制文件内容到请求体 + if _, err := io.Copy(fileWriter, file); err != nil { + return "", fmt.Errorf("复制文件内容到请求体失败: %w", err) + } + + // 4. 创建HTTP POST请求 + contentType := bodyWriter.FormDataContentType() // 自动包含boundary,无需手动拼接 + req, err := http.NewRequest(http.MethodPost, imgddUploadURL, bodyBuf) + if err != nil { + return "", fmt.Errorf("创建HTTP请求失败: %w", err) + } + + // 5. 设置请求头(与PHP代码保持一致) + setImgddRequestHeaders(req, contentType) + + // 6. 发送HTTP请求 + client := &http.Client{ + Timeout: imgddTimeout, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, // 注意:字段名是 InsecureSkipVerify(非 TLSInsecureSkipVerify) + }, + }, + } + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("发送HTTP请求失败: %w", err) + } + defer resp.Body.Close() // 延迟关闭响应体 + + // 7. 读取响应内容 + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("读取响应内容失败: %w", err) + } + + // 8. 解析JSON响应 + var imgddResp ImgddUploadResponse + if err := json.Unmarshal(respBody, &imgddResp); err != nil { + return "", fmt.Errorf("解析JSON响应失败: %w, 响应内容: %s", err, string(respBody)) + } + + // 9. 按PHP逻辑判断响应结果 + if imgddResp.URL != "" { + // 存在url字段,上传成功 + return imgddResp.URL, nil + } else if imgddResp.Message != "" { + // 存在message字段,返回对应错误 + return "", fmt.Errorf("上传失败请重试(%s)", imgddResp.Message) + } else { + // 既无url也无message,接口错误 + return "", fmt.Errorf("上传失败!接口错误,响应内容: %s", string(respBody)) + } +} + +// setImgddRequestHeaders 设置imgdd必需的请求头 +func setImgddRequestHeaders(req *http.Request, contentType string) { + // 设置Content-Type(包含multipart boundary) + req.Header.Set("Content-Type", contentType) + // 设置Referer(与PHP一致,接口可能做了来源校验) + req.Header.Set("Referer", imgddReferer) + // 补充通用请求头,提升兼容性 + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36") + req.Header.Set("Connection", "keep-alive") + req.Header.Set("Accept", "*/*") + req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9") + req.Header.Set("Accept-Encoding", "gzip, deflate, br") +} + +// 示例调用 +func main() { + // 配置本地文件路径和上传文件名(请替换为实际值) + localFilePath := "./test.jpg" // 本地图片实际路径 + uploadFilename := "custom-img.jpg" // 上传时的自定义文件名(可包含后缀) + + // 调用imgdd上传函数 + imgURL, err := ImgddImgUpload(localFilePath, uploadFilename) + if err != nil { + fmt.Printf("imgdd图床上传失败: %v\n", err) + return + } + + fmt.Printf("imgdd图床上传成功,图片URL: %s\n", imgURL) +} diff --git a/common/contrib/files/locimg/go.mod b/common/contrib/files/locimg/go.mod new file mode 100644 index 00000000..3340ea78 --- /dev/null +++ b/common/contrib/files/locimg/go.mod @@ -0,0 +1,3 @@ +module blazing/contrib/files/locimg + +go 1.25.0 diff --git a/common/contrib/files/locimg/locimg.go b/common/contrib/files/locimg/locimg.go new file mode 100644 index 00000000..459c51d4 --- /dev/null +++ b/common/contrib/files/locimg/locimg.go @@ -0,0 +1,187 @@ +package main + +import ( + "bytes" + "compress/gzip" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "os" + "time" +) + +// 全局配置(与PHP代码保持完全一致) +const ( + // locimg上传接口地址 + locimgUploadURL = "https://yunimg.cc/upload/upload.html" + // 必需的Referer + locimgReferer = "https://yunimg.cc/" + // 固定User-Agent(与PHP的curl配置一致) + locimgUA = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36" + // 请求超时时间 + locimgTimeout = 30 * time.Second + // 文件字段固定MIME类型(对应PHP的CURLFile第二个参数) + locimgFileMIME = "image/jpeg" +) + +// LocimgData 响应数据中的data结构体(对应PHP的$arr['data']) +type LocimgData struct { + URL string `json:"url"` // 图片URL字段 +} + +// LocimgUploadResponse locimg接口返回JSON结构(对应PHP的响应格式) +type LocimgUploadResponse struct { + Data LocimgData `json:"data"` // 成功返回的数据字段 + Msg string `json:"msg"` // 失败返回的提示信息 +} + +// LocimgImgUpload locimg图床上传函数,完全对齐PHP功能 +// filepath: 本地文件路径 +// filename: 上传文件名(包含后缀,对应PHP的$filename) +// 返回值: 最终图片URL,错误信息 +func LocimgImgUpload(filepath string, filename string) (string, error) { + // 1. 参数校验 + if filepath == "" { + return "", errors.New("本地文件路径不能为空") + } + if filename == "" { + return "", errors.New("上传文件名不能为空") + } + + // 2. 打开本地文件 + file, err := os.Open(filepath) + if err != nil { + return "", fmt.Errorf("打开本地文件失败: %w, 路径: %s", err, filepath) + } + defer file.Close() // 延迟关闭文件,确保资源释放 + + // 3. 构建multipart/form-data请求体(包含文件字段和普通表单字段) + bodyBuf := &bytes.Buffer{} + bodyWriter := multipart.NewWriter(bodyBuf) + defer bodyWriter.Close() // 延迟关闭writer,确保请求体完整 + + // 3.1 添加文件字段:"image"(修复核心:使用textproto.MIMEHeader构建头部) + // 步骤1:创建textproto.MIMEHeader实例 + mimeHeader := textproto.MIMEHeader{} + // 步骤2:设置Content-Disposition,指定表单字段名和文件名(必须用双引号包裹) + mimeHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="image"; filename="%s"`, filename)) + // 步骤3:设置Content-Type,指定固定MIME类型 + mimeHeader.Set("Content-Type", locimgFileMIME) + // 步骤4:传入CreatePart(mimeHeader会自动转为指针类型) + fileWriter, err := bodyWriter.CreatePart(mimeHeader) + if err != nil { + return "", fmt.Errorf("创建文件表单字段失败: %w", err) + } + // 复制文件内容到请求体(保持不变) + if _, err := io.Copy(fileWriter, file); err != nil { + return "", fmt.Errorf("复制文件内容到请求体失败: %w", err) + } + + // 3.2 添加普通表单字段:"fileId"(对应PHP的'fileId' => $filename) + if err := bodyWriter.WriteField("fileId", filename); err != nil { + return "", fmt.Errorf("写入表单字段fileId失败: %w", err) + } + + // 4. 创建HTTP POST请求 + contentType := bodyWriter.FormDataContentType() // 自动包含boundary,无需手动拼接 + req, err := http.NewRequest(http.MethodPost, locimgUploadURL, bodyBuf) + if err != nil { + return "", fmt.Errorf("创建HTTP请求失败: %w", err) + } + + // 5. 设置请求头(完全对齐PHP的curl请求头配置) + setLocimgRequestHeaders(req, contentType) + + // 6. 配置HTTP客户端(支持gzip解码、忽略SSL验证、超时控制) + client := &http.Client{ + Timeout: locimgTimeout, + Transport: &http.Transport{ + // 忽略SSL证书验证(对应PHP的CURLOPT_SSL_VERIFYPEER=false/CURLOPT_SSL_VERIFYHOST=false) + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, // 注意:字段名是 InsecureSkipVerify(非 TLSInsecureSkipVerify) + }, + // 支持gzip解码(对应PHP的CURLOPT_ENCODING "gzip") + DisableCompression: false, + }, + } + + // 7. 发送HTTP请求 + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("发送HTTP请求失败: %w", err) + } + defer resp.Body.Close() // 延迟关闭响应体 + + // 8. 读取并解码响应内容(处理gzip压缩响应) + var respBody []byte + if resp.Header.Get("Content-Encoding") == "gzip" { + // 解压gzip响应(对应PHP的gzip解码) + gzipReader, err := gzip.NewReader(resp.Body) + if err != nil { + return "", fmt.Errorf("创建gzip解压阅读器失败: %w", err) + } + defer gzipReader.Close() + respBody, err = io.ReadAll(gzipReader) + } else { + respBody, err = io.ReadAll(resp.Body) + } + if err != nil { + return "", fmt.Errorf("读取响应内容失败: %w", err) + } + + // 9. 解析JSON响应 + var locimgResp LocimgUploadResponse + if err := json.Unmarshal(respBody, &locimgResp); err != nil { + return "", fmt.Errorf("解析JSON响应失败: %w, 响应内容: %s", err, string(respBody)) + } + + // 10. 按PHP逻辑判断响应结果 + if locimgResp.Data.URL != "" { + // 存在data.url字段,上传成功 + return locimgResp.Data.URL, nil + } else if locimgResp.Msg != "" { + // 存在msg字段,返回对应错误 + return "", fmt.Errorf("上传失败请重试(%s)", locimgResp.Msg) + } else { + // 既无data.url也无msg,接口错误 + return "", fmt.Errorf("上传失败!接口错误,响应内容: %s", string(respBody)) + } +} + +// setLocimgRequestHeaders 设置locimg必需的请求头,完全对齐PHP的curl配置 +func setLocimgRequestHeaders(req *http.Request, contentType string) { + // 默认请求头(对应PHP的$httpheader数组) + req.Header.Set("Accept", "*/*") + req.Header.Set("Accept-Encoding", "gzip,deflate,sdch") + req.Header.Set("Accept-Language", "zh-CN,zh;q=0.8") + req.Header.Set("Connection", "close") // 对应PHP的Connection: close + req.Header.Set("User-Agent", locimgUA) // 固定User-Agent,与PHP一致 + + // 额外请求头(对应PHP的$addheader参数:X-Requested-With: XMLHttpRequest) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + + // 核心请求头 + req.Header.Set("Content-Type", contentType) // 包含multipart boundary + req.Header.Set("Referer", locimgReferer) // 来源校验,与PHP一致 +} + +// 示例调用 +func main() { + // 配置本地文件路径和上传文件名(请替换为实际值) + localFilePath := "./test.jpg" // 本地图片实际路径 + uploadFilename := "custom-locimg.jpg" // 上传时的自定义文件名(包含后缀) + + // 调用locimg上传函数 + imgURL, err := LocimgImgUpload(localFilePath, uploadFilename) + if err != nil { + fmt.Printf("locimg图床上传失败: %v\n", err) + return + } + + fmt.Printf("locimg图床上传成功,图片URL: %s\n", imgURL) +} diff --git a/common/contrib/files/pngcm/go.mod b/common/contrib/files/pngcm/go.mod index a97c9dcf..d0b50e10 100644 --- a/common/contrib/files/pngcm/go.mod +++ b/common/contrib/files/pngcm/go.mod @@ -7,11 +7,12 @@ require github.com/gogf/gf/v2 v2.8.0 require ( github.com/BurntSushi/toml v1.4.0 // indirect github.com/clbanning/mxj/v2 v2.7.0 // indirect + github.com/emirpasic/gods v1.18.1 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/gorilla/websocket v1.5.1 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/grokify/html-strip-tags-go v0.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/magiconair/properties v1.8.7 // indirect diff --git a/common/contrib/files/pngcm/go.sum b/common/contrib/files/pngcm/go.sum index cf9ab1c3..9131e734 100644 --- a/common/contrib/files/pngcm/go.sum +++ b/common/contrib/files/pngcm/go.sum @@ -1,22 +1,30 @@ github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= - +github.com/gogf/gf/v2 v2.8.0 h1:CgNDoLFQCBxQaWOoGMzgU068T+tm0t/eNUgLV2wPJag= +github.com/gogf/gf/v2 v2.8.0/go.mod h1:6iYuZZ+A0ZcH8+4MDS/P0SvTPCvKzRvyAsY1kbkJYJc= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= -github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grokify/html-strip-tags-go v0.1.0 h1:03UrQLjAny8xci+R+qjCce/MYnpNXCtgzltlQbOBae4= github.com/grokify/html-strip-tags-go v0.1.0/go.mod h1:ZdzgfHEzAfz9X6Xe5eBLVblWIxXfYSQ40S/VKrAOGpc= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -33,12 +41,16 @@ github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZ github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= @@ -48,11 +60,15 @@ go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35 go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/common/contrib/files/zhuanzhuan/go.mod b/common/contrib/files/zhuanzhuan/go.mod new file mode 100644 index 00000000..48818465 --- /dev/null +++ b/common/contrib/files/zhuanzhuan/go.mod @@ -0,0 +1,3 @@ +module blazing/contrib/files/zhuanzhuan + +go 1.25.0 diff --git a/common/contrib/files/zhuanzhuan/zhuanzhuan.go b/common/contrib/files/zhuanzhuan/zhuanzhuan.go new file mode 100644 index 00000000..0317ca28 --- /dev/null +++ b/common/contrib/files/zhuanzhuan/zhuanzhuan.go @@ -0,0 +1,148 @@ +package zhuanzhuan + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "time" +) + +// 全局配置 - 建议通过环境变量或配置文件加载,此处保持原有结构 +const ( + uploadURL = "https://dsyy.zhuanzhuan.com/uploadFile" + savePath = "./uploads" // 文件保存根路径,需替换为实际路径 + timeout = 30 * time.Second + defaultCT = "image/jpeg" +) + +// UploadResponse 定义接口返回的JSON结构 +type UploadResponse struct { + Status bool `json:"status"` + Data []UploadData `json:"data"` +} + +// UploadData 定义返回数据中的单个文件信息 +type UploadData struct { + URL string `json:"url"` +} + +// UploadFile 上传文件到指定接口 +// fileKey: 文件标识(不含后缀),接口会拼接 .jpg 查找本地文件 +// 返回值: 上传成功后的文件URL,错误信息 +func UploadFile(fileKey string) (string, error) { + // 1. 校验参数 + if fileKey == "" { + return "", fmt.Errorf("fileKey 不能为空") + } + + // 2. 拼接本地文件路径 + fileName := fmt.Sprintf("%s.jpg", fileKey) + localPath := filepath.Join(savePath, fileName) + + // 3. 检查并打开文件 + file, err := os.Open(localPath) + if err != nil { + return "", fmt.Errorf("打开本地文件失败: %w, 路径: %s", err, localPath) + } + defer file.Close() // 延迟关闭文件,确保资源释放 + + // 4. 构建multipart/form-data请求体 + bodyBuf := &bytes.Buffer{} + bodyWriter := multipart.NewWriter(bodyBuf) + defer bodyWriter.Close() // 延迟关闭writer,简化代码 + + // 4.1 添加表单字段(picDirType) + if err := bodyWriter.WriteField("picDirType", "1"); err != nil { + return "", fmt.Errorf("写入表单字段 picDirType 失败: %w", err) + } + + // 4.2 添加文件字段 + fileWriter, err := bodyWriter.CreateFormFile("uploadfiles", fileName) + if err != nil { + return "", fmt.Errorf("创建文件表单字段失败: %w", err) + } + // 复制文件内容到请求体 + if _, err := io.Copy(fileWriter, file); err != nil { + return "", fmt.Errorf("复制文件内容到请求体失败: %w", err) + } + + // 5. 创建HTTP POST请求 + contentType := bodyWriter.FormDataContentType() // 自动包含boundary,无需手动拼接 + req, err := http.NewRequest(http.MethodPost, uploadURL, bodyBuf) + if err != nil { + return "", fmt.Errorf("创建HTTP请求失败: %w", err) + } + + // 6. 设置请求头(对齐原有Java版本请求头) + setRequestHeaders(req, contentType) + + // 7. 发送HTTP请求 + client := &http.Client{Timeout: timeout} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("发送HTTP请求失败: %w", err) + } + defer resp.Body.Close() // 延迟关闭响应体 + + // 8. 读取响应内容 + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("读取响应内容失败: %w", err) + } + + // 9. 解析JSON响应 + var uploadResp UploadResponse + if err := json.Unmarshal(respBody, &uploadResp); err != nil { + return "", fmt.Errorf("解析JSON响应失败: %w, 响应内容: %s", err, string(respBody)) + } + + // 10. 校验响应结果并提取URL + if err := validateUploadResponse(uploadResp, respBody); err != nil { + return "", err + } + + return uploadResp.Data[0].URL, nil +} + +// setRequestHeaders 设置HTTP请求头,抽离为独立函数提升可读性 +func setRequestHeaders(req *http.Request, contentType string) { + req.Header.Set("Connection", "keep-alive") + req.Header.Set("Accept", "*/*") + req.Header.Set("Origin", "https://m.zhuanzhuan.com") + req.Header.Set("User-Agent", "Mozilla/5.0") + req.Header.Set("Content-Type", contentType) + req.Header.Set("Referer", "https://m.zhuanzhuan.com/ec/") + req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9") + req.Header.Set("Accept-Encoding", "gzip,deflate") +} + +// validateUploadResponse 校验上传响应结果,抽离为独立函数 +func validateUploadResponse(resp UploadResponse, respBody []byte) error { + if !resp.Status { + return fmt.Errorf("接口返回失败状态, 响应内容: %s", string(respBody)) + } + if len(resp.Data) == 0 { + return fmt.Errorf("接口返回数据为空, 响应内容: %s", string(respBody)) + } + if resp.Data[0].URL == "" { + return fmt.Errorf("接口返回URL为空, 响应内容: %s", string(respBody)) + } + return nil +} + +// 示例调用 +func main() { + // 替换为实际的文件标识(不含.jpg后缀) + fileKey := "test-file-key" + url, err := UploadFile(fileKey) + if err != nil { + fmt.Printf("文件上传失败: %v\n", err) + return + } + fmt.Printf("文件上传成功,访问URL: %s\n", url) +}