package service import ( "crypto/hmac" "crypto/rand" "crypto/sha1" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "path" "regexp" "strings" "time" "wx_service/config" ) var ( ErrQiniuNotConfigured = errors.New("qiniu is not configured") ) type QiniuService struct { cfg config.QiniuConfig } func NewQiniuService(cfg config.QiniuConfig) *QiniuService { return &QiniuService{cfg: cfg} } type QiniuUploadToken struct { Token string `json:"token"` Key string `json:"key"` UploadURL string `json:"upload_url"` ExpireAt int64 `json:"expire"` CDNDomain string `json:"cdn_domain"` } var extPattern = regexp.MustCompile(`^\.[a-z0-9]{1,10}$`) func (s *QiniuService) CreateUploadToken(miniProgramID uint, userID uint, filename string) (QiniuUploadToken, error) { if s.cfg.AccessKey == "" || s.cfg.SecretKey == "" || s.cfg.Bucket == "" { return QiniuUploadToken{}, ErrQiniuNotConfigured } expireSeconds := s.cfg.TokenExpireSeconds if expireSeconds <= 0 { expireSeconds = 300 } expireAt := time.Now().Add(time.Duration(expireSeconds) * time.Second).Unix() ext := strings.ToLower(path.Ext(filename)) if !extPattern.MatchString(ext) { ext = "" } randomHex, err := randomHex(16) if err != nil { return QiniuUploadToken{}, fmt.Errorf("generate random key: %w", err) } // 统一由后端生成 key,避免前端随意写入任意路径。 // 这里按“业务前缀/小程序/用户/日期/随机名”组织,便于后期排查与管理。 keyPrefix := strings.Trim(s.cfg.KeyPrefix, "/") key := fmt.Sprintf("%s/mp_%d/user_%d/%s/%s%s", keyPrefix, miniProgramID, userID, time.Now().Format("20060102"), randomHex, ext, ) putPolicy := map[string]interface{}{ // scope = ":" 表示只允许写入指定 key(更安全) "scope": fmt.Sprintf("%s:%s", s.cfg.Bucket, key), "deadline": expireAt, // 上传完成后返回给前端的 JSON(七牛会做变量替换) "returnBody": `{"key":"$(key)","hash":"$(etag)","fsize":$(fsize),"mimeType":"$(mimeType)"}`, } policyJSON, err := json.Marshal(putPolicy) if err != nil { return QiniuUploadToken{}, fmt.Errorf("marshal put policy: %w", err) } encodedPolicy := urlSafeBase64NoPad(policyJSON) sign := hmacSHA1([]byte(s.cfg.SecretKey), []byte(encodedPolicy)) encodedSign := urlSafeBase64NoPad(sign) token := fmt.Sprintf("%s:%s:%s", s.cfg.AccessKey, encodedSign, encodedPolicy) return QiniuUploadToken{ Token: token, Key: key, UploadURL: s.cfg.UploadURL, ExpireAt: expireAt, CDNDomain: s.cfg.CDNDomain, }, nil } func hmacSHA1(secret []byte, data []byte) []byte { mac := hmac.New(sha1.New, secret) mac.Write(data) return mac.Sum(nil) } func urlSafeBase64NoPad(data []byte) string { return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(data) } func randomHex(nBytes int) (string, error) { if nBytes <= 0 { return "", fmt.Errorf("invalid random bytes length") } buf := make([]byte, nBytes) if _, err := rand.Read(buf); err != nil { return "", err } return hex.EncodeToString(buf), nil }