e14255cf64
- 新增 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
176 lines
5.7 KiB
Go
176 lines
5.7 KiB
Go
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_url;CDN 为阿里云 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
|
||
}
|