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 <noreply@anthropic.com>
This commit is contained in:
39
cmd/main.go
39
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user