package controller import ( "context" "errors" "fmt" "net/http" "strconv" "strings" "time" "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/gateway/pkg/starcache" "github.com/topfans/backend/pkg/database" "github.com/topfans/backend/pkg/jwt" "github.com/topfans/backend/pkg/logger" "github.com/topfans/backend/pkg/models" pb "github.com/topfans/backend/pkg/proto/user" userSvcRepo "github.com/topfans/backend/services/userService/repository" userSvc "github.com/topfans/backend/services/userService/service" "go.uber.org/zap" "gorm.io/gorm" ) // UserController 用户控制器 type UserController struct { userServiceClient pb.UserSocialService starCache *starcache.Cache // authSvc 用于 DeleteAccount 直调 userService 服务层 (避免新增 proto/RPC)。 // 架构权衡:gateway 通过 Go import 直接调 userService/service.DeleteAccount, // 在 gateway 进程内执行(共享同一 postgres DB 连接)。这是 service-stability // plan §3.8 的已知妥协 — 真正的 RPC 化留待批次 5(proto regen 工件链稳定后)。 authSvc userSvc.AuthService } // NewUserController 创建用户控制器 // // starCache 用于 GetCurrentUser/GetMyProfile/AddIdentity/SwitchIdentity // 几个 GET 路径上的 star 解析:取代原先每次都直接 RPC GetFanIdentities // 拉一遍可选身份列表。详见 pkg/starcache。 func NewUserController(dubboClient *client.Client, starCache *starcache.Cache) (*UserController, error) { svc, err := pb.NewUserSocialService(dubboClient) if err != nil { return nil, err } // DeleteAccount 直调:实例化 userService 的 AuthService(service 层), // 与 gateway 共享同一 postgres 连接(database 包是进程级单例)。 authSvc := userSvc.NewAuthService( userSvcRepo.NewUserRepository(), userSvcRepo.NewFanProfileRepository(), userSvcRepo.NewStarRepository(), database.GetDB(), ) return &UserController{ userServiceClient: svc, starCache: starCache, authSvc: authSvc, }, nil } // findStar 解析 starID 对应的 *pb.Star。失败仅为 warn,不阻断主流程。 func (ctrl *UserController) findStar(ctx context.Context, starID int64) *pb.Star { star, err := ctrl.starCache.GetStar(ctx, starID) if err != nil { logger.Logger.Warn("GetStar cache miss+RPC failed, continuing with nil star", zap.Int64("star_id", starID), zap.Error(err), ) return nil } return star } // GetCurrentUser 获取当前用户信息 // @Summary 获取当前用户信息 // @Description 获取当前登录用户的完整信息 // @Tags users // @Accept json // @Produce json // @Security BearerAuth // @Success 200 {object} response.Response{data=dto.GetMeResponseDTO} // @Router /api/v1/auth/me [get] func (ctrl *UserController) GetCurrentUser(c *gin.Context) { // 从认证中间件获取用户信息 userID, _ := c.Get("user_id") starID, _ := c.Get("star_id") logger.Logger.Info("GetCurrentUser request", zap.Any("user_id", userID), zap.Any("star_id", starID), ) // 创建带 Attachments 的 context // 注意:Dubbo Attachments 的值必须是 string 或 []string ctx := context.Background() userIDStr := strconv.FormatInt(userID.(int64), 10) starIDStr := strconv.FormatInt(starID.(int64), 10) attachments := map[string]interface{}{ "user_id": userIDStr, "star_id": starIDStr, } logger.Logger.Info("Setting Dubbo attachments", zap.String("user_id", userIDStr), zap.String("star_id", starIDStr), zap.String("attachment_key", fmt.Sprintf("%v", constant.AttachmentKey)), ) ctx = context.WithValue(ctx, constant.AttachmentKey, attachments) // 调用 Dubbo 服务 resp, err := ctrl.userServiceClient.GetCurrentUser(ctx, &pb.GetCurrentUserRequest{}) if err != nil { logger.Logger.Error("GetCurrentUser failed", zap.Error(err)) response.HandleError(c, err) return } star := ctrl.findStar(ctx, resp.FanProfile.StarId) // 升级进度:累计上架时长 + 下一级阈值(满级时 nextLevelHours = exhibitionHours 表示 100%) exhibitionHours, nextLevelHours := loadLevelProgress(userID.(int64), starID.(int64), int64(resp.FanProfile.Level)) // 转换为 DTO 并返回 data := dto.ToGetMeResponseDTO(resp.User, resp.FanProfile, star, exhibitionHours, nextLevelHours) response.Success(c, data) } // GetUser 获取指定用户信息(公开接口) // @Summary 获取指定用户信息 // @Description 根据用户ID获取用户公开信息 // @Tags users // @Accept json // @Produce json // @Param user_id path int true "用户ID" // @Success 200 {object} response.Response // @Router /api/v1/users/{user_id} [get] func (ctrl *UserController) GetUser(c *gin.Context) { userIDStr := c.Param("user_id") userID, err := strconv.ParseInt(userIDStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{ "code": "BAD_REQUEST", "message": "invalid user_id", }) return } // 调用 Dubbo 服务(无需 Attachments) ctx := context.Background() resp, err := ctrl.userServiceClient.GetUser(ctx, &pb.GetUserRequest{ UserId: userID, }) if err != nil { logger.Logger.Error("GetUser failed", zap.Error(err)) response.InternalError(c, "获取用户信息失败") return } c.JSON(http.StatusOK, resp) } // GetMyProfile 获取当前用户粉丝档案 // @Summary 获取当前用户粉丝档案 // @Description 获取当前登录用户的粉丝档案信息 // @Tags users // @Accept json // @Produce json // @Security BearerAuth // @Success 200 {object} response.Response{data=dto.ProfileResponseDTO} // @Router /api/v1/me/profile [get] func (ctrl *UserController) GetMyProfile(c *gin.Context) { userID, _ := c.Get("user_id") starID, _ := c.Get("star_id") logger.Logger.Debug("GetMyProfile request", zap.Any("user_id", userID), zap.Any("star_id", starID), ) // 创建带 Attachments 的 context // 注意:Dubbo Attachments 的值必须是 string 或 []string ctx := context.Background() ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{ "user_id": strconv.FormatInt(userID.(int64), 10), "star_id": strconv.FormatInt(starID.(int64), 10), }) // 调用 Dubbo 服务 resp, err := ctrl.userServiceClient.GetMyProfile(ctx, &pb.GetMyProfileRequest{}) if err != nil { logger.Logger.Error("GetMyProfile failed", zap.Error(err)) response.HandleError(c, err) return } star := ctrl.findStar(ctx, resp.FanProfile.StarId) // 转换为 DTO 并返回 data := dto.ToProfileResponseDTO(resp.User, resp.FanProfile, star) response.Success(c, data) } // GetFanProfile 获取指定粉丝档案(公开接口) // @Summary 获取粉丝档案 // @Description 根据用户ID和明星ID获取粉丝档案信息 // @Tags users // @Accept json // @Produce json // @Param user_id query int true "用户ID" // @Param star_id query int true "明星ID" // @Success 200 {object} response.Response // @Router /api/v1/fan-profiles [get] func (ctrl *UserController) GetFanProfile(c *gin.Context) { userIDStr := c.Query("user_id") starIDStr := c.Query("star_id") userID, err := strconv.ParseInt(userIDStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{ "code": "BAD_REQUEST", "message": "invalid user_id", }) return } starID, err := strconv.ParseInt(starIDStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{ "code": "BAD_REQUEST", "message": "invalid star_id", }) return } // 调用 Dubbo 服务(无需 Attachments) ctx := context.Background() resp, err := ctrl.userServiceClient.GetFanProfile(ctx, &pb.GetFanProfileRequest{ UserId: userID, StarId: starID, }) if err != nil { logger.Logger.Error("GetFanProfile failed", zap.Error(err)) response.InternalError(c, "获取粉丝档案失败") return } c.JSON(http.StatusOK, resp) } // UpdateNickname 更新昵称 // @Summary 更新用户昵称 // @Description 更新当前用户的粉丝昵称 // @Tags users // @Accept json // @Produce json // @Security BearerAuth // @Param request body pb.UpdateNicknameRequest true "更新昵称请求" // @Success 200 {object} response.Response{data=dto.UpdateNicknameResponseDTO} // @Router /api/v1/me/nickname [put] func (ctrl *UserController) UpdateNickname(c *gin.Context) { var req pb.UpdateNicknameRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "请求参数错误") return } userID, _ := c.Get("user_id") starID, _ := c.Get("star_id") // 创建带 Attachments 的 context // 注意:Dubbo Attachments 的值必须是 string 或 []string ctx := context.Background() ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{ "user_id": strconv.FormatInt(userID.(int64), 10), "star_id": strconv.FormatInt(starID.(int64), 10), }) // 调用 Dubbo 服务 resp, err := ctrl.userServiceClient.UpdateNickname(ctx, &req) if err != nil { logger.Logger.Error("UpdateNickname failed", zap.Error(err)) response.HandleError(c, err) return } // 转换为 DTO 并返回 data := dto.ToUpdateNicknameResponseDTO(resp.FanProfile.Nickname) response.Success(c, data) } // UpdatePassword 更新密码 // @Summary 更新用户密码 // @Description 更新当前用户的登录密码 // @Tags users // @Accept json // @Produce json // @Security BearerAuth // @Param request body pb.UpdatePasswordRequest true "更新密码请求" // @Success 200 {object} response.Response // @Router /api/v1/account/password [post] func (ctrl *UserController) UpdatePassword(c *gin.Context) { var req pb.UpdatePasswordRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "请求参数错误") return } userID, _ := c.Get("user_id") starID, _ := c.Get("star_id") // 创建带 Attachments 的 context // 注意:Dubbo Attachments 的值必须是 string 或 []string ctx := context.Background() ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{ "user_id": strconv.FormatInt(userID.(int64), 10), "star_id": strconv.FormatInt(starID.(int64), 10), }) // 调用 Dubbo 服务 _, err := ctrl.userServiceClient.UpdatePassword(ctx, &req) if err != nil { logger.Logger.Error("UpdatePassword failed", zap.Error(err)) response.HandleError(c, err) return } logger.Logger.Info("UpdatePassword successful", zap.Any("user_id", userID), ) // 返回空 data response.Success(c, gin.H{}) } // UpdateAvatar 更新用户头像 // @Summary 更新用户头像 // @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 if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "请求参数错误: "+err.Error()) return } // 验证URL格式 if req.AvatarUrl == "" { response.BadRequest(c, "avatar_url不能为空") return } // 使用 asset_controller 中的 isValidURL 函数验证URL格式 // 这里我们直接使用简单的验证 if len(req.AvatarUrl) > 500 { response.BadRequest(c, "头像URL过长,最大长度为500字符") 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") // 创建带 Attachments 的 context // 注意:Dubbo Attachments 的值必须是 string 或 []string ctx := context.Background() ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{ "user_id": strconv.FormatInt(userID.(int64), 10), "star_id": strconv.FormatInt(starID.(int64), 10), }) // 调用 Dubbo 服务 resp, err := ctrl.userServiceClient.UpdateAvatar(ctx, &req) if err != nil { logger.Logger.Error("UpdateAvatar failed", zap.Error(err)) response.HandleError(c, err) return } logger.Logger.Info("UpdateAvatar successful", zap.Any("user_id", userID), zap.String("avatar_url", resp.AvatarUrl), ) // 返回响应 response.Success(c, gin.H{ "avatar_url": resp.AvatarUrl, }) } // AddIdentity 添加粉丝身份 // @Summary 添加粉丝身份 // @Description 为当前用户添加一个新的明星粉丝身份 // @Tags users // @Accept json // @Produce json // @Security BearerAuth // @Param request body pb.AddIdentityRequest true "添加身份请求" // @Success 200 {object} response.Response{data=dto.AddIdentityResponseDTO} // @Router /api/v1/my/fan-identities [post] func (ctrl *UserController) AddIdentity(c *gin.Context) { var req pb.AddIdentityRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "请求参数错误") return } userID, _ := c.Get("user_id") starID, _ := c.Get("star_id") // 创建带 Attachments 的 context // 注意:Dubbo Attachments 的值必须是 string 或 []string ctx := context.Background() ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{ "user_id": strconv.FormatInt(userID.(int64), 10), "star_id": strconv.FormatInt(starID.(int64), 10), }) // 调用 Dubbo 服务 _, err := ctrl.userServiceClient.AddIdentity(ctx, &req) if err != nil { logger.Logger.Error("AddIdentity failed", zap.Error(err)) response.HandleError(c, err) return } star := ctrl.findStar(ctx, req.StarId) // 转换为 DTO 并返回 var identityID string if star != nil { identityID = star.IdentityId } data := dto.ToAddIdentityResponseDTO(identityID) response.Success(c, data) } // SwitchIdentity 切换粉丝身份 // @Summary 切换粉丝身份 // @Description 切换到指定的粉丝身份,返回新的访问令牌 // @Tags users // @Accept json // @Produce json // @Security BearerAuth // @Param request body pb.SwitchIdentityRequest true "切换身份请求" // @Success 200 {object} response.Response{data=dto.SwitchIdentityResponseDTO} // @Router /api/v1/my/fan-identities/switch [post] func (ctrl *UserController) SwitchIdentity(c *gin.Context) { var req pb.SwitchIdentityRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "请求参数错误") return } userID, _ := c.Get("user_id") starID, _ := c.Get("star_id") // 创建带 Attachments 的 context // 注意:Dubbo Attachments 的值必须是 string 或 []string ctx := context.Background() ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{ "user_id": strconv.FormatInt(userID.(int64), 10), "star_id": strconv.FormatInt(starID.(int64), 10), }) // 调用 Dubbo 服务 resp, err := ctrl.userServiceClient.SwitchIdentity(ctx, &req) if err != nil { logger.Logger.Error("SwitchIdentity failed", zap.Error(err)) response.HandleError(c, err) return } star := ctrl.findStar(ctx, resp.FanProfile.StarId) // 升级进度:累计上架时长 + 下一级阈值(满级时 nextLevelHours = exhibitionHours 表示 100%) exhibitionHours, nextLevelHours := loadLevelProgress(userID.(int64), starID.(int64), int64(resp.FanProfile.Level)) // 转换为 DTO 并返回 data := dto.ToSwitchIdentityResponseDTO( resp.AccessToken, resp.ExpiresIn, resp.FanProfile, star, exhibitionHours, nextLevelHours, ) response.Success(c, data) } // GetFanIdentities 获取可选粉丝身份列表(公开接口) // @Summary 获取可选粉丝身份列表 // @Description 获取所有可选的明星粉丝身份列表 // @Tags users // @Accept json // @Produce json // @Param keyword query string false "搜索关键词" // @Success 200 {object} response.Response{data=dto.FanIdentityListResponseDTO} // @Router /api/v1/fan-identities [get] func (ctrl *UserController) GetFanIdentities(c *gin.Context) { keyword := c.Query("keyword") // 调用 Dubbo 服务(无需 Attachments) ctx := context.Background() resp, err := ctrl.userServiceClient.GetFanIdentities(ctx, &pb.GetFanIdentitiesRequest{ Keyword: keyword, }) if err != nil { logger.Logger.Error("GetFanIdentities failed", zap.Error(err)) response.HandleError(c, err) return } // 转换为 DTO 并返回 data := dto.ToFanIdentityListResponseDTO(resp.Stars) response.Success(c, data) } // GetMyFanIdentities 获取我的粉丝身份列表 // @Summary 获取我的粉丝身份列表 // @Description 获取当前用户拥有的所有粉丝身份列表 // @Tags users // @Accept json // @Produce json // @Security BearerAuth // @Success 200 {object} response.Response{data=dto.MyFanIdentitiesResponseDTO} // @Router /api/v1/my/fan-identities [get] func (ctrl *UserController) GetMyFanIdentities(c *gin.Context) { // 从上下文中获取用户ID和明星ID(中间件已验证) userID, exists := c.Get("user_id") if !exists { response.Error(c, http.StatusUnauthorized, "用户未认证") return } starID, exists := c.Get("star_id") if !exists { response.Error(c, http.StatusUnauthorized, "明星身份未设置") return } // 创建 Context 并设置 Dubbo Attachments ctx := context.WithValue(context.Background(), constant.AttachmentKey, map[string]interface{}{ "user_id": strconv.FormatInt(userID.(int64), 10), "star_id": strconv.FormatInt(starID.(int64), 10), }) // 调用 Dubbo 服务 resp, err := ctrl.userServiceClient.GetMyFanIdentities(ctx, &pb.GetMyFanIdentitiesRequest{}) if err != nil { logger.Logger.Error("GetMyFanIdentities failed", zap.Int64("user_id", userID.(int64)), zap.Int64("star_id", starID.(int64)), zap.Error(err), ) response.HandleError(c, err) return } // 转换为 DTO 并返回 data := dto.ToMyFanIdentitiesResponseDTO(resp.Items, resp.CurrentStarId) response.Success(c, data) } // DeleteAccount 注销账号 // @Summary 注销账号 // @Description 软删除当前用户账号:停用账号和所有粉丝身份,释放手机号供重新注册。 // @Description 本接口分两步: // @Description 1) 网关本地把当前 JWT 写入 Redis 黑名单(防 JWT 在过期前被中间件放行,JWT 生命周期) // @Description 2) RPC userService.DeleteAccount 做软删(user 生命周期,userService 持有 ownership) // @Tags users // @Accept json // @Produce json // @Security BearerAuth // @Success 200 {object} response.Response // @Router /api/user/delete-account [post] func (ctrl *UserController) DeleteAccount(c *gin.Context) { // 从认证中间件获取用户信息 userID, exists := c.Get("user_id") if !exists { response.Error(c, http.StatusUnauthorized, "未授权") return } uid := userID.(int64) logger.Logger.Info("DeleteAccount request", zap.Int64("user_id", uid), ) // Step 1: 网关本地把当前 JWT 写入黑名单(JWT 生命周期,网关职责) // 解析 token 拿剩余有效期,Redis TTL = 剩余有效时间 tokenTTL := 7 * 24 * time.Hour // 默认 7 天 authHeader := c.GetHeader("Authorization") rawToken := strings.TrimPrefix(authHeader, "Bearer ") if claims, parseErr := jwt.ParseToken(rawToken); parseErr == nil && claims.ExpiresAt != nil { if remaining := time.Until(claims.ExpiresAt.Time); remaining > 0 { tokenTTL = remaining } } if rawToken != "" { // Redis 不可用时记日志但不阻断注销流程(用户生命周期更重要) blacklistCtx := context.Background() if redisErr := database.AddToBlacklist(blacklistCtx, rawToken, uid, "account_deleted", tokenTTL); redisErr != nil { logger.Logger.Warn("DeleteAccount: failed to blacklist token", zap.Int64("user_id", uid), zap.Error(redisErr), ) } } // Step 2: 直调 userService.AuthService.DeleteAccount(uid) 做软删 // 已知架构妥协:proto regen 工件链不稳定(user.triple.go 是手写的,proto 加 RPC // 方法需要手工同步 triple.go),本批次不走 gRPC,改为 Go import 同进程共享 DB 连接。 // 真正的 cross-process RPC 化留待批次 5 proto regen 流程稳定后(known TODO ticket)。 if _, err := ctrl.authSvc.DeleteAccount(uid); err != nil { logger.Logger.Error("DeleteAccount service call failed", zap.Int64("user_id", uid), zap.Error(err), ) response.Error(c, http.StatusInternalServerError, "注销失败,请稍后重试") return } logger.Logger.Info("DeleteAccount successful", zap.Int64("user_id", uid), ) response.SuccessWithMessage(c, "账号已注销", nil) } // loadLevelProgress 读取 (userID, starID) 的累计上架时长与下一级阈值,供前端展示升级进度。 // // 返回 (exhibitionHours, nextLevelHours): // - 满级(currentLevel >= level_cap_config.max_level)时返回 (累计时长, 累计时长), // 前端 progress=1 即可,无需下一级阈值。 // - 任意查询失败(无记录 / DB 不可用)时安全降级为 (0, 0),让前端 progress=0,不影响主链路。 // // 注:gateway 共享同一 postgres 连接(database.GetDB 是进程级单例),这里只读、只附加查询, // 不写入,不动 proto,不污染 userService。 func loadLevelProgress(userID, starID, currentLevel int64) (int64, int64) { db := database.GetDB() if db == nil || userID <= 0 || starID <= 0 { return 0, 0 } // 1. 累计上架时长 var eh models.UserExhibitionHours exhibitionHours := int64(0) if err := db.Select("total_exhibition_hours"). Where("user_id = ? AND star_id = ?", userID, starID). First(&eh).Error; err == nil { exhibitionHours = eh.TotalExhibitionHours } else if !errors.Is(err, gorm.ErrRecordNotFound) { // 真实查询错误:warn 但不阻断主链路 logger.Logger.Warn("loadLevelProgress: read user_exhibition_hours failed", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Error(err)) } // 2. 等级上限(满级判定) maxLevel := userSvcRepo.GetLevelCap() if currentLevel >= int64(maxLevel) { return exhibitionHours, exhibitionHours } // 3. 下一级阈值:取 level_thresholds 中 level = currentLevel+1 的 max_exhibition_hours var next models.LevelThreshold if err := db.Select("max_exhibition_hours"). Where("level = ?", currentLevel+1). First(&next).Error; err != nil { // 无下一级配置(理论上不该发生)→ 降级 logger.Logger.Warn("loadLevelProgress: read level_thresholds next level failed", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Int64("current_level", currentLevel), zap.Error(err)) return exhibitionHours, exhibitionHours } return exhibitionHours, next.MaxExhibitionHours }