Files
wx_service/internal/common/qiniu/handler/upload_handler.go
T
nepiedg e14255cf64 feat(upload): 支持阿里云 OSS 直传凭证 + 营销图管理后台静态路由
- 新增 internal/common/oss: OSS PostPolicy/UploadHost,CDN 为 aliyuncs 时返回 OSS 凭证
- upload_handler: QINIU_CDN_DOMAIN 为 OSS 域名时返回 oss_access_key_id/policy/signature,upload_url 为 bucket 域名
- routes: 增加 /admin/marketing 静态页面路由

Made-with: Cursor
2026-03-06 11:25:23 +00:00

176 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"path"
"regexp"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"wx_service/config"
oss "wx_service/internal/common/oss"
qiniuservice "wx_service/internal/common/qiniu/service"
"wx_service/internal/middleware"
"wx_service/internal/model"
)
type UploadHandler struct {
qiniuService *qiniuservice.QiniuService
}
func NewUploadHandler(qiniuService *qiniuservice.QiniuService) *UploadHandler {
return &UploadHandler{qiniuService: qiniuService}
}
type qiniuTokenRequest struct {
// filename 用于保留文件后缀(可选),例如:"a.png"、"video.mp4"
Filename string `json:"filename"`
}
type qiniuCallbackPayload struct {
Key string `json:"key"`
Hash string `json:"hash"`
Fsize int64 `json:"fsize"`
MimeType string `json:"mimeType"`
}
type uploadTokenResponse struct {
Token string `json:"token,omitempty"`
Key string `json:"key"`
UploadURL string `json:"upload_url"`
ExpireAt int64 `json:"expire,omitempty"`
CDNDomain string `json:"cdn_domain,omitempty"`
OSSAccessKey string `json:"oss_access_key_id,omitempty"`
OSSPolicy string `json:"oss_policy,omitempty"`
OSSSignature string `json:"oss_signature,omitempty"`
}
var extPattern = regexp.MustCompile(`^\.[a-z0-9]{1,10}$`)
// QiniuToken 返回直传所需的 token/key/upload_urlCDN 为阿里云 OSS 时返回 OSS PostObject 凭证。
// 建议放在鉴权后:用当前登录用户生成 key,避免前端写入任意路径。
func (h *UploadHandler) QiniuToken(c *gin.Context) {
user := middleware.MustCurrentUser(c)
var req qiniuTokenRequest
_ = c.ShouldBindJSON(&req)
cfg := config.AppConfig.Qiniu
cdnDomain := strings.TrimSpace(cfg.CDNDomain)
if oss.IsOSSDomain(cdnDomain) && cfg.AccessKey != "" && cfg.SecretKey != "" && cfg.Bucket != "" {
ext := path.Ext(req.Filename)
if ext == "" || !extPattern.MatchString(strings.ToLower(ext)) {
ext = ".jpg"
}
keyPrefix := strings.Trim(cfg.KeyPrefix, "/")
key := fmt.Sprintf("%s/mp_%d/user_%d/%s/%x%s",
keyPrefix, user.MiniProgramID, user.ID,
time.Now().Format("20060102"), time.Now().UnixNano()&0xffffffff, ext)
endpoint := oss.ParseOSSEndpoint(cdnDomain)
expireSeconds := cfg.TokenExpireSeconds
if expireSeconds <= 0 {
expireSeconds = 300
}
policy, signature, err := oss.PostPolicy(cfg.Bucket, endpoint, key, cfg.SecretKey, expireSeconds)
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "生成 OSS 凭证失败"))
return
}
uploadURL := oss.UploadHost(cfg.Bucket, endpoint)
cdnHost := "https://" + cfg.Bucket + "." + endpoint + ".aliyuncs.com"
c.JSON(http.StatusOK, model.Success(uploadTokenResponse{
Key: key,
UploadURL: uploadURL,
CDNDomain: cdnHost,
OSSAccessKey: cfg.AccessKey,
OSSPolicy: policy,
OSSSignature: signature,
}))
return
}
token, err := h.qiniuService.CreateUploadToken(user.MiniProgramID, user.ID, req.Filename)
if err != nil {
if errors.Is(err, qiniuservice.ErrQiniuNotConfigured) {
c.JSON(http.StatusServiceUnavailable, model.Error(http.StatusServiceUnavailable, "未配置七牛上传服务,请联系管理员"))
return
}
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取上传凭证失败,请稍后重试"))
return
}
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
}