From 7229dfa1b7799e4fc6d5cbebe748f01849643785 Mon Sep 17 00:00:00 2001 From: RainySY Date: Tue, 21 Apr 2026 14:48:11 +0800 Subject: [PATCH] refactor: decouple services from global config and clean up structure - Remove duplicate UploadBaseURL constant (identical to APIBaseURL) - Pass parentFileID and customDomain into service constructors instead of reading from config.GlobalConfig at call time, eliminating hidden global state dependencies in the service layer - Replace []interface{} response building in HandleList with a typed imageResponse struct for compile-time safety - Extract inline CORS closure from main.go into handler.CORSMiddleware(), consistent with how AuthMiddleware is organized - Remove narrating comments throughout; keep only the non-obvious one explaining why DoRawPUT omits auth headers Co-Authored-By: Claude Sonnet 4.6 --- cmd/main.go | 39 +++++------------- internal/handler/image.go | 55 +++++++++++-------------- internal/handler/router.go | 32 +++++++++------ internal/handler/upload.go | 30 ++++---------- internal/pan123/client.go | 37 ++++++----------- internal/pan123/file.go | 18 ++------- internal/pan123/upload.go | 33 +++------------ internal/service/image_service.go | 29 ++++++------- internal/service/upload_service.go | 65 ++++++++++++------------------ 9 files changed, 120 insertions(+), 218 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index f5baba6..044ad96 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -17,49 +17,28 @@ func main() { flag.StringVar(&cfgFlag, "c", "conf/config.yaml", "指定配置文件路径") flag.Parse() - // 1. 初始化读取配置 config.InitConfig(cfgFlag) + cfg := config.GlobalConfig - // 2. 注入 123pan 客户端基础 SDK - client := pan123.NewClient(config.GlobalConfig.ClientID, config.GlobalConfig.ClientSecret) + client := pan123.NewClient(cfg.ClientID, cfg.ClientSecret) - // 3. 构造上层 Service 业务操作对象 - uploadSvc := service.NewUploadService(client) - imageSvc := service.NewImageService(client) + uploadSvc := service.NewUploadService(client, cfg.ParentFileID, cfg.CustomDomain) + imageSvc := service.NewImageService(client, cfg.ParentFileID, cfg.CustomDomain) - // 4. 组装 Web Handler 层 uploadHandler := handler.NewUploadHandler(uploadSvc) imageHandler := handler.NewImageHandler(imageSvc) - // 5. 启动 Gin 骨架 gin.SetMode(gin.ReleaseMode) r := gin.Default() - - // 【安全防护】:将单次 Multipart 表单承受的最高物理占用内存限制为 10MB - // 若超过此量则会被拒绝或直接下刷到临时文件盘,彻底免疫大发包引起的 OOM 宕机 - r.MaxMultipartMemory = 10 << 20 - - // 提供一个基础版的跨域中间件(如果后续要在其他域名环境挂载 API 小组件的话会用到) - r.Use(func(c *gin.Context) { - c.Writer.Header().Set("Access-Control-Allow-Origin", "*") - c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE") - c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") - if c.Request.Method == "OPTIONS" { - c.AbortWithStatus(204) - return - } - c.Next() - }) + r.MaxMultipartMemory = 10 << 20 + r.Use(handler.CORSMiddleware()) handler.ConfigureRoutes(r, imageHandler, uploadHandler) - addr := fmt.Sprintf(":%d", config.GlobalConfig.Port) - log.Printf("==============================") - log.Printf("123pan 私人图床服务已启动,并挂载高危防御盾牌!") - log.Printf("请访问 http://localhost%s 浏览!", addr) - log.Printf("==============================") + addr := fmt.Sprintf(":%d", cfg.Port) + log.Printf("服务已启动,访问 http://localhost%s", addr) if err := r.Run(addr); err != nil { - log.Fatalf("Fatal: Web 服务启动暴毙了: %v", err) + log.Fatalf("服务启动失败: %v", err) } } diff --git a/internal/handler/image.go b/internal/handler/image.go index ecfc6fb..7550ba9 100644 --- a/internal/handler/image.go +++ b/internal/handler/image.go @@ -17,60 +17,51 @@ func NewImageHandler(svc *service.ImageService) *ImageHandler { return &ImageHandler{imageSvc: svc} } -// HandleList 获取图床目录下所有图片 +type imageResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Size int64 `json:"size"` + URL string `json:"url"` + OriginURL string `json:"origin_url"` + CreatedAt string `json:"created_at"` +} + func (h *ImageHandler) HandleList(c *gin.Context) { items, err := h.imageSvc.GetImageItems() if err != nil { log.Printf("查询图床列表失败: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{ - "code": 500, - "message": err.Error(), - }) + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()}) return } - // 仅返回是文件的信息 (过滤可能存在的子文件夹) - var images []interface{} + images := make([]imageResponse, 0, len(items)) for _, it := range items { - // 123pan 中 type 通常 0是文件,1是文件夹,不过创建文件时type又传的1...这里我们只输出有 size 或文件形态的 - images = append(images, gin.H{ - "id": it.FileID, - "name": it.Filename, - "size": it.Size, - "url": it.UserSelfURL, - "origin_url": it.DownloadURL, - "created_at": it.CreateAt, + images = append(images, imageResponse{ + ID: it.FileID, + Name: it.Filename, + Size: it.Size, + URL: it.UserSelfURL, + OriginURL: it.DownloadURL, + CreatedAt: it.CreateAt, }) } - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "message": "success", - "data": images, - }) + c.JSON(http.StatusOK, gin.H{"code": 0, "message": "success", "data": images}) } -// HandleDelete 删除单个图床图片 func (h *ImageHandler) HandleDelete(c *gin.Context) { - id := c.Param("id") // 删除支持 逗号分割多选或者单个记录 + id := c.Param("id") if id == "" { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "图片 ID 为空"}) return } ids := strings.Split(id, ",") - err := h.imageSvc.DeleteImages(ids) - if err != nil { + if err := h.imageSvc.DeleteImages(ids); err != nil { log.Printf("删除图片 %v 失败: %v", ids, err) - c.JSON(http.StatusInternalServerError, gin.H{ - "code": 500, - "message": err.Error(), - }) + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "message": "deleted", - }) + c.JSON(http.StatusOK, gin.H{"code": 0, "message": "deleted"}) } diff --git a/internal/handler/router.go b/internal/handler/router.go index cd47aa1..e3efbb0 100644 --- a/internal/handler/router.go +++ b/internal/handler/router.go @@ -10,17 +10,27 @@ import ( "imagehost/static" ) -// AuthMiddleware 核心 API 保护护卫 +func CORSMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(http.StatusNoContent) + return + } + c.Next() + } +} + func AuthMiddleware() gin.HandlerFunc { return func(c *gin.Context) { tokenCfg := config.GlobalConfig.APIToken - // 如果配置文件放空了代表使用者放弃防御 if tokenCfg == "" { c.Next() return } - // 适配支持 Header 注入 (WEB端拦截器) 或者 查询字符串 (简化图床软件推送提取) authHeader := c.GetHeader("Authorization") queryToken := c.Query("token") @@ -33,8 +43,7 @@ func AuthMiddleware() gin.HandlerFunc { clientToken = queryToken } - // 【防护高级演练】拦截网络侧信道 Timing 定时猜测攻击 - // (通过将比较强行推至相同的微小汇编运行时间从而防止密钥逐字枚举) + // constant-time compare prevents timing-based token enumeration if subtle.ConstantTimeCompare([]byte(clientToken), []byte(tokenCfg)) != 1 { c.JSON(http.StatusForbidden, gin.H{ "code": 403, @@ -43,23 +52,20 @@ func AuthMiddleware() gin.HandlerFunc { c.Abort() return } - + c.Next() } } -// ConfigureRoutes 注册所有的 API 路由和静态资源 func ConfigureRoutes(r *gin.Engine, imgHandler *ImageHandler, upHandler *UploadHandler) { - // API 分组进行统一下挂网门,使用中间件强权控制 api := r.Group("/api") - api.Use(AuthMiddleware()) // 全部保护! + api.Use(AuthMiddleware()) { - api.POST("/upload", upHandler.HandleUpload) // 接收表单 file 字段传图 - api.GET("/images", imgHandler.HandleList) // 瀑布流展示列表获取 - api.DELETE("/images/:id", imgHandler.HandleDelete) // 删除图片 + api.POST("/upload", upHandler.HandleUpload) + api.GET("/images", imgHandler.HandleList) + api.DELETE("/images/:id", imgHandler.HandleDelete) } - // 静态资源:图床首页前端,这部分无需密码即可被公网下载渲染 // 【全静态内嵌化架构升级】无须再依赖同目录下的 static 文件夹! r.StaticFS("/static", http.FS(static.FS)) r.GET("/", func(c *gin.Context) { htmlData, err := static.FS.ReadFile("index.html") diff --git a/internal/handler/upload.go b/internal/handler/upload.go index 51691bd..e7defe0 100644 --- a/internal/handler/upload.go +++ b/internal/handler/upload.go @@ -18,22 +18,16 @@ func NewUploadHandler(svc *service.UploadService) *UploadHandler { return &UploadHandler{uploadSvc: svc} } -// HandleUpload 接受 /api/upload 的 POST 表单请求 func (h *UploadHandler) HandleUpload(c *gin.Context) { - // 'file' 是约定好的 form 表单 file 键值,支持 ShareX / PicGo 等 fileHeader, err := c.FormFile("file") if err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "code": 400, - "message": "无法收到图片文件: " + err.Error(), - }) + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "无法收到图片文件: " + err.Error()}) return } - // 【安全防护1】:强行判定后缀格式,禁止脚本上传渗透 ext := strings.ToLower(filepath.Ext(fileHeader.Filename)) validExts := map[string]bool{ - ".jpg": true, ".jpeg": true, ".png": true, + ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".svg": true, ".bmp": true, } if !validExts[ext] { @@ -44,32 +38,24 @@ func (h *UploadHandler) HandleUpload(c *gin.Context) { return } - contentType := fileHeader.Header.Get("Content-Type") - if !strings.HasPrefix(contentType, "image/") { - c.JSON(http.StatusUnsupportedMediaType, gin.H{ - "code": 415, - "message": "不支持的 Content-Type 类型。", - }) + if !strings.HasPrefix(fileHeader.Header.Get("Content-Type"), "image/") { + c.JSON(http.StatusUnsupportedMediaType, gin.H{"code": 415, "message": "不支持的 Content-Type 类型。"}) return } - log.Printf("准备上传文件: %s", fileHeader.Filename) + log.Printf("[上传] 接收文件: %s", fileHeader.Filename) fileInfo, err := h.uploadSvc.UploadFile(fileHeader) if err != nil { - log.Printf("上传业务失败: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{ - "code": 500, - "message": err.Error(), - }) + log.Printf("[上传] 失败: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()}) return } - // 适配外部图床响应。ShareX/PicGo 中可以通过 json_path 指定取 url c.JSON(http.StatusOK, gin.H{ "code": 0, "message": "success", "data": fileInfo, - "url": fileInfo.UserSelfURL, + "url": fileInfo.UserSelfURL, }) } diff --git a/internal/pan123/client.go b/internal/pan123/client.go index 8f0ea83..c559b3c 100644 --- a/internal/pan123/client.go +++ b/internal/pan123/client.go @@ -11,36 +11,31 @@ import ( ) const ( - APIBaseURL = "https://open-api.123pan.com" - UploadBaseURL = "https://open-api.123pan.com" - PlatformName = "open_platform" + APIBaseURL = "https://open-api.123pan.com" + PlatformName = "open_platform" ) type Client struct { httpClient *http.Client clientID string clientSecret string - + mu sync.RWMutex token string expiredAt time.Time } -// NewClient 初始化 123pan API 的定制化客户端 func NewClient(clientID, clientSecret string) *Client { return &Client{ - httpClient: &http.Client{ - Timeout: 30 * time.Second, // 设置超时防阻塞 - }, + httpClient: &http.Client{Timeout: 30 * time.Second}, clientID: clientID, clientSecret: clientSecret, } } -// getToken 安全获取当前有效的 token,必要时自动刷新并缓存 func (c *Client) getToken() (string, error) { c.mu.RLock() - // 提前 5 分钟刷新以防恰好在请求过程中过期 + // refresh 5 minutes before expiry to avoid mid-request expiration if c.token != "" && time.Now().Before(c.expiredAt.Add(-5*time.Minute)) { t := c.token c.mu.RUnlock() @@ -50,7 +45,6 @@ func (c *Client) getToken() (string, error) { c.mu.Lock() defer c.mu.Unlock() - // 双重检查锁定 if c.token != "" && time.Now().Before(c.expiredAt.Add(-5*time.Minute)) { return c.token, nil } @@ -65,13 +59,12 @@ func (c *Client) getToken() (string, error) { if err != nil { return "", fmt.Errorf("create token request error: %w", err) } - req.Header.Set("Content-Type", "application/json") req.Header.Set("Platform", PlatformName) resp, err := c.httpClient.Do(req) if err != nil { - return "", fmt.Errorf("http execute token request failed: %w", err) + return "", fmt.Errorf("token request failed: %w", err) } defer resp.Body.Close() @@ -79,7 +72,7 @@ func (c *Client) getToken() (string, error) { if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { return "", fmt.Errorf("decode token response error: %w", err) } - + if apiResp.Code != 0 { return "", fmt.Errorf("123pan API error (get token), code: %d, msg: %s", apiResp.Code, apiResp.Message) } @@ -91,7 +84,6 @@ func (c *Client) getToken() (string, error) { exp, err := time.Parse(time.RFC3339, data.ExpiredAt) if err != nil { - // 解析失败时给个保守过期时间 exp = time.Now().Add(2 * time.Hour) } @@ -100,7 +92,6 @@ func (c *Client) getToken() (string, error) { return c.token, nil } -// DoJSONRequest 发起基于 JSON 的标准请求,并自动装配所需的三大件 Header func (c *Client) DoJSONRequest(method, url string, body interface{}, target interface{}) error { var bodyReader io.Reader if body != nil { @@ -121,7 +112,6 @@ func (c *Client) DoJSONRequest(method, url string, body interface{}, target inte return fmt.Errorf("获取 123pan AccessToken 失败: %w", err) } - // 123pan 开放平台标准 Header 规范 req.Header.Set("Content-Type", "application/json") req.Header.Set("Platform", PlatformName) req.Header.Set("Authorization", "Bearer "+token) @@ -138,10 +128,9 @@ func (c *Client) DoJSONRequest(method, url string, body interface{}, target inte } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("http status code error %d, body: %s", resp.StatusCode, string(respBytes)) + return fmt.Errorf("http status %d, body: %s", resp.StatusCode, string(respBytes)) } - // 反序列化 123pan 返回的整体数据 (BaseResp 格式需在调用该处的人自己指定或由外包装封入) if target != nil { if err := json.Unmarshal(respBytes, target); err != nil { return fmt.Errorf("unmarshal response error: %w, payload: %s", err, string(respBytes)) @@ -151,27 +140,25 @@ func (c *Client) DoJSONRequest(method, url string, body interface{}, target inte return nil } -// DoRawPUT 对于实际的分片数据上传,需要剥离 Auth 头部并发起干净的 PUT 请求 +// DoRawPUT uploads binary data to a presigned URL without auth headers (required by 123pan). func (c *Client) DoRawPUT(url string, data io.Reader, size int64) error { req, err := http.NewRequest("PUT", url, data) if err != nil { return fmt.Errorf("create put request error: %w", err) } - // 对于纯二进制上传必须准确标注 Content-Type 和 Length req.Header.Set("Content-Type", "application/octet-stream") - req.ContentLength = size + req.ContentLength = size - // 文档强调: PUT请求的header中请不要携带Authorization、Platform参数 resp, err := c.httpClient.Do(req) if err != nil { - return fmt.Errorf("http put execute error: %w", err) + return fmt.Errorf("PUT request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { respBytes, _ := io.ReadAll(resp.Body) - return fmt.Errorf("http error code %d during PUT upload, body: %s", resp.StatusCode, string(respBytes)) + return fmt.Errorf("PUT http status %d, body: %s", resp.StatusCode, string(respBytes)) } return nil diff --git a/internal/pan123/file.go b/internal/pan123/file.go index 03647d2..24857c8 100644 --- a/internal/pan123/file.go +++ b/internal/pan123/file.go @@ -5,7 +5,6 @@ import ( "fmt" ) -// GetFileList 获取某个目录下面的(图片)文件列表 func (c *Client) GetFileList(parentFileID string, limit int, lastFileID string) ([]FileItem, string, error) { reqBody := FileListReq{ ParentFileID: parentFileID, @@ -15,37 +14,28 @@ func (c *Client) GetFileList(parentFileID string, limit int, lastFileID string) } var resp BaseResp - // 注意这里文档使用的是 APIBaseURL + /api/v1/oss/file/list - err := c.DoJSONRequest("POST", APIBaseURL+"/api/v1/oss/file/list", reqBody, &resp) - if err != nil { + if err := c.DoJSONRequest("POST", APIBaseURL+"/api/v1/oss/file/list", reqBody, &resp); err != nil { return nil, "", err } - if resp.Code != 0 { return nil, "", fmt.Errorf("123pan API error (list files), code: %d, msg: %s", resp.Code, resp.Message) } var data FileListRespData if err := json.Unmarshal(resp.Data, &data); err != nil { - return nil, "", fmt.Errorf("fail to decode file list data: %w", err) + return nil, "", fmt.Errorf("decode file list data error: %w", err) } return data.FileList, data.LastFileID, nil } -// DeleteFiles 根据 FileIDs 数组批量/单点删除远程文件 func (c *Client) DeleteFiles(fileIDs []string) error { - reqBody := DeleteFileReq{ - FileIDs: fileIDs, - } + reqBody := DeleteFileReq{FileIDs: fileIDs} var resp BaseResp - // 删除接口使用的是 /api/v1/oss/file/delete - err := c.DoJSONRequest("POST", APIBaseURL+"/api/v1/oss/file/delete", reqBody, &resp) - if err != nil { + if err := c.DoJSONRequest("POST", APIBaseURL+"/api/v1/oss/file/delete", reqBody, &resp); err != nil { return err } - if resp.Code != 0 { return fmt.Errorf("123pan API error (delete file), code: %d, msg: %s", resp.Code, resp.Message) } diff --git a/internal/pan123/upload.go b/internal/pan123/upload.go index c40e47d..b4d050f 100644 --- a/internal/pan123/upload.go +++ b/internal/pan123/upload.go @@ -5,7 +5,6 @@ import ( "fmt" ) -// CreateFile 步骤1: 创建文件/发卷预申报。123pan 会下发预上传 ID(preuploadID)或判定直接秒传。 func (c *Client) CreateFile(parentFileID, filename, etag string, size int64) (*CreateFileRespData, error) { reqBody := CreateFileReq{ ParentFileID: parentFileID, @@ -16,12 +15,9 @@ func (c *Client) CreateFile(parentFileID, filename, etag string, size int64) (*C } var resp BaseResp - // 上传接口大部分挂在 UploadBaseURL + /upload/v1/xxx - err := c.DoJSONRequest("POST", UploadBaseURL+"/upload/v1/oss/file/create", reqBody, &resp) - if err != nil { + if err := c.DoJSONRequest("POST", APIBaseURL+"/upload/v1/oss/file/create", reqBody, &resp); err != nil { return nil, err } - if resp.Code != 0 { return nil, fmt.Errorf("123pan API error (create file), code: %d, msg: %s", resp.Code, resp.Message) } @@ -30,11 +26,9 @@ func (c *Client) CreateFile(parentFileID, filename, etag string, size int64) (*C if err := json.Unmarshal(resp.Data, &data); err != nil { return nil, fmt.Errorf("decode create_file data error: %w", err) } - return &data, nil } -// GetUploadURL 步骤2: 凭借传入的 preuploadID 及 切片编号(对于整体直接设为1即可) 换取可以真正上传二进制的预签名 URL。 func (c *Client) GetUploadURL(preuploadID string, sliceNo int) (*GetUploadURLRespData, error) { reqBody := GetUploadURLReq{ PreuploadID: preuploadID, @@ -42,11 +36,9 @@ func (c *Client) GetUploadURL(preuploadID string, sliceNo int) (*GetUploadURLRes } var resp BaseResp - err := c.DoJSONRequest("POST", UploadBaseURL+"/upload/v1/oss/file/get_upload_url", reqBody, &resp) - if err != nil { + if err := c.DoJSONRequest("POST", APIBaseURL+"/upload/v1/oss/file/get_upload_url", reqBody, &resp); err != nil { return nil, err } - if resp.Code != 0 { return nil, fmt.Errorf("123pan API error (get upload url), code: %d, msg: %s", resp.Code, resp.Message) } @@ -55,22 +47,16 @@ func (c *Client) GetUploadURL(preuploadID string, sliceNo int) (*GetUploadURLRes if err := json.Unmarshal(resp.Data, &data); err != nil { return nil, fmt.Errorf("decode get_upload_url data error: %w", err) } - return &data, nil } -// UploadComplete 步骤4 (由于步骤3是调用泛化的 DoRawPUT 进行的纯数据上传): 当步骤3全部走完时,调用此 API 宣告物理上传完毕 func (c *Client) UploadComplete(preuploadID string) (*UploadCompleteRespData, error) { - reqBody := UploadCompleteReq{ - PreuploadID: preuploadID, - } + reqBody := UploadCompleteReq{PreuploadID: preuploadID} var resp BaseResp - err := c.DoJSONRequest("POST", UploadBaseURL+"/upload/v1/oss/file/upload_complete", reqBody, &resp) - if err != nil { + if err := c.DoJSONRequest("POST", APIBaseURL+"/upload/v1/oss/file/upload_complete", reqBody, &resp); err != nil { return nil, err } - if resp.Code != 0 { return nil, fmt.Errorf("123pan API error (upload complete), code: %d, msg: %s", resp.Code, resp.Message) } @@ -79,22 +65,16 @@ func (c *Client) UploadComplete(preuploadID string) (*UploadCompleteRespData, er if err := json.Unmarshal(resp.Data, &data); err != nil { return nil, fmt.Errorf("decode upload_complete data error: %w", err) } - return &data, nil } -// CheckAsyncResult 步骤5 (如需): 若上一步返回的数据指出 async == true 且 completed == false,那么需轮询此 API func (c *Client) CheckAsyncResult(preuploadID string) (*UploadAsyncResultRespData, error) { - reqBody := UploadAsyncResultReq{ - PreuploadID: preuploadID, - } + reqBody := UploadAsyncResultReq{PreuploadID: preuploadID} var resp BaseResp - err := c.DoJSONRequest("POST", UploadBaseURL+"/upload/v1/oss/file/upload_async_result", reqBody, &resp) - if err != nil { + if err := c.DoJSONRequest("POST", APIBaseURL+"/upload/v1/oss/file/upload_async_result", reqBody, &resp); err != nil { return nil, err } - if resp.Code != 0 { return nil, fmt.Errorf("123pan API error (async result query), code: %d, msg: %s", resp.Code, resp.Message) } @@ -103,6 +83,5 @@ func (c *Client) CheckAsyncResult(preuploadID string) (*UploadAsyncResultRespDat if err := json.Unmarshal(resp.Data, &data); err != nil { return nil, fmt.Errorf("decode async_result data error: %w", err) } - return &data, nil } diff --git a/internal/service/image_service.go b/internal/service/image_service.go index 3902df1..52c2316 100644 --- a/internal/service/image_service.go +++ b/internal/service/image_service.go @@ -4,41 +4,38 @@ import ( "fmt" "strings" - "imagehost/internal/config" "imagehost/internal/pan123" ) type ImageService struct { - client *pan123.Client + client *pan123.Client + parentFileID string + customDomain string } -func NewImageService(client *pan123.Client) *ImageService { - return &ImageService{client: client} +func NewImageService(client *pan123.Client, parentFileID, customDomain string) *ImageService { + return &ImageService{ + client: client, + parentFileID: parentFileID, + customDomain: strings.TrimRight(customDomain, "/"), + } } -// GetImageItems 获取图床列表格式化信息 func (s *ImageService) GetImageItems() ([]pan123.FileItem, error) { - // 获取前 100 张即可 - items, _, err := s.client.GetFileList(config.GlobalConfig.ParentFileID, 100, "") + items, _, err := s.client.GetFileList(s.parentFileID, 100, "") if err != nil { - return nil, fmt.Errorf("获取列表异常: %w", err) + return nil, fmt.Errorf("获取列表失败: %w", err) } - domain := strings.TrimRight(config.GlobalConfig.CustomDomain, "/") - - // 格式化清理,如果有设置自定义解析,则将直链全部附魔为指定域名 for i := range items { - // 如果 123pan 源生返回了 userSelfURL,可以优先。如果没有且我们配了 custom_domain,尝试通过拼接提供后备访问链 - if items[i].UserSelfURL == "" && domain != "" { - // 一些简易替换,具体规则需按你在 123pan 的直链白名单空间里设置来调整 - items[i].UserSelfURL = fmt.Sprintf("%s/%s", domain, items[i].Filename) + if items[i].UserSelfURL == "" && s.customDomain != "" { + items[i].UserSelfURL = fmt.Sprintf("%s/%s", s.customDomain, items[i].Filename) } } return items, nil } -// DeleteImages 删除单张或多张 func (s *ImageService) DeleteImages(ids []string) error { if len(ids) == 0 { return nil diff --git a/internal/service/upload_service.go b/internal/service/upload_service.go index 236c412..576e6bc 100644 --- a/internal/service/upload_service.go +++ b/internal/service/upload_service.go @@ -9,19 +9,23 @@ import ( "strings" "time" - "imagehost/internal/config" "imagehost/internal/pan123" ) type UploadService struct { - client *pan123.Client + client *pan123.Client + parentFileID string + customDomain string } -func NewUploadService(client *pan123.Client) *UploadService { - return &UploadService{client: client} +func NewUploadService(client *pan123.Client, parentFileID, customDomain string) *UploadService { + return &UploadService{ + client: client, + parentFileID: parentFileID, + customDomain: strings.TrimRight(customDomain, "/"), + } } -// UploadFile 处理单一文件的五步上传编排 func (s *UploadService) UploadFile(fileHeader *multipart.FileHeader) (pan123.FileItem, error) { file, err := fileHeader.Open() if err != nil { @@ -29,64 +33,53 @@ func (s *UploadService) UploadFile(fileHeader *multipart.FileHeader) (pan123.Fil } defer file.Close() - // 【致命漏洞修复】使用流式处理替换无痛但高危的被动全量加载 (ReadAll) h := md5.New() if _, err := io.Copy(h, file); err != nil { - return pan123.FileItem{}, fmt.Errorf("动态计算文件 MD5 失败: %w", err) + return pan123.FileItem{}, fmt.Errorf("计算文件 MD5 失败: %w", err) } etag := fmt.Sprintf("%x", h.Sum(nil)) size := fileHeader.Size - // 重置读取游标,准备让底层的 123pan.Client 直传该 file IO 节点 file.Seek(0, io.SeekStart) fileName := fileHeader.Filename var fileID string - // 步骤 1: 创建文件申报 - createResp, err := s.client.CreateFile(config.GlobalConfig.ParentFileID, fileName, etag, size) + createResp, err := s.client.CreateFile(s.parentFileID, fileName, etag, size) if err != nil { - return pan123.FileItem{}, fmt.Errorf("123pan 发送申报异常: %w", err) + return pan123.FileItem{}, fmt.Errorf("123pan 创建文件失败: %w", err) } if createResp.Reuse { - // 秒传命中 fileID = createResp.FileID - log.Printf("[上传] 文件 %s 命中服务器秒传!", fileName) + log.Printf("[上传] 文件 %s 命中秒传", fileName) } else { - // 没有秒传,需要物理 PUT 上传切片 preuploadID := createResp.PreuploadID - // 步骤 2: 获取签发 URL urlResp, err := s.client.GetUploadURL(preuploadID, 1) if err != nil { - return pan123.FileItem{}, fmt.Errorf("123pan 获取直传地址失败: %w", err) + return pan123.FileItem{}, fmt.Errorf("123pan 获取上传地址失败: %w", err) } - log.Printf("[上传] 开始推送 %s 到 123pan (大小: %d bytes)...", fileName, size) - // 步骤 3: 真正的二进制推送 (此处直接透传管道 file,坚决不在 RAM 内过境缓存!) - err = s.client.DoRawPUT(urlResp.PresignedURL, file, size) - if err != nil { - return pan123.FileItem{}, fmt.Errorf("123pan 数据切片推送失败: %w", err) + log.Printf("[上传] 推送 %s 到 123pan (%d bytes)...", fileName, size) + if err = s.client.DoRawPUT(urlResp.PresignedURL, file, size); err != nil { + return pan123.FileItem{}, fmt.Errorf("123pan 数据上传失败: %w", err) } - // 步骤 4: 推送完毕申报 completeResp, err := s.client.UploadComplete(preuploadID) if err != nil { - return pan123.FileItem{}, fmt.Errorf("123pan 汇报上传完结失败: %w", err) + return pan123.FileItem{}, fmt.Errorf("123pan 上传完结通知失败: %w", err) } - // 判决处理策略 if completeResp.Completed { fileID = completeResp.FileID } else if completeResp.Async { - // 步骤 5: 异步轮询 (最大容忍 20s) - log.Printf("[上传] 123pan 正在后端异步处理合并 %s...", fileName) + log.Printf("[上传] 123pan 异步处理 %s...", fileName) for i := 0; i < 20; i++ { time.Sleep(1 * time.Second) asyncResp, err := s.client.CheckAsyncResult(preuploadID) if err != nil { - log.Printf("轮询报错 (将继续尝试): %v", err) + log.Printf("[上传] 轮询出错 (继续重试): %v", err) continue } if asyncResp.Completed { @@ -95,24 +88,18 @@ func (s *UploadService) UploadFile(fileHeader *multipart.FileHeader) (pan123.Fil } } if fileID == "" { - return pan123.FileItem{}, fmt.Errorf("123pan 异步处理落盘超时") + return pan123.FileItem{}, fmt.Errorf("123pan 异步处理超时") } } else { - return pan123.FileItem{}, fmt.Errorf("123pan 返回的完结状态不能被理解: %v", completeResp) + return pan123.FileItem{}, fmt.Errorf("123pan 返回未知完结状态: %v", completeResp) } } - log.Printf("[上传] 文件 %s 整体保存完毕, ID: %s", fileName, fileID) + log.Printf("[上传] %s 完成, ID: %s", fileName, fileID) - // 根据图床需求,组装格式化的信息,此时可返回。 - // 这里通过自定义协议拼接URL假若没有详情API能立即获取。(或者使用 FileID 查询) downloadURL := "" - if config.GlobalConfig.CustomDomain != "" { - // 假设其静态资源访问域名形式为 domain/fileID/filename 或者是普通形式。 - // 大部分直链空间只需: customDomain / fileName / file_id - // 作为通用兼容处理,如果直链域名未配特定的子径,先假设能直接根据路径拼接,具体的按实际调整。 - domain := strings.TrimRight(config.GlobalConfig.CustomDomain, "/") - downloadURL = fmt.Sprintf("%s/%s", domain, fileName) + if s.customDomain != "" { + downloadURL = fmt.Sprintf("%s/%s", s.customDomain, fileName) } return pan123.FileItem{ @@ -121,6 +108,6 @@ func (s *UploadService) UploadFile(fileHeader *multipart.FileHeader) (pan123.Fil Size: size, Etag: etag, DownloadURL: downloadURL, - UserSelfURL: downloadURL, // 我们以此值为向外暴露的主要直链URL + UserSelfURL: downloadURL, }, nil }