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
This commit is contained in:
@@ -0,0 +1,69 @@
|
|||||||
|
package oss
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsOSSDomain 判断 CDN 域名是否为阿里云 OSS(据此决定返回 OSS 还是七牛凭证)
|
||||||
|
func IsOSSDomain(cdnDomain string) bool {
|
||||||
|
return strings.Contains(strings.ToLower(cdnDomain), "aliyuncs.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseOSSEndpoint 从 CDN 域名解析 OSS endpoint,如 oss-cn-beijing.aliyuncs.com -> oss-cn-beijing
|
||||||
|
func ParseOSSEndpoint(cdnDomain string) string {
|
||||||
|
// oss-cn-beijing.aliyuncs.com -> oss-cn-beijing
|
||||||
|
re := regexp.MustCompile(`^([a-z0-9-]+)\.aliyuncs\.com$`)
|
||||||
|
m := re.FindStringSubmatch(strings.TrimSpace(strings.ToLower(cdnDomain)))
|
||||||
|
if len(m) >= 2 {
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
return "oss-cn-beijing"
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostPolicy 生成 OSS PostObject 所需的 policy 和 signature
|
||||||
|
// bucket: 桶名, endpoint: 如 oss-cn-beijing, key: 对象 key, accessKeySecret: 密钥, expireSeconds: 有效期
|
||||||
|
func PostPolicy(bucket, endpoint, key, accessKeySecret string, expireSeconds int) (policyBase64, signature string, err error) {
|
||||||
|
if expireSeconds <= 0 {
|
||||||
|
expireSeconds = 300
|
||||||
|
}
|
||||||
|
expiration := time.Now().Add(time.Duration(expireSeconds) * time.Second).UTC()
|
||||||
|
expirationStr := expiration.Format("2006-01-02T15:04:05.000Z")
|
||||||
|
|
||||||
|
keyPrefix := key
|
||||||
|
if idx := strings.LastIndex(key, "/"); idx >= 0 {
|
||||||
|
keyPrefix = key[:idx+1]
|
||||||
|
}
|
||||||
|
if keyPrefix == "" {
|
||||||
|
keyPrefix = "uploads/"
|
||||||
|
}
|
||||||
|
|
||||||
|
policy := map[string]interface{}{
|
||||||
|
"expiration": expirationStr,
|
||||||
|
"conditions": []interface{}{
|
||||||
|
[]interface{}{"content-length-range", 0, 10 * 1024 * 1024}, // 10MB
|
||||||
|
[]interface{}{"starts-with", "$key", keyPrefix},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
policyJSON, err := json.Marshal(policy)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
policyBase64 = base64.StdEncoding.EncodeToString(policyJSON)
|
||||||
|
|
||||||
|
mac := hmac.New(sha1.New, []byte(accessKeySecret))
|
||||||
|
mac.Write([]byte(policyBase64))
|
||||||
|
signature = base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
return policyBase64, signature, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadHost 返回 OSS PostObject 的完整上传地址
|
||||||
|
func UploadHost(bucket, endpoint string) string {
|
||||||
|
return fmt.Sprintf("https://%s.%s.aliyuncs.com", bucket, endpoint)
|
||||||
|
}
|
||||||
@@ -3,15 +3,21 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"wx_service/config"
|
||||||
|
oss "wx_service/internal/common/oss"
|
||||||
qiniuservice "wx_service/internal/common/qiniu/service"
|
qiniuservice "wx_service/internal/common/qiniu/service"
|
||||||
"wx_service/internal/middleware"
|
"wx_service/internal/middleware"
|
||||||
"wx_service/internal/model"
|
"wx_service/internal/model"
|
||||||
@@ -37,13 +43,61 @@ type qiniuCallbackPayload struct {
|
|||||||
MimeType string `json:"mimeType"`
|
MimeType string `json:"mimeType"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// QiniuToken 返回七牛直传所需的 token/key/upload_url 等信息。
|
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,避免前端写入任意路径。
|
// 建议放在鉴权后:用当前登录用户生成 key,避免前端写入任意路径。
|
||||||
func (h *UploadHandler) QiniuToken(c *gin.Context) {
|
func (h *UploadHandler) QiniuToken(c *gin.Context) {
|
||||||
user := middleware.MustCurrentUser(c)
|
user := middleware.MustCurrentUser(c)
|
||||||
|
|
||||||
var req qiniuTokenRequest
|
var req qiniuTokenRequest
|
||||||
_ = c.ShouldBindJSON(&req) // filename 可选,解析失败也不影响生成 token
|
_ = 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)
|
token, err := h.qiniuService.CreateUploadToken(user.MiniProgramID, user.ID, req.Filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -54,7 +108,6 @@ func (h *UploadHandler) QiniuToken(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取上传凭证失败,请稍后重试"))
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取上传凭证失败,请稍后重试"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, model.Success(token))
|
c.JSON(http.StatusOK, model.Success(token))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ func Register(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Web 管理后台静态文件
|
||||||
|
router.StaticFile("/admin/marketing", "web/marketing/index.html")
|
||||||
|
router.StaticFile("/admin/marketing/", "web/marketing/index.html")
|
||||||
|
|
||||||
// 健康检查:用于容器/负载均衡探活
|
// 健康检查:用于容器/负载均衡探活
|
||||||
router.GET("/healthz", func(c *gin.Context) {
|
router.GET("/healthz", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
|||||||
Reference in New Issue
Block a user