Files
zt-closet-manage-backend/internal/service/user.go
T
zhengchiliu 0863234bc5 feat(backend): 对齐业务服务额度与内容治理口径
仪表盘待办与穿搭概况改为公开/私密/已封禁口径,移除恒为零的待审核统计;补齐额度凭证来源 source=4 与流水 change_type=7 枚举及订单商品类型、额度有效期快照字段;管理员发放与回收额度凭证在同一事务内写入人工调整流水,回收流水使用独立 order_no 避免撞唯一索引,并增加凭证归属校验;对齐 Quota 配置免费额度值,同步更新 README 与配置说明。
2026-09-16 02:58:56 +08:00

440 lines
13 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.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)
}