diff --git a/internal/common/oss/service.go b/internal/common/oss/service.go new file mode 100644 index 0000000..e6768ce --- /dev/null +++ b/internal/common/oss/service.go @@ -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) +} diff --git a/internal/common/qiniu/handler/upload_handler.go b/internal/common/qiniu/handler/upload_handler.go index dc637c8..9e20066 100644 --- a/internal/common/qiniu/handler/upload_handler.go +++ b/internal/common/qiniu/handler/upload_handler.go @@ -3,15 +3,21 @@ 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" @@ -37,13 +43,61 @@ type qiniuCallbackPayload struct { 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,避免前端写入任意路径。 func (h *UploadHandler) QiniuToken(c *gin.Context) { user := middleware.MustCurrentUser(c) 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) if err != nil { @@ -54,7 +108,6 @@ func (h *UploadHandler) QiniuToken(c *gin.Context) { c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取上传凭证失败,请稍后重试")) return } - c.JSON(http.StatusOK, model.Success(token)) } diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 58e28c8..fb4da7d 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -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) { c.JSON(http.StatusOK, gin.H{"status": "ok"})