first commit

This commit is contained in:
2026-09-11 01:47:20 +08:00
commit 303207d3cc
74 changed files with 8348 additions and 0 deletions
+406
View File
@@ -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)
}