profile.vue only rendered fan level digit, hiding the fact that fans level up via accumulated exhibition hours (6h/level, cap 20), not login/task experience. Users saw "LV X" without "X more hours to next level", so upgrades felt invisible. - backend: surface exhibition_hours / next_level_hours on CurrentIdentityDTO via a new loadLevelProgress helper in user_controller (read-only, no proto regen, no service surface change). Full-level responses set next_level_hours = exhibition_hours so the client can derive progress=1. - frontend: split level-box into base track + gold fill + text overlay; width bound to progressRatio with 0.4s transition. Adds "距 LV X+1 还差 N 小时" hint and MAX indicator for full-level users.
720 lines
23 KiB
Go
720 lines
23 KiB
Go
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
|
||
}
|