topfans/backend/gateway/controller/user_controller.go
zerosaturation 27313f414a feat(profile): render level progress bar in LV box
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.
2026-07-27 18:02:59 +08:00

720 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}