仪表盘待办与穿搭概况改为公开/私密/已封禁口径,移除恒为零的待审核统计;补齐额度凭证来源 source=4 与流水 change_type=7 枚举及订单商品类型、额度有效期快照字段;管理员发放与回收额度凭证在同一事务内写入人工调整流水,回收流水使用独立 order_no 避免撞唯一索引,并增加凭证归属校验;对齐 Quota 配置免费额度值,同步更新 README 与配置说明。
440 lines
13 KiB
Go
440 lines
13 KiB
Go
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)
|
||
}
|