package service import ( "context" "strings" "git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global" "git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/dto" "git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqldao" "git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel" "git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/cos" "git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode" ) // UserService 用户管理服务。 type UserService struct { userDao *mysqldao.UserDao quotaGrantDao *mysqldao.QuotaGrantDao } // NewUserService 创建用户管理服务。 func NewUserService() *UserService { return &UserService{ userDao: mysqldao.NewUserDao(), quotaGrantDao: mysqldao.NewQuotaGrantDao(), } } // List 分页查询用户(关键词/状态筛选)。 func (s *UserService) List(ctx context.Context, request *dto.UserListRequest) (*dto.UserListResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.AdminUserList") defer span.End() page, pageSize := normalizePage(request.Page, request.PageSize) list, total, err := s.userDao.UserList(ctx, mysqldao.UserListParams{ Keyword: strings.TrimSpace(request.Keyword), Status: request.Status, Page: page, Size: pageSize, }) if err != nil { return nil, errcode.DatabaseError } items := make([]dto.UserListItem, 0, len(list)) for i := range list { items = append(items, buildUserListItem(&list[i])) } return &dto.UserListResponse{ PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize}, List: items, }, nil } // Detail 用户详情:完整展示永久额度(Cos)与每日额度(AI/社区/衣柜)+ 额度凭证列表。 func (s *UserService) Detail(ctx context.Context, userUUID string) (*dto.UserDetailResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.AdminUserDetail") defer span.End() user, err := s.userDao.UserGetByUUID(ctx, userUUID) if err != nil { if isNotFound(err) { return nil, errcode.UserNotFound } return nil, errcode.DatabaseError } now := nowUnix() grants, err := s.quotaGrantDao.QuotaGrantListByUserID(ctx, user.ID) if err != nil { return nil, errcode.DatabaseError } // 有效凭证按类型聚合(expire_time=0 跟随 VIP)。 validAmount := map[int8]int64{} validUsed := map[int8]int64{} for _, g := range grants { if grantExpired(g.ExpireTime, user.VIPExpire, now) { continue } validAmount[g.QuotaType] += g.Amount validUsed[g.QuotaType] += g.Used } // Cos 永久已用(user_quotas.used 永久累计)。 cosFreeUsed := int64(0) if q, qerr := s.quotaGrantDao.QuotaGetByUserIDAndType(ctx, user.ID, QuotaTypeCos); qerr == nil { cosFreeUsed = q.Used } // 当日已用(user_daily_usages,AI/社区/衣柜)。 dailyUsed := map[int8]int64{} if usage, uerr := s.quotaGrantDao.UserDailyUsageGetByUserIDAndDate(ctx, user.ID, todayStatDate()); uerr == nil { dailyUsed[QuotaTypeAI] = usage.AIRecommendCount dailyUsed[QuotaTypeOutfitShare] = usage.OutfitShareCount dailyUsed[QuotaTypeClosetShare] = usage.ClosetShareCount } types := []int8{QuotaTypeCos, QuotaTypeAI, QuotaTypeOutfitShare, QuotaTypeClosetShare} quotaItems := make([]dto.QuotaItem, 0, len(types)) for _, quotaType := range types { freeTotal := defaultQuotaValue(quotaType) total := freeTotal + validAmount[quotaType] var used int64 if quotaType == QuotaTypeCos { used = cosFreeUsed + validUsed[quotaType] } else { used = dailyUsed[quotaType] } remain := total - used if remain < 0 { remain = 0 } quotaItems = append(quotaItems, dto.QuotaItem{ QuotaType: quotaType, Total: total, Used: used, Remain: remain, }) } grantItems := make([]dto.UserGrantItem, 0, len(grants)) for _, g := range grants { grantItems = append(grantItems, dto.UserGrantItem{ ID: g.ID, QuotaType: g.QuotaType, Amount: g.Amount, Used: g.Used, Source: g.Source, ExpireTime: g.ExpireTime, RefID: g.RefID, CreatedAt: g.CreatedAt, }) } item := buildUserListItem(user) return &dto.UserDetailResponse{UserListItem: item, Quotas: quotaItems, Grants: grantItems}, nil } // Ban 封禁用户:临时(按天数)/永久,写封禁原因与时间。 func (s *UserService) Ban(ctx context.Context, adminID int64, userUUID string, request *dto.UserBanRequest) *errcode.Error { ctx, span := global.Tracer.Start(ctx, "svc.AdminUserBan") defer span.End() user, err := s.userDao.UserGetByUUID(ctx, userUUID) if err != nil { if isNotFound(err) { return errcode.UserNotFound } return errcode.DatabaseError } now := nowUnix() updates := map[string]any{ "ban_reason": strings.TrimSpace(request.Reason), "ban_start": now, "updated_by": adminID, } switch request.BanType { case UserStatusTempBan: if request.BanDays <= 0 { return errcode.InvalidParams.WithDetails("临时禁用需填写封禁天数") } updates["status"] = UserStatusTempBan updates["ban_expire"] = now + int64(request.BanDays)*86400 case UserStatusPermBan: updates["status"] = UserStatusPermBan updates["ban_expire"] = 0 default: return errcode.InvalidParams.WithDetails("封禁类型不合法") } if err := s.userDao.UserUpdateByID(ctx, user.ID, updates); err != nil { return errcode.DatabaseError } return nil } // Unban 解封用户(恢复正常,清空封禁信息)。 func (s *UserService) Unban(ctx context.Context, adminID int64, userUUID string, request *dto.UserUnbanRequest) *errcode.Error { ctx, span := global.Tracer.Start(ctx, "svc.AdminUserUnban") defer span.End() user, err := s.userDao.UserGetByUUID(ctx, userUUID) if err != nil { if isNotFound(err) { return errcode.UserNotFound } return errcode.DatabaseError } if user.Status != UserStatusTempBan && user.Status != UserStatusPermBan { return nil // 幂等:非封禁状态无需处理。 } updates := map[string]any{ "status": UserStatusNormal, "ban_reason": "", "ban_start": 0, "ban_expire": 0, "updated_by": adminID, } if err := s.userDao.UserUpdateByID(ctx, user.ID, updates); err != nil { return errcode.DatabaseError } return nil } // QuotaAdjust 额度发放:写入有有效期额度凭证(source=加量包)。 // Cos 为一次性库存,AI/社区/衣柜 为有效期内每日加量;expire_days 天内有效。 func (s *UserService) QuotaAdjust(ctx context.Context, adminID int64, userUUID string, request *dto.UserQuotaAdjustRequest) *errcode.Error { ctx, span := global.Tracer.Start(ctx, "svc.AdminUserQuotaAdjust") defer span.End() user, err := s.userDao.UserGetByUUID(ctx, userUUID) if err != nil { if isNotFound(err) { return errcode.UserNotFound } return errcode.DatabaseError } grant := &mysqlmodel.QuotaGrant{ UserID: user.ID, QuotaType: request.QuotaType, Amount: request.Amount, Used: 0, Source: QuotaGrantSourceAddon, ExpireTime: nowUnix() + request.ExpireDays*86400, RefID: "manual_" + genUUID(), } grant.CreatedBy = adminID grant.UpdatedBy = adminID // 凭证与「人工调整」流水同事务写入:小程序额度明细中能看到对应的增加记录, // 避免业务侧到期失效任务补记了扣减、却查不到发放来源的账实不符。 if err := s.quotaGrantDao.QuotaGrantCreateWithFlow(ctx, grant, mysqldao.QuotaFlowParams{ UserID: user.ID, VipExpire: user.VIPExpire, QuotaType: request.QuotaType, Delta: request.Amount, FreeTotal: defaultQuotaValue(request.QuotaType), StatDate: todayStatDate(), OrderNo: grant.RefID, // 与业务侧到期失效流水按 ref_id 关联的口径保持一致 Reason: "管理员发放额度凭证", }); err != nil { return errcode.DatabaseError } return nil } // GrantRevoke 回收额度凭证(软删除,按 id + user_id 归属校验防越权)。 func (s *UserService) GrantRevoke(ctx context.Context, adminID int64, userUUID string, grantID int64) *errcode.Error { ctx, span := global.Tracer.Start(ctx, "svc.AdminUserGrantRevoke") defer span.End() user, err := s.userDao.UserGetByUUID(ctx, userUUID) if err != nil { if isNotFound(err) { return errcode.UserNotFound } return errcode.DatabaseError } grant, err := s.quotaGrantDao.QuotaGrantGetByID(ctx, grantID) if err != nil { if isNotFound(err) { return errcode.UserGrantNotFound } return errcode.DatabaseError } // 归属校验:凭证必须属于该用户,防止越权回收他人凭证。 if grant.UserID != user.ID { return errcode.UserGrantNotFound } // 回收使「未用完的部分」失效:Cos 为 Amount-Used,每日额度 Used 恒为 0(口径与业务侧到期失效一致)。 revoke := grant.Amount - grant.Used if revoke < 0 { revoke = 0 } // 回收流水使用独立 order_no:同一凭证的发放流水同为 change_type=5, // 复用 ref_id 会撞 uk_quota_flow_idem(order_no, quota_type, change_type) 唯一索引。 if err := s.quotaGrantDao.QuotaGrantRevokeWithFlow(ctx, mysqldao.QuotaFlowParams{ UserID: user.ID, VipExpire: user.VIPExpire, QuotaType: grant.QuotaType, Delta: -revoke, FreeTotal: defaultQuotaValue(grant.QuotaType), StatDate: todayStatDate(), OrderNo: "revoke_" + genUUID(), RelatedID: grant.ID, Reason: "管理员回收额度凭证", }); err != nil { return errcode.DatabaseError } return nil } // VIPAdjust 人工调整用户会员(事务:更新会员字段 + 人工调整流水)。 // 叠加语义(与购买发放一致):vip_expire = max(当前到期, now) + vip_days*86400;vip_days=0 表示取消会员。 func (s *UserService) VIPAdjust(ctx context.Context, adminID int64, userUUID string, request *dto.UserVIPAdjustRequest) *errcode.Error { ctx, span := global.Tracer.Start(ctx, "svc.AdminUserVIPAdjust") defer span.End() user, err := s.userDao.UserGetByUUID(ctx, userUUID) if err != nil { if isNotFound(err) { return errcode.UserNotFound } return errcode.DatabaseError } now := nowUnix() // 变更前快照(流水用)。 oldType := user.VIPType oldExpire := user.VIPExpire // 会员已过期时接口视角归一为非会员。 if oldExpire > 0 && oldExpire < now { oldType = VIPTypeNone } var newType int8 var newExpire int64 if request.VIPDays <= 0 { // 取消会员。 if request.VIPType != VIPTypeNone { return errcode.InvalidParams.WithDetails("取消会员时 vip_type 应为 1") } newType = VIPTypeNone newExpire = 0 } else { if request.VIPType == VIPTypeNone { return errcode.InvalidParams.WithDetails("赠送会员天数时 vip_type 不能为 1") } newType = request.VIPType base := user.VIPExpire if base < now { base = now } newExpire = base + request.VIPDays*86400 } flow := &mysqlmodel.VIPFlowLog{ UserID: user.ID, ChangeType: VIPChangeTypeManual, OldVIPType: oldType, NewVIPType: newType, OldVIPExpire: oldExpire, Source: VIPSourceManual, Reason: strings.TrimSpace(request.Reason), } // 会员已过期/从未开通时重置开始时间;续期保留原开始时间。 if newExpire > 0 && (oldExpire <= 0 || oldExpire <= now) { user.VIPStart = now } user.VIPType = newType user.VIPExpire = newExpire if err := s.userDao.VIPAdjustWithFlow(ctx, user, flow); err != nil { return errcode.DatabaseError } return nil } // defaultQuotaValue 返回某类额度的免费底值: // 优先取 global.QuotaSetting 配置(与业务服务 UserDefaultQuota 免费值对齐), // 未配置(指针为 nil)或负数时回落内置常量 DefaultQuota*。 func defaultQuotaValue(quotaType int8) int64 { cfg := global.QuotaSetting switch quotaType { case QuotaTypeCos: if cfg != nil && cfg.Cos != nil && *cfg.Cos >= 0 { return *cfg.Cos } return DefaultQuotaCos case QuotaTypeAI: if cfg != nil && cfg.AI != nil && *cfg.AI >= 0 { return *cfg.AI } return DefaultQuotaAI case QuotaTypeOutfitShare: if cfg != nil && cfg.OutfitShare != nil && *cfg.OutfitShare >= 0 { return *cfg.OutfitShare } return DefaultQuotaOutfitShare case QuotaTypeClosetShare: if cfg != nil && cfg.ClosetShare != nil && *cfg.ClosetShare >= 0 { return *cfg.ClosetShare } return DefaultQuotaClosetShare default: return 0 } } // grantExpired 判断凭证是否失效:expire_time=0 表示跟随 VIP,其余按到期时间判断。 func grantExpired(expireTime, vipExpire, now int64) bool { if expireTime == 0 { return vipExpire <= now } return expireTime <= now } // buildUserListItem 用户模型 → 列表项(会员已过期归一为非会员)。 func buildUserListItem(u *mysqlmodel.User) dto.UserListItem { vipType := u.VIPType vipExpire := u.VIPExpire if vipType != VIPTypeNone && vipExpire > 0 && vipExpire < nowUnix() { vipType = VIPTypeNone vipExpire = 0 } return dto.UserListItem{ ID: u.ID, UserUUID: u.UserUUID, Username: u.Username, Avatar: toImageURL(u.Avatar), Sex: u.Sex, Phone: u.Phone, Status: u.Status, BanReason: u.BanReason, BanStart: u.BanStart, BanExpire: u.BanExpire, VIPType: vipType, VIPExpire: vipExpire, LastLoginTime: u.LastLoginTime, CreatedAt: u.CreatedAt, } } // toImageURL 头像地址兼容转换:以 http(s) 开头的历史完整 URL 直接透传, // 其余(COS 相对 key)转为带 CDN 鉴权签名的可访问 URL。 func toImageURL(key string) string { if key == "" || strings.HasPrefix(key, "http://") || strings.HasPrefix(key, "https://") { return key } return cos.GetCosUrl(key) }