diff --git a/backend/cleanup-orphan-avatars b/backend/cleanup-orphan-avatars new file mode 100755 index 0000000..3f197a6 Binary files /dev/null and b/backend/cleanup-orphan-avatars differ diff --git a/backend/gateway/cmd/cleanup-orphan-avatars/main.go b/backend/gateway/cmd/cleanup-orphan-avatars/main.go new file mode 100644 index 0000000..994baa2 --- /dev/null +++ b/backend/gateway/cmd/cleanup-orphan-avatars/main.go @@ -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)) +} diff --git a/backend/gateway/controller/asset_controller.go b/backend/gateway/controller/asset_controller.go index 8b96a4a..d89333f 100644 --- a/backend/gateway/controller/asset_controller.go +++ b/backend/gateway/controller/asset_controller.go @@ -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_.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( diff --git a/backend/gateway/controller/user_controller.go b/backend/gateway/controller/user_controller.go index b5e8f38..39644b3 100644 --- a/backend/gateway/controller/user_controller.go +++ b/backend/gateway/controller/user_controller.go @@ -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 为当前用户添加一个新的明星粉丝身份 diff --git a/backend/gateway/pkg/ossutil/ossutil.go b/backend/gateway/pkg/ossutil/ossutil.go new file mode 100644 index 0000000..351dd4e --- /dev/null +++ b/backend/gateway/pkg/ossutil/ossutil.go @@ -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://.oss-.aliyuncs.com/), +// 防止调用方写入指向其他 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 +} diff --git a/frontend/pages/components/BottomNav.vue b/frontend/pages/components/BottomNav.vue index 7f8bf2b..145ebb2 100644 --- a/frontend/pages/components/BottomNav.vue +++ b/frontend/pages/components/BottomNav.vue @@ -69,7 +69,7 @@ const navItems = [ iconHeight: '120rpx' }, { - name: '广场', + name: '我的', icon: '/static/icon/square.png', angle: 57, // 右下方 path: '/pages/profile/myWorks' diff --git a/frontend/pages/profile/profile.vue b/frontend/pages/profile/profile.vue index e4efdef..fd75037 100644 --- a/frontend/pages/profile/profile.vue +++ b/frontend/pages/profile/profile.vue @@ -1100,6 +1100,8 @@ const closeAvatarModal = () => { // 处理头像更新后的操作(更新本地存储、刷新UI) const handleAvatarUpdateSuccess = async (newAvatarUrl) => { + // 后端签名接口返回的 key 每次都唯一(avatar_.png), + // URL 字符串天然不同, 会重新拉取,无需再追加 ?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) { diff --git a/frontend/pages/profile/setNickname.vue b/frontend/pages/profile/setNickname.vue index b60ba5d..4eddd1d 100644 --- a/frontend/pages/profile/setNickname.vue +++ b/frontend/pages/profile/setNickname.vue @@ -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'