Files
zt-closet-manage-backend/internal/service/user.go
T
2026-09-11 01:47:20 +08:00

407 lines
12 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 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.QuotaGrantCreate(ctx, grant); 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
}
if _, err := s.quotaGrantDao.QuotaGrantGetByID(ctx, grantID); err != nil {
if isNotFound(err) {
return errcode.UserGrantNotFound
}
return errcode.DatabaseError
}
if err := s.quotaGrantDao.QuotaGrantSoftDelete(ctx, grantID, user.ID); 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)
}