feat:修改头像bug
This commit is contained in:
parent
0f1cbb39c1
commit
edadb0e082
BIN
backend/cleanup-orphan-avatars
Executable file
BIN
backend/cleanup-orphan-avatars
Executable file
Binary file not shown.
241
backend/gateway/cmd/cleanup-orphan-avatars/main.go
Normal file
241
backend/gateway/cmd/cleanup-orphan-avatars/main.go
Normal file
@ -0,0 +1,241 @@
|
||||
// Package main 提供 OSS 头像孤儿文件清理工具。
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// go run ./gateway/cmd/cleanup-orphan-avatars \
|
||||
// --older-than-days 7 # 只看 7 天前的对象
|
||||
// --limit 100 # 一次最多处理 100 个
|
||||
// --delete # 真正删除;缺省为 dry-run,只打印不打
|
||||
//
|
||||
// DB / OSS 配置走环境变量(与 userService 一致):
|
||||
// - DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME
|
||||
// - OSS_REGION, OSS_BUCKET_NAME, OSS_STS_ROLE_ARN,
|
||||
// OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET, OSS_AVATAR_DIR
|
||||
//
|
||||
// 默认扫描前缀 = $OSS_AVATAR_DIR("avatar/"),用 --prefix 覆盖。
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/topfans/backend/gateway/config"
|
||||
"github.com/topfans/backend/gateway/pkg/ossutil"
|
||||
"github.com/topfans/backend/pkg/database"
|
||||
"github.com/topfans/backend/pkg/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
prefix = flag.String("prefix", "", "OSS 前缀,默认 = cfg.OSS.AvatarDir")
|
||||
olderThanDays = flag.Int("older-than-days", 0, "仅标记 mtime 早于 N 天的对象(0 = 不过滤)")
|
||||
limit = flag.Int("limit", 0, "最多处理多少个孤儿(0 = 不限)")
|
||||
deleteMode = flag.Bool("delete", false, "真正删除 OSS 对象;缺省为 dry-run")
|
||||
batchSize = flag.Int("batch-size", 100, "每处理 N 个对象打印一次进度")
|
||||
dbHost = flag.String("db-host", getEnv("DB_HOST", "localhost"), "DB host")
|
||||
dbPort = flag.Int("db-port", getEnvInt("DB_PORT", 5432), "DB port")
|
||||
dbUser = flag.String("db-user", getEnv("DB_USER", "postgres"), "DB user")
|
||||
dbPassword = flag.String("db-password", getEnv("DB_PASSWORD", ""), "DB password")
|
||||
dbName = flag.String("db-name", getEnv("DB_NAME", "top-fans"), "DB name")
|
||||
)
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvInt(key string, fallback int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
mode := "DRY-RUN"
|
||||
if *deleteMode {
|
||||
mode = "DELETE"
|
||||
}
|
||||
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
|
||||
log.Printf("=== cleanup-orphan-avatars [%s] ===", mode)
|
||||
|
||||
cfg := config.Load()
|
||||
|
||||
scanPrefix := *prefix
|
||||
if scanPrefix == "" {
|
||||
scanPrefix = cfg.OSS.AvatarDir
|
||||
}
|
||||
if scanPrefix == "" {
|
||||
log.Fatalf("--prefix 为空且 cfg.OSS.AvatarDir 未设置,请显式传 --prefix")
|
||||
}
|
||||
log.Printf("scan prefix: %s", scanPrefix)
|
||||
if *olderThanDays > 0 {
|
||||
log.Printf("age filter: only mtime < %d days ago", *olderThanDays)
|
||||
}
|
||||
if *limit > 0 {
|
||||
log.Printf("limit: max %d orphans to process", *limit)
|
||||
}
|
||||
|
||||
if err := database.Init(database.Config{
|
||||
Host: *dbHost,
|
||||
Port: *dbPort,
|
||||
User: *dbUser,
|
||||
Password: *dbPassword,
|
||||
DBName: *dbName,
|
||||
SSLMode: "disable",
|
||||
TimeZone: "Asia/Shanghai",
|
||||
}); err != nil {
|
||||
log.Fatalf("connect DB failed: %v", err)
|
||||
}
|
||||
defer func() { _ = database.Close() }()
|
||||
|
||||
db := database.GetDB()
|
||||
referenced, skipped := collectReferencedKeys(db, &cfg.OSS)
|
||||
log.Printf("referenced keys: %d (含 fan_profiles),解析失败/外链跳过: %d", referenced.cardinality(), skipped)
|
||||
|
||||
objs, err := ossutil.List(&cfg.OSS, scanPrefix, 0)
|
||||
if err != nil {
|
||||
log.Fatalf("OSS List failed: %v", err)
|
||||
}
|
||||
log.Printf("OSS objects under %q: %d", scanPrefix, len(objs))
|
||||
|
||||
cutoff := time.Time{}
|
||||
if *olderThanDays > 0 {
|
||||
cutoff = time.Now().Add(-time.Duration(*olderThanDays) * 24 * time.Hour)
|
||||
}
|
||||
|
||||
var (
|
||||
orphans []ossutil.ObjectInfo
|
||||
skippedNew int
|
||||
skippedRef int
|
||||
)
|
||||
for _, obj := range objs {
|
||||
if referenced.contains(obj.Key) {
|
||||
skippedRef++
|
||||
continue
|
||||
}
|
||||
if !cutoff.IsZero() && obj.LastModified.After(cutoff) {
|
||||
skippedNew++
|
||||
continue
|
||||
}
|
||||
orphans = append(orphans, obj)
|
||||
if *limit > 0 && len(orphans) >= *limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("candidates: %d (referenced skip: %d, age skip: %d)", len(orphans), skippedRef, skippedNew)
|
||||
if len(orphans) == 0 {
|
||||
log.Printf("nothing to do. bye.")
|
||||
return
|
||||
}
|
||||
|
||||
var totalSize int64
|
||||
for _, o := range orphans {
|
||||
totalSize += o.Size
|
||||
}
|
||||
log.Printf("would free: %d files, %s", len(orphans), humanBytes(totalSize))
|
||||
|
||||
processed := 0
|
||||
for _, o := range orphans {
|
||||
processed++
|
||||
if *deleteMode {
|
||||
if err := ossutil.Delete(&cfg.OSS, o.Key); err != nil {
|
||||
log.Printf("[FAIL] delete %s: %v", o.Key, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[DEL ] %s size=%s mtime=%s", o.Key, humanBytes(o.Size), o.LastModified.Format(time.RFC3339))
|
||||
} else {
|
||||
log.Printf("[SKIP] %s size=%s mtime=%s", o.Key, humanBytes(o.Size), o.LastModified.Format(time.RFC3339))
|
||||
}
|
||||
if processed%*batchSize == 0 {
|
||||
log.Printf("--- progress: %d / %d ---", processed, len(orphans))
|
||||
}
|
||||
}
|
||||
log.Printf("done. processed=%d", processed)
|
||||
}
|
||||
|
||||
// referencedKeySet 记录"正在被数据库引用的 OSS key"。
|
||||
// 用 map[string]struct{} 而不是 sync.Map —— 工具是单 goroutine。
|
||||
type referencedKeySet struct {
|
||||
m map[string]struct{}
|
||||
}
|
||||
|
||||
func (s *referencedKeySet) add(k string) {
|
||||
if s.m == nil {
|
||||
s.m = make(map[string]struct{})
|
||||
}
|
||||
s.m[k] = struct{}{}
|
||||
}
|
||||
|
||||
func (s *referencedKeySet) contains(k string) bool {
|
||||
_, ok := s.m[k]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s *referencedKeySet) cardinality() int {
|
||||
return len(s.m)
|
||||
}
|
||||
|
||||
// collectReferencedKeys 从 users / fan_profiles 收集所有头像 URL,
|
||||
// 通过 cfg 校验 host 后转成 OSS key 入集合。
|
||||
// 返回:(set, 解析失败/外链 数量)。
|
||||
func collectReferencedKeys(db *gorm.DB, cfg *config.OSSConfig) (referencedKeySet, int) {
|
||||
set := referencedKeySet{}
|
||||
skipped := 0
|
||||
|
||||
var userURLs []string
|
||||
if err := db.Model(&models.User{}).
|
||||
Where("avatar_url IS NOT NULL AND avatar_url <> '' AND deleted_at IS NULL").
|
||||
Pluck("avatar_url", &userURLs).Error; err != nil {
|
||||
log.Fatalf("query users.avatar_url failed: %v", err)
|
||||
}
|
||||
for _, u := range userURLs {
|
||||
k, err := ossutil.ExtractKeyFromPublicURL(cfg, u)
|
||||
if err != nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
set.add(k)
|
||||
}
|
||||
|
||||
var profileURLs []string
|
||||
if err := db.Model(&models.FanProfile{}).
|
||||
Where("avatar_url IS NOT NULL AND avatar_url <> ''").
|
||||
Pluck("avatar_url", &profileURLs).Error; err != nil {
|
||||
log.Fatalf("query fan_profiles.avatar_url failed: %v", err)
|
||||
}
|
||||
for _, u := range profileURLs {
|
||||
k, err := ossutil.ExtractKeyFromPublicURL(cfg, u)
|
||||
if err != nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
set.add(k)
|
||||
}
|
||||
return set, skipped
|
||||
}
|
||||
|
||||
func humanBytes(n int64) string {
|
||||
const k = 1024
|
||||
if n < k {
|
||||
return fmt.Sprintf("%dB", n)
|
||||
}
|
||||
if n < k*k {
|
||||
return fmt.Sprintf("%.1fKB", float64(n)/k)
|
||||
}
|
||||
if n < k*k*k {
|
||||
return fmt.Sprintf("%.1fMB", float64(n)/(k*k))
|
||||
}
|
||||
return fmt.Sprintf("%.1fGB", float64(n)/(k*k*k))
|
||||
}
|
||||
@ -817,7 +817,7 @@ func (ctrl *AssetController) GetAssetStatus(c *gin.Context) {
|
||||
// @Security BearerAuth
|
||||
// @Param type query string false "上传类型: avatar/asset,默认asset"
|
||||
// @Param order_id query string false "订单ID(可选)。不传则后端生成,并在响应中返回,用于后续铸造全流程唯一标识"
|
||||
// @Success 200 {object} response.Response{data=object{order_id=string,policy=string,security_token=string,x_oss_signature_version=string,x_oss_credential=string,x_oss_date=string,signature=string,host=string,dir=string,expire_time=int}} "成功返回上传签名信息"
|
||||
// @Success 200 {object} response.Response{data=object{order_id=string,policy=string,security_token=string,x_oss_signature_version=string,x_oss_credential=string,x_oss_date=string,signature=string,host=string,dir=string,key=string,expire_time=int}} "成功返回上传签名信息"
|
||||
// @Failure 400 {object} response.Response "参数错误"
|
||||
// @Failure 401 {object} response.Response "未授权"
|
||||
// @Failure 500 {object} response.Response "OSS配置错误或生成签名失败"
|
||||
@ -898,13 +898,13 @@ func (ctrl *AssetController) GetOSSUploadSignature(c *gin.Context) {
|
||||
// GetPublicOSSUploadSignature 公开版 OSS 上传签名(用于注册等未登录场景的头像上传)
|
||||
// @Summary 获取公开 OSS 上传签名(注册流程用)
|
||||
// @Description 用于注册等未登录场景下上传头像,无需鉴权。
|
||||
// @Description 限定 key 必须以 avatar/register-pending/{key}/ 开头,key 由前端传入(如手机号),并要求 key 与 scene+key 一致。
|
||||
// @Description 后端生成唯一完整 key,policy 锁到该 key,前端只能写到指定路径;key 形如 avatar/register-pending/{key}/avatar_<uuid>.png。
|
||||
// @Tags assets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param scene query string true "场景,目前固定为 register"
|
||||
// @Param key query string true "前端传入的命名空间(注册时传 mobile),仅允许 [a-zA-Z0-9_-],最长 32 字符"
|
||||
// @Success 200 {object} response.Response "成功返回上传签名信息"
|
||||
// @Success 200 {object} response.Response{data=object{policy=string,security_token=string,x_oss_signature_version=string,x_oss_credential=string,x_oss_date=string,signature=string,host=string,dir=string,key=string,expire_time=int}} "成功返回上传签名信息"
|
||||
// @Failure 400 {object} response.Response "参数错误"
|
||||
// @Failure 500 {object} response.Response "OSS配置错误或生成签名失败"
|
||||
// @Router /api/v1/public/oss/upload-signature [get]
|
||||
@ -927,8 +927,10 @@ func (ctrl *AssetController) GetPublicOSSUploadSignature(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 强制使用 avatar 目录,并将 key 限定到 avatar/register-pending/{key}/ 子目录
|
||||
// 后端生成唯一文件名,policy 用 eq 锁到完整 key
|
||||
uploadDir := fmt.Sprintf("%sregister-pending/%s/", cfg.OSS.AvatarDir, key)
|
||||
token, err := ctrl.generateOSSPolicyTokenWithDir(cfg.OSS, uploadDir)
|
||||
uniqueFilename := fmt.Sprintf("avatar_%s.png", uuid.New().String())
|
||||
token, err := ctrl.generateOSSPolicyTokenWithKey(cfg.OSS, uploadDir+uniqueFilename)
|
||||
if err != nil {
|
||||
logger.Logger.Error("Generate public OSS signature failed",
|
||||
zap.Error(err),
|
||||
@ -979,9 +981,108 @@ func (ctrl *AssetController) generateOSSPolicyToken(
|
||||
if userID != nil && starID != nil {
|
||||
uploadDir = fmt.Sprintf("%s%d/%d/", baseDir, userID, starID)
|
||||
}
|
||||
|
||||
// 头像上传:后端生成唯一 key,避免
|
||||
// 1) URL 字符串相同导致 webview/CDN 命中老图缓存
|
||||
// 2) 并发上传时两路相互覆盖
|
||||
// 3) 上传中途失败留下半截文件
|
||||
// policy 用 eq 锁到该 key,前端只能写到指定路径
|
||||
if uploadType == "avatar" {
|
||||
uniqueFilename := fmt.Sprintf("avatar_%s.png", uuid.New().String())
|
||||
return ctrl.generateOSSPolicyTokenWithKey(ossConfig, uploadDir+uniqueFilename)
|
||||
}
|
||||
|
||||
return ctrl.generateOSSPolicyTokenWithDir(ossConfig, uploadDir)
|
||||
}
|
||||
|
||||
// dirFromKey 从完整 OSS key 提取目录(含末尾 /)
|
||||
func dirFromKey(key string) string {
|
||||
idx := strings.LastIndex(key, "/")
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
return key[:idx+1]
|
||||
}
|
||||
|
||||
// generateOSSPolicyTokenWithKey 按显式完整 key(OSS 对象路径)生成 OSS 上传策略和签名
|
||||
// 与 WithDir 区别:policy 用 eq 锁到该 key(更严),返回值带 key 供前端直接使用
|
||||
// 适用场景:头像上传——后端控制完整 key,URL 天然每次唯一
|
||||
func (ctrl *AssetController) generateOSSPolicyTokenWithKey(
|
||||
ossConfig config.OSSConfig,
|
||||
uploadKey string,
|
||||
) (map[string]interface{}, error) {
|
||||
// 1. 创建 STS 凭证提供器
|
||||
credConfig := new(credentials.Config).
|
||||
SetType("ram_role_arn").
|
||||
SetAccessKeyId(ossConfig.AccessKeyID).
|
||||
SetAccessKeySecret(ossConfig.AccessKeySecret).
|
||||
SetRoleArn(ossConfig.RoleArn).
|
||||
SetRoleSessionName("topfans-upload-session").
|
||||
SetPolicy("").
|
||||
SetRoleSessionExpiration(ossConfig.TokenExpireTime)
|
||||
|
||||
provider, err := credentials.NewCredential(credConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建凭证提供器失败: %w", err)
|
||||
}
|
||||
|
||||
// 2. 获取临时凭证
|
||||
cred, err := provider.GetCredential()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取临时凭证失败: %w", err)
|
||||
}
|
||||
|
||||
// 3. 构建 Policy
|
||||
utcTime := time.Now().UTC()
|
||||
date := utcTime.Format("20060102")
|
||||
expiration := utcTime.Add(time.Duration(ossConfig.TokenExpireTime) * time.Second)
|
||||
|
||||
// 构建 Policy 条件
|
||||
conditions := []interface{}{
|
||||
map[string]string{"bucket": ossConfig.BucketName},
|
||||
// 限制 key 必须等于指定完整 key(更严:前端只能写到该路径)
|
||||
[]interface{}{"eq", "$key", uploadKey},
|
||||
map[string]string{"x-oss-signature-version": "OSS4-HMAC-SHA256"},
|
||||
map[string]string{"x-oss-credential": fmt.Sprintf("%s/%s/%s/%s/aliyun_v4_request",
|
||||
*cred.AccessKeyId, date, ossConfig.Region, "oss")},
|
||||
map[string]string{"x-oss-date": utcTime.Format("20060102T150405Z")},
|
||||
map[string]string{"x-oss-security-token": *cred.SecurityToken},
|
||||
}
|
||||
|
||||
policyMap := map[string]interface{}{
|
||||
"expiration": expiration.Format("2006-01-02T15:04:05.000Z"),
|
||||
"conditions": conditions,
|
||||
}
|
||||
|
||||
// 4. 生成 Policy 的 Base64 编码
|
||||
policyJSON, err := json.Marshal(policyMap)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化 policy 失败: %w", err)
|
||||
}
|
||||
policyBase64 := base64.StdEncoding.EncodeToString(policyJSON)
|
||||
|
||||
// 5. 计算签名
|
||||
signingKey := buildSigningKey(*cred.AccessKeySecret, date, ossConfig.Region, "oss")
|
||||
signature := calculateSignature(signingKey, policyBase64)
|
||||
|
||||
// 6. 构建返回数据
|
||||
host := fmt.Sprintf("https://%s.oss-%s.aliyuncs.com", ossConfig.BucketName, ossConfig.Region)
|
||||
|
||||
return map[string]interface{}{
|
||||
"policy": policyBase64,
|
||||
"security_token": *cred.SecurityToken,
|
||||
"x_oss_signature_version": "OSS4-HMAC-SHA256",
|
||||
"x_oss_credential": fmt.Sprintf("%s/%s/%s/%s/aliyun_v4_request",
|
||||
*cred.AccessKeyId, date, ossConfig.Region, "oss"),
|
||||
"x_oss_date": utcTime.Format("20060102T150405Z"),
|
||||
"signature": signature,
|
||||
"host": host,
|
||||
"dir": dirFromKey(uploadKey),
|
||||
"key": uploadKey,
|
||||
"expire_time": expiration.Unix(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generateOSSPolicyTokenWithDir 按显式目录生成 OSS 上传策略和签名
|
||||
// (用于公开场景等需要把 key 限定到非 userID/starID 命名空间的场景)
|
||||
func (ctrl *AssetController) generateOSSPolicyTokenWithDir(
|
||||
|
||||
@ -9,7 +9,9 @@ import (
|
||||
"dubbo.apache.org/dubbo-go/v3/client"
|
||||
"dubbo.apache.org/dubbo-go/v3/common/constant"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/topfans/backend/gateway/config"
|
||||
"github.com/topfans/backend/gateway/dto"
|
||||
"github.com/topfans/backend/gateway/pkg/ossutil"
|
||||
"github.com/topfans/backend/gateway/pkg/response"
|
||||
"github.com/topfans/backend/pkg/logger"
|
||||
pb "github.com/topfans/backend/pkg/proto/user"
|
||||
@ -332,13 +334,14 @@ func (ctrl *UserController) UpdatePassword(c *gin.Context) {
|
||||
|
||||
// UpdateAvatar 更新用户头像
|
||||
// @Summary 更新用户头像
|
||||
// @Description 更新当前用户的头像URL
|
||||
// @Description 更新当前用户的头像URL。提交前会校验 OSS 对象真实存在,避免 user 表指向不存在的对象。
|
||||
// @Tags users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body pb.UpdateAvatarRequest true "更新头像请求"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Failure 400 {object} response.Response "头像URL无效或对象不存在"
|
||||
// @Router /api/v1/me/avatar [put]
|
||||
func (ctrl *UserController) UpdateAvatar(c *gin.Context) {
|
||||
var req pb.UpdateAvatarRequest
|
||||
@ -360,6 +363,27 @@ func (ctrl *UserController) UpdateAvatar(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 提取 OSS key 并校验对象真实存在,避免 user 表写入一个尚未落地的 URL
|
||||
cfg := config.Load()
|
||||
ossKey, err := ossutil.ExtractKeyFromPublicURL(&cfg.OSS, req.AvatarUrl)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "头像URL格式不合法: "+err.Error())
|
||||
return
|
||||
}
|
||||
exist, err := ossutil.Head(&cfg.OSS, ossKey)
|
||||
if err != nil {
|
||||
logger.Logger.Error("OSS HEAD failed during UpdateAvatar",
|
||||
zap.String("oss_key", ossKey),
|
||||
zap.Error(err),
|
||||
)
|
||||
response.Error(c, http.StatusInternalServerError, "校验头像对象失败")
|
||||
return
|
||||
}
|
||||
if !exist {
|
||||
response.BadRequest(c, "头像对象在 OSS 上不存在,请先完成上传")
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := c.Get("user_id")
|
||||
starID, _ := c.Get("star_id")
|
||||
|
||||
@ -389,7 +413,6 @@ func (ctrl *UserController) UpdateAvatar(c *gin.Context) {
|
||||
"avatar_url": resp.AvatarUrl,
|
||||
})
|
||||
}
|
||||
|
||||
// AddIdentity 添加粉丝身份
|
||||
// @Summary 添加粉丝身份
|
||||
// @Description 为当前用户添加一个新的明星粉丝身份
|
||||
|
||||
185
backend/gateway/pkg/ossutil/ossutil.go
Normal file
185
backend/gateway/pkg/ossutil/ossutil.go
Normal file
@ -0,0 +1,185 @@
|
||||
// Package ossutil 提供 gateway 侧对 OSS 的轻量操作封装。
|
||||
//
|
||||
// 涉及 STS 凭证的操作建议在请求粒度调用即可(不要跨请求复用 STS,
|
||||
// STS token 过期会导致后续调用 403)。每次调用都换一次凭证,延迟成本可接受。
|
||||
package ossutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aliyun/aliyun-oss-go-sdk/oss"
|
||||
"github.com/aliyun/credentials-go/credentials"
|
||||
"github.com/topfans/backend/gateway/config"
|
||||
"github.com/topfans/backend/pkg/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ObjectInfo 描述 OSS 对象的关键元数据,供调用方做"按时间过滤孤儿"等判断。
|
||||
type ObjectInfo struct {
|
||||
Key string
|
||||
Size int64
|
||||
LastModified time.Time
|
||||
}
|
||||
|
||||
// ExtractKeyFromPublicURL 从完整 OSS 公开 URL 提取对象 key。
|
||||
// 仅接受本 bucket 域名(https://<bucket>.oss-<region>.aliyuncs.com/<key>),
|
||||
// 防止调用方写入指向其他 bucket / 公网图片的 URL。
|
||||
// 任何 query string(缓存击穿用)都会被忽略,只取 path 里的 key。
|
||||
func ExtractKeyFromPublicURL(cfg *config.OSSConfig, rawURL string) (string, error) {
|
||||
if cfg == nil {
|
||||
return "", fmt.Errorf("ossutil: cfg 不能为空")
|
||||
}
|
||||
if rawURL == "" {
|
||||
return "", fmt.Errorf("URL 不能为空")
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("URL 解析失败: %w", err)
|
||||
}
|
||||
if u.Scheme != "https" && u.Scheme != "http" {
|
||||
return "", fmt.Errorf("仅支持 http(s) URL")
|
||||
}
|
||||
expectedHost := fmt.Sprintf("%s.oss-%s.aliyuncs.com", cfg.BucketName, cfg.Region)
|
||||
if u.Host != expectedHost {
|
||||
return "", fmt.Errorf("URL host 必须是本 bucket 域名 %s", expectedHost)
|
||||
}
|
||||
key := strings.TrimPrefix(u.Path, "/")
|
||||
if key == "" {
|
||||
return "", fmt.Errorf("URL 必须包含对象 key")
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// newClient 创建一个使用 STS 临时凭证的 OSS 客户端。
|
||||
// 调用方负责 client 生命周期;本包内 helper 各自建一个,不跨调用复用。
|
||||
func newClient(cfg *config.OSSConfig) (*oss.Client, *oss.Bucket, func(), error) {
|
||||
credConfig := new(credentials.Config).
|
||||
SetType("ram_role_arn").
|
||||
SetAccessKeyId(cfg.AccessKeyID).
|
||||
SetAccessKeySecret(cfg.AccessKeySecret).
|
||||
SetRoleArn(cfg.RoleArn).
|
||||
SetRoleSessionName("topfans-ossutil").
|
||||
SetPolicy("").
|
||||
SetRoleSessionExpiration(cfg.TokenExpireTime)
|
||||
|
||||
provider, err := credentials.NewCredential(credConfig)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("创建凭证提供器失败: %w", err)
|
||||
}
|
||||
cred, err := provider.GetCredential()
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("获取临时凭证失败: %w", err)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("https://oss-%s.aliyuncs.com", cfg.Region)
|
||||
client, err := oss.New(endpoint, *cred.AccessKeyId, *cred.AccessKeySecret,
|
||||
oss.SecurityToken(*cred.SecurityToken))
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("创建OSS客户端失败: %w", err)
|
||||
}
|
||||
bucket, err := client.Bucket(cfg.BucketName)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("获取Bucket失败: %w", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
// OSS SDK 当前没有需要显式关闭的连接;留作未来扩展
|
||||
}
|
||||
return client, bucket, cleanup, nil
|
||||
}
|
||||
|
||||
// Head 检查指定 key 的对象是否存在。
|
||||
// 返回 (true, nil) 表示存在;(false, nil) 表示不存在但调用成功;
|
||||
// 返回 (_, err) 表示调用本身失败(网络/权限等)。
|
||||
func Head(cfg *config.OSSConfig, key string) (bool, error) {
|
||||
if cfg == nil {
|
||||
return false, fmt.Errorf("ossutil: cfg 不能为空")
|
||||
}
|
||||
if key == "" {
|
||||
return false, fmt.Errorf("ossutil: key 不能为空")
|
||||
}
|
||||
_, bucket, cleanup, err := newClient(cfg)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
exist, err := bucket.IsObjectExist(key)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("OSS HEAD 失败: %w", err)
|
||||
}
|
||||
return exist, nil
|
||||
}
|
||||
|
||||
// Delete 删除指定 key 的对象。
|
||||
func Delete(cfg *config.OSSConfig, key string) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("ossutil: cfg 不能为空")
|
||||
}
|
||||
if key == "" {
|
||||
return fmt.Errorf("ossutil: key 不能为空")
|
||||
}
|
||||
_, bucket, cleanup, err := newClient(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
if err := bucket.DeleteObject(key); err != nil {
|
||||
logger.Logger.Warn("OSS DeleteObject 失败", zap.String("key", key), zap.Error(err))
|
||||
return fmt.Errorf("OSS DELETE 失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List 列出指定前缀下的所有对象元数据(不含公共前缀本身)。
|
||||
// maxKeys 传 <=0 时使用 OSS 默认分页行为,每次最多 1000 个,内部循环拉完。
|
||||
func List(cfg *config.OSSConfig, prefix string, maxKeys int) ([]ObjectInfo, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("ossutil: cfg 不能为空")
|
||||
}
|
||||
_, bucket, cleanup, err := newClient(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
var objs []ObjectInfo
|
||||
var continuationToken string
|
||||
for {
|
||||
opts := []oss.Option{}
|
||||
if prefix != "" {
|
||||
opts = append(opts, oss.Prefix(prefix))
|
||||
}
|
||||
if continuationToken != "" {
|
||||
opts = append(opts, oss.ContinuationToken(continuationToken))
|
||||
}
|
||||
if maxKeys > 0 {
|
||||
// 限制单次返回数量,OSS 单次最大 1000
|
||||
if maxKeys > 1000 {
|
||||
maxKeys = 1000
|
||||
}
|
||||
opts = append(opts, oss.MaxKeys(maxKeys))
|
||||
}
|
||||
|
||||
result, err := bucket.ListObjects(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("OSS ListObjects 失败: %w", err)
|
||||
}
|
||||
for _, obj := range result.Objects {
|
||||
objs = append(objs, ObjectInfo{
|
||||
Key: obj.Key,
|
||||
Size: obj.Size,
|
||||
LastModified: obj.LastModified,
|
||||
})
|
||||
}
|
||||
if !result.IsTruncated {
|
||||
break
|
||||
}
|
||||
continuationToken = result.NextMarker
|
||||
}
|
||||
return objs, nil
|
||||
}
|
||||
@ -69,7 +69,7 @@ const navItems = [
|
||||
iconHeight: '120rpx'
|
||||
},
|
||||
{
|
||||
name: '广场',
|
||||
name: '我的',
|
||||
icon: '/static/icon/square.png',
|
||||
angle: 57, // 右下方
|
||||
path: '/pages/profile/myWorks'
|
||||
|
||||
@ -1100,6 +1100,8 @@ const closeAvatarModal = () => {
|
||||
|
||||
// 处理头像更新后的操作(更新本地存储、刷新UI)
|
||||
const handleAvatarUpdateSuccess = async (newAvatarUrl) => {
|
||||
// 后端签名接口返回的 key 每次都唯一(avatar_<uuid>.png),
|
||||
// URL 字符串天然不同,<image> 会重新拉取,无需再追加 ?t= 兜底
|
||||
// 1. 更新本地缓存
|
||||
userAvatarUrl.value = newAvatarUrl;
|
||||
const userStr = uni.getStorageSync('user');
|
||||
@ -1180,13 +1182,14 @@ const uploadAvatarToOss = async (filePath) => {
|
||||
}
|
||||
console.log(signRes)
|
||||
// 2. 构建FormData并上传到OSS
|
||||
// 注意:文件名固定为avatar.png
|
||||
// 直接用后端返回的 key(每次唯一),避免覆盖原图、并发竞态、webview 缓存命中老图
|
||||
const ossKey = signRes.data.key
|
||||
uni.uploadFile({
|
||||
url: signRes.data.host,
|
||||
filePath: filePath,
|
||||
name: 'file',
|
||||
formData: {
|
||||
key: signRes.data.dir + 'avatar.png',
|
||||
key: ossKey,
|
||||
policy: signRes.data.policy,
|
||||
success_action_status: '200',
|
||||
'x-oss-credential': signRes.data.x_oss_credential,
|
||||
@ -1198,8 +1201,8 @@ const uploadAvatarToOss = async (filePath) => {
|
||||
success: async (uploadRes) => {
|
||||
try {
|
||||
if (uploadRes.statusCode === 200 || uploadRes.statusCode === 204) {
|
||||
// 3. 拼接完整URL
|
||||
const avatarUrl = `${signRes.data.host}/${signRes.data.dir}avatar.png`;
|
||||
// 3. 拼接完整URL(用后端返回的 key,每次唯一)
|
||||
const avatarUrl = `${signRes.data.host}/${ossKey}`;
|
||||
|
||||
// 4. 清除旧头像的缓存(如果有)
|
||||
if (userAvatarUrl.value) {
|
||||
|
||||
@ -178,13 +178,14 @@ const uploadAvatarToOss = async (filePath) => {
|
||||
throw new Error(signRes.message || '获取签名失败');
|
||||
}
|
||||
|
||||
// 2. 上传到 OSS(文件名固定 avatar.png,避免签名 policy 范围外)
|
||||
// 2. 上传到 OSS(用后端返回的 key,每次唯一,避免覆盖 / 缓存命中)
|
||||
const ossKey = signRes.data.key
|
||||
uni.uploadFile({
|
||||
url: signRes.data.host,
|
||||
filePath: filePath,
|
||||
name: 'file',
|
||||
formData: {
|
||||
key: signRes.data.dir + 'avatar.png',
|
||||
key: ossKey,
|
||||
policy: signRes.data.policy,
|
||||
success_action_status: '200',
|
||||
'x-oss-credential': signRes.data.x_oss_credential,
|
||||
@ -196,7 +197,7 @@ const uploadAvatarToOss = async (filePath) => {
|
||||
success: (uploadRes) => {
|
||||
uni.hideLoading();
|
||||
if (uploadRes.statusCode === 200 || uploadRes.statusCode === 204) {
|
||||
userAvatarUrl.value = `${signRes.data.host}/${signRes.data.dir}avatar.png`;
|
||||
userAvatarUrl.value = `${signRes.data.host}/${ossKey}`;
|
||||
uni.showToast({
|
||||
title: '头像已选择',
|
||||
icon: 'success'
|
||||
|
||||
Loading…
Reference in New Issue
Block a user