first commit
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user