diff --git a/internal/common/qiniu/handler/upload_handler.go b/internal/common/qiniu/handler/upload_handler.go index f354fb3..dc637c8 100644 --- a/internal/common/qiniu/handler/upload_handler.go +++ b/internal/common/qiniu/handler/upload_handler.go @@ -1,8 +1,14 @@ package handler import ( + "encoding/json" "errors" + "io" + "log" "net/http" + "net/url" + "strconv" + "strings" "github.com/gin-gonic/gin" @@ -24,6 +30,13 @@ type qiniuTokenRequest struct { Filename string `json:"filename"` } +type qiniuCallbackPayload struct { + Key string `json:"key"` + Hash string `json:"hash"` + Fsize int64 `json:"fsize"` + MimeType string `json:"mimeType"` +} + // QiniuToken 返回七牛直传所需的 token/key/upload_url 等信息。 // 建议放在鉴权后:用当前登录用户生成 key,避免前端写入任意路径。 func (h *UploadHandler) QiniuToken(c *gin.Context) { @@ -44,3 +57,66 @@ func (h *UploadHandler) QiniuToken(c *gin.Context) { c.JSON(http.StatusOK, model.Success(token)) } + +// QiniuCallback 处理七牛上传回调(无需登录),通过签名验签确保来源可信。 +// 说明: +// - 验签失败返回 401(非可信请求,直接拒绝) +// - 业务处理临时失败返回 503(触发七牛重试) +func (h *UploadHandler) QiniuCallback(c *gin.Context) { + rawBody, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "读取回调内容失败")) + return + } + + if err := h.qiniuService.VerifyCallbackSignature(c.Request, rawBody); err != nil { + switch { + case errors.Is(err, qiniuservice.ErrQiniuNotConfigured): + c.JSON(http.StatusServiceUnavailable, model.Error(http.StatusServiceUnavailable, "七牛服务未配置")) + default: + c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "回调验签失败")) + } + return + } + + payload, err := parseQiniuCallbackPayload(c.ContentType(), rawBody) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "回调内容格式错误")) + return + } + if strings.TrimSpace(payload.Key) == "" { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "回调内容缺少 key")) + return + } + + // 当前阶段先记录日志。后续如接入 DB/任务系统,处理失败可保持 503 以触发七牛重试。 + log.Printf("[qiniu_callback] key=%s hash=%s fsize=%d mimeType=%s", payload.Key, payload.Hash, payload.Fsize, payload.MimeType) + c.JSON(http.StatusOK, model.Success(gin.H{"ok": true})) +} + +func parseQiniuCallbackPayload(contentType string, raw []byte) (qiniuCallbackPayload, error) { + var payload qiniuCallbackPayload + trimmed := strings.TrimSpace(strings.ToLower(contentType)) + if strings.Contains(trimmed, "application/json") { + if err := json.Unmarshal(raw, &payload); err != nil { + return qiniuCallbackPayload{}, err + } + return payload, nil + } + + values, err := url.ParseQuery(string(raw)) + if err != nil { + return qiniuCallbackPayload{}, err + } + payload.Key = strings.TrimSpace(values.Get("key")) + payload.Hash = strings.TrimSpace(values.Get("hash")) + payload.MimeType = strings.TrimSpace(values.Get("mimeType")) + if rawFsize := strings.TrimSpace(values.Get("fsize")); rawFsize != "" { + fsize, parseErr := strconv.ParseInt(rawFsize, 10, 64) + if parseErr != nil { + return qiniuCallbackPayload{}, parseErr + } + payload.Fsize = fsize + } + return payload, nil +} diff --git a/internal/common/qiniu/handler/upload_handler_test.go b/internal/common/qiniu/handler/upload_handler_test.go new file mode 100644 index 0000000..2683665 --- /dev/null +++ b/internal/common/qiniu/handler/upload_handler_test.go @@ -0,0 +1,103 @@ +package handler + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "wx_service/config" + qiniuservice "wx_service/internal/common/qiniu/service" +) + +func TestQiniuCallbackSuccess(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + cfg := config.QiniuConfig{AccessKey: "ak-test", SecretKey: "sk-test"} + h := NewUploadHandler(qiniuservice.NewQiniuService(cfg)) + + body := "key=uploads/test.png&hash=abc&fsize=12&mimeType=image%2Fpng" + req := httptest.NewRequest(http.MethodPost, "/api/v1/common/upload/qiniu/callback", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", "QBox "+cfg.AccessKey+":"+signQiniu(req.URL.Path+"\n"+body, cfg.SecretKey)) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + + h.QiniuCallback(c) + + if w.Code != http.StatusOK { + t.Fatalf("status=%d, want=200, body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), `"code":200`) { + t.Fatalf("unexpected response body: %s", w.Body.String()) + } +} + +func TestQiniuCallbackInvalidSignature(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + cfg := config.QiniuConfig{AccessKey: "ak-test", SecretKey: "sk-test"} + h := NewUploadHandler(qiniuservice.NewQiniuService(cfg)) + + body := "key=uploads/test.png&hash=abc" + req := httptest.NewRequest(http.MethodPost, "/api/v1/common/upload/qiniu/callback", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", "QBox ak-test:bad-sign") + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + + h.QiniuCallback(c) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("status=%d, want=401, body=%s", w.Code, w.Body.String()) + } +} + +func TestQiniuCallbackMissingKey(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + cfg := config.QiniuConfig{AccessKey: "ak-test", SecretKey: "sk-test"} + h := NewUploadHandler(qiniuservice.NewQiniuService(cfg)) + + body := "hash=abc&fsize=12" + req := httptest.NewRequest(http.MethodPost, "/api/v1/common/upload/qiniu/callback", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", "QBox "+cfg.AccessKey+":"+signQiniu(req.URL.Path+"\n"+body, cfg.SecretKey)) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + + h.QiniuCallback(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status=%d, want=400, body=%s", w.Code, w.Body.String()) + } +} + +func TestParseQiniuCallbackPayloadJSON(t *testing.T) { + t.Parallel() + raw := []byte(`{"key":"uploads/test.png","hash":"abc","fsize":321,"mimeType":"image/png"}`) + got, err := parseQiniuCallbackPayload("application/json", raw) + if err != nil { + t.Fatalf("parseQiniuCallbackPayload: %v", err) + } + if got.Key != "uploads/test.png" || got.Hash != "abc" || got.Fsize != 321 { + t.Fatalf("unexpected payload: %+v", got) + } +} + +func signQiniu(signing, secret string) string { + mac := hmac.New(sha1.New, []byte(secret)) + _, _ = mac.Write([]byte(signing)) + return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(mac.Sum(nil)) +} diff --git a/internal/routes/common_routes.go b/internal/routes/common_routes.go index 6a2778a..1bcbae2 100644 --- a/internal/routes/common_routes.go +++ b/internal/routes/common_routes.go @@ -6,6 +6,11 @@ import ( qiniuhandler "wx_service/internal/common/qiniu/handler" ) +func registerCommonPublicRoutes(api *gin.RouterGroup, uploadHandler *qiniuhandler.UploadHandler) { + // 七牛上传回调:由七牛服务端调用,不能挂登录鉴权。 + api.POST("/common/upload/qiniu/callback", uploadHandler.QiniuCallback) +} + func registerCommonRoutes(protected *gin.RouterGroup, uploadHandler *qiniuhandler.UploadHandler) { // 公共接口(所有小程序共用) common := protected.Group("/common") diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 966acdf..9be7e4f 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -39,6 +39,7 @@ func Register( // 公众号网页授权:不需要登录(code 本身来自微信授权回调) registerWeChatOfficialRoutes(api, oaOAuthHandler) + registerCommonPublicRoutes(api, uploadHandler) if lawyerHandler != nil { api.POST("/lawyers", lawyerHandler.Create)