first commit
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"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/internal/redisdao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/lzcwyjwt"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// AdminService 管理员账号业务服务。
|
||||
type AdminService struct {
|
||||
adminDao *mysqldao.AdminDao
|
||||
}
|
||||
|
||||
// NewAdminService 创建管理员服务。
|
||||
func NewAdminService() *AdminService {
|
||||
return &AdminService{
|
||||
adminDao: mysqldao.NewAdminDao(),
|
||||
}
|
||||
}
|
||||
|
||||
// Login 管理员登录:账号密码校验 → 签发双 token → 会话入库 → 返回管理员信息。
|
||||
func (s *AdminService) Login(ctx context.Context, request *dto.AdminLoginRequest) (*dto.AdminLoginResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminLogin")
|
||||
defer span.End()
|
||||
|
||||
admin, err := s.adminDao.AdminGetByUsername(ctx, request.Username)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
// 账号不存在与密码错误统一返回同一错误,避免账号枚举攻击。
|
||||
return nil, errcode.AdminLoginFailed
|
||||
}
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
if admin.Status != AdminStatusEnabled {
|
||||
return nil, errcode.AdminDisabled
|
||||
}
|
||||
if cerr := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(request.Password)); cerr != nil {
|
||||
return nil, errcode.AdminLoginFailed
|
||||
}
|
||||
|
||||
accessToken, refreshToken, terr := lzcwyjwt.GenerateTokens(admin.ID)
|
||||
if terr != nil {
|
||||
return nil, errcode.UnauthorizedTokenGenerate
|
||||
}
|
||||
if aerr := redisdao.AuthTokenSet(ctx, admin.ID, accessToken, refreshToken); aerr != nil {
|
||||
// Redis 故障降级放行(与鉴权中间件 fail-open 策略一致)。
|
||||
slog.WarnContext(ctx, "set admin auth token failed, allow by fallback", "error", aerr)
|
||||
}
|
||||
|
||||
// best-effort 更新最近登录时间。
|
||||
_ = s.adminDao.AdminUpdate(ctx, admin.ID, map[string]any{"last_login_time": nowUnix()})
|
||||
|
||||
return &dto.AdminLoginResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpireTime: nowUnix() + int64(global.JWTSetting.AccessTokenExpire),
|
||||
AdminInfo: buildAdminInfo(admin),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RefreshToken 使用 refresh token 刷新访问令牌(需携带旧 access token 精确吊销旧会话)。
|
||||
func (s *AdminService) RefreshToken(ctx context.Context, request *dto.AdminRefreshTokenRequest) (*dto.AdminRefreshTokenResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminRefreshToken")
|
||||
defer span.End()
|
||||
|
||||
claims, err := lzcwyjwt.ParseRefreshToken(request.RefreshToken)
|
||||
if err != nil {
|
||||
if errors.Is(err, lzcwyjwt.ErrTokenExpired) {
|
||||
return nil, errcode.UnauthorizedTokenTimeout
|
||||
}
|
||||
return nil, errcode.UnauthorizedTokenError
|
||||
}
|
||||
|
||||
// 校验 refresh token 仍在有效会话中。
|
||||
uid, aerr := redisdao.AuthAdminIDByRefresh(ctx, request.RefreshToken)
|
||||
if aerr != nil {
|
||||
if isCacheMiss(aerr) {
|
||||
return nil, errcode.UnauthorizedTokenError
|
||||
}
|
||||
return nil, errcode.CacheError
|
||||
}
|
||||
if uid != claims.AdminID {
|
||||
return nil, errcode.UnauthorizedTokenError
|
||||
}
|
||||
|
||||
// 管理员状态回查(禁用后 refresh 也立即失效)。
|
||||
admin, dbErr := s.adminDao.AdminGetByID(ctx, claims.AdminID)
|
||||
if dbErr != nil {
|
||||
return nil, errcode.AdminNotFound
|
||||
}
|
||||
if admin.Status != AdminStatusEnabled {
|
||||
return nil, errcode.AdminDisabled
|
||||
}
|
||||
|
||||
accessToken, refreshToken, terr := lzcwyjwt.GenerateTokens(claims.AdminID)
|
||||
if terr != nil {
|
||||
return nil, errcode.UnauthorizedTokenGenerate
|
||||
}
|
||||
|
||||
// 失效旧会话:吊销旧 access + 旧 refresh,防止旧 token 泄露后被继续使用。
|
||||
if request.AccessToken == "" {
|
||||
return nil, errcode.InvalidParams.WithDetails("缺少旧 access token")
|
||||
}
|
||||
oldUID, oerr := redisdao.AuthAdminIDByAccess(ctx, request.AccessToken)
|
||||
if oerr != nil && !isCacheMiss(oerr) {
|
||||
return nil, errcode.CacheError
|
||||
}
|
||||
if oerr == nil && oldUID != claims.AdminID {
|
||||
return nil, errcode.UnauthorizedTokenError
|
||||
}
|
||||
_ = redisdao.AuthTokenDel(ctx, claims.AdminID, request.AccessToken, request.RefreshToken)
|
||||
if serr := redisdao.AuthTokenSet(ctx, claims.AdminID, accessToken, refreshToken); serr != nil {
|
||||
return nil, errcode.CacheError
|
||||
}
|
||||
|
||||
return &dto.AdminRefreshTokenResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpireTime: nowUnix() + int64(global.JWTSetting.AccessTokenExpire),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Logout 登出:吊销当前会话(access + refresh)。
|
||||
func (s *AdminService) Logout(ctx context.Context, accessToken, refreshToken string) *errcode.Error {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminLogout")
|
||||
defer span.End()
|
||||
|
||||
adminID, e := requireAdminID(ctx)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if err := redisdao.AuthTokenDel(ctx, adminID, accessToken, refreshToken); err != nil {
|
||||
slog.WarnContext(ctx, "admin logout delete token failed", "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChangePassword 修改自己密码(校验原密码,新密码 bcrypt 入库)。
|
||||
func (s *AdminService) ChangePassword(ctx context.Context, adminID int64, request *dto.AdminChangePasswordRequest) *errcode.Error {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminChangePassword")
|
||||
defer span.End()
|
||||
|
||||
admin, err := s.adminDao.AdminGetByID(ctx, adminID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return errcode.AdminNotFound
|
||||
}
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
if cerr := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(request.OldPassword)); cerr != nil {
|
||||
return errcode.AdminOldPasswordWrong
|
||||
}
|
||||
|
||||
hash, herr := bcrypt.GenerateFromPassword([]byte(request.NewPassword), bcrypt.DefaultCost)
|
||||
if herr != nil {
|
||||
return errcode.AdminPasswordInvalid
|
||||
}
|
||||
if err := s.adminDao.AdminUpdate(ctx, adminID, map[string]any{"password_hash": string(hash)}); err != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
|
||||
// 改密后失效全部会话,强制重新登录。
|
||||
if derr := redisdao.AuthAdminTokensDelAll(ctx, adminID); derr != nil {
|
||||
slog.WarnContext(ctx, "invalidate admin tokens failed after password change", "error", derr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List 分页查询管理员列表(仅超级管理员)。
|
||||
func (s *AdminService) List(ctx context.Context, request *dto.AdminListRequest) (*dto.AdminListResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminList")
|
||||
defer span.End()
|
||||
|
||||
page, pageSize := normalizePage(request.Page, request.PageSize)
|
||||
|
||||
list, total, err := s.adminDao.AdminList(ctx, request.Status, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
items := make([]dto.AdminInfo, 0, len(list))
|
||||
for i := range list {
|
||||
items = append(items, buildAdminInfo(&list[i]))
|
||||
}
|
||||
return &dto.AdminListResponse{
|
||||
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
|
||||
List: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Create 创建管理员账号(仅超级管理员)。
|
||||
func (s *AdminService) Create(ctx context.Context, request *dto.AdminCreateRequest) (*dto.AdminInfo, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminCreate")
|
||||
defer span.End()
|
||||
|
||||
username := strings.TrimSpace(request.Username)
|
||||
if username == "" {
|
||||
return nil, errcode.InvalidParams.WithDetails("账号不能为空")
|
||||
}
|
||||
|
||||
// 用户名唯一性校验(含软删,防历史账号占用)。
|
||||
if _, err := s.adminDao.AdminGetByUsername(ctx, username); err == nil {
|
||||
return nil, errcode.AdminUsernameExists
|
||||
} else if !isNotFound(err) {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
hash, herr := bcrypt.GenerateFromPassword([]byte(request.Password), bcrypt.DefaultCost)
|
||||
if herr != nil {
|
||||
return nil, errcode.AdminPasswordInvalid
|
||||
}
|
||||
|
||||
admin := &mysqlmodel.AdminUser{
|
||||
Username: username,
|
||||
PasswordHash: string(hash),
|
||||
Nickname: strings.TrimSpace(request.Nickname),
|
||||
Role: request.Role,
|
||||
Status: AdminStatusEnabled,
|
||||
}
|
||||
if admin.Nickname == "" {
|
||||
admin.Nickname = username
|
||||
}
|
||||
if err := s.adminDao.AdminCreate(ctx, admin); err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
info := buildAdminInfo(admin)
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// Update 更新管理员账号(仅超级管理员;不能操作自己以外的超级管理员)。
|
||||
func (s *AdminService) Update(ctx context.Context, adminID, targetID int64, request *dto.AdminUpdateRequest) *errcode.Error {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminUpdate")
|
||||
defer span.End()
|
||||
|
||||
target, err := s.adminDao.AdminGetByID(ctx, targetID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return errcode.AdminNotFound
|
||||
}
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
|
||||
// 保护规则:超级管理员之间不能互操作;目标为超级管理员且非本人时拒绝。
|
||||
if target.Role == AdminRoleSuper && targetID != adminID {
|
||||
return errcode.AdminCannotModifySuper
|
||||
}
|
||||
// 不能禁用自己。
|
||||
if adminID == targetID && request.Status == AdminStatusDisabled {
|
||||
return errcode.AdminCannotDisableSelf
|
||||
}
|
||||
|
||||
updates := map[string]any{}
|
||||
if strings.TrimSpace(request.Nickname) != "" {
|
||||
updates["nickname"] = strings.TrimSpace(request.Nickname)
|
||||
}
|
||||
if request.Role != 0 && request.Role != target.Role {
|
||||
updates["role"] = request.Role
|
||||
}
|
||||
if request.Status != 0 && request.Status != target.Status {
|
||||
updates["status"] = request.Status
|
||||
}
|
||||
if request.Password != "" {
|
||||
hash, herr := bcrypt.GenerateFromPassword([]byte(request.Password), bcrypt.DefaultCost)
|
||||
if herr != nil {
|
||||
return errcode.AdminPasswordInvalid
|
||||
}
|
||||
updates["password_hash"] = string(hash)
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
if err := s.adminDao.AdminUpdate(ctx, targetID, updates); err != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
// 禁用/改密后失效目标账号全部会话,立即生效。
|
||||
if request.Status == AdminStatusDisabled || request.Password != "" {
|
||||
if derr := redisdao.AuthAdminTokensDelAll(ctx, targetID); derr != nil {
|
||||
slog.WarnContext(ctx, "invalidate admin tokens failed", "admin_id", targetID, "error", derr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除管理员账号(仅超级管理员;不能删除自己与其他超级管理员)。
|
||||
func (s *AdminService) Delete(ctx context.Context, adminID, targetID int64) *errcode.Error {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminDelete")
|
||||
defer span.End()
|
||||
|
||||
if adminID == targetID {
|
||||
return errcode.AdminCannotDeleteSelf
|
||||
}
|
||||
|
||||
target, err := s.adminDao.AdminGetByID(ctx, targetID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return errcode.AdminNotFound
|
||||
}
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
if target.Role == AdminRoleSuper {
|
||||
return errcode.AdminCannotModifySuper
|
||||
}
|
||||
|
||||
if err := s.adminDao.AdminSoftDelete(ctx, targetID); err != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
if derr := redisdao.AuthAdminTokensDelAll(ctx, targetID); derr != nil {
|
||||
slog.WarnContext(ctx, "invalidate admin tokens failed", "admin_id", targetID, "error", derr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureInitialSuperAdmin 幂等创建初始超级管理员(配置 Admin.InitialUsername/InitialPassword)。
|
||||
// 已存在则跳过;账号被软删则视为不存在重新创建。
|
||||
func EnsureInitialSuperAdmin(ctx context.Context) {
|
||||
if global.AdminSetting == nil ||
|
||||
strings.TrimSpace(global.AdminSetting.InitialUsername) == "" ||
|
||||
global.AdminSetting.InitialPassword == "" {
|
||||
slog.WarnContext(ctx, "admin initial account not configured, skip seed")
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(global.AdminSetting.InitialUsername)
|
||||
svc := NewAdminService()
|
||||
|
||||
// 已存在(含被禁用)则跳过,避免覆盖运营修改后的账号。
|
||||
if admin, err := svc.adminDao.AdminGetByUsername(ctx, username); err == nil {
|
||||
if admin.DeletedAt == 0 {
|
||||
slog.InfoContext(ctx, "initial admin already exists, skip seed", "username", username)
|
||||
return
|
||||
}
|
||||
} else if !isNotFound(err) {
|
||||
slog.ErrorContext(ctx, "check initial admin failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
hash, herr := bcrypt.GenerateFromPassword([]byte(global.AdminSetting.InitialPassword), bcrypt.DefaultCost)
|
||||
if herr != nil {
|
||||
slog.ErrorContext(ctx, "hash initial admin password failed", "error", herr)
|
||||
return
|
||||
}
|
||||
|
||||
admin := &mysqlmodel.AdminUser{
|
||||
Username: username,
|
||||
PasswordHash: string(hash),
|
||||
Nickname: username,
|
||||
Role: AdminRoleSuper,
|
||||
Status: AdminStatusEnabled,
|
||||
}
|
||||
if err := svc.adminDao.AdminCreate(ctx, admin); err != nil {
|
||||
slog.ErrorContext(ctx, "create initial admin failed", "error", err)
|
||||
return
|
||||
}
|
||||
slog.InfoContext(ctx, "initial admin created", "username", username)
|
||||
}
|
||||
|
||||
// buildAdminInfo 将管理员模型映射为响应信息(密码哈希不出网)。
|
||||
func buildAdminInfo(a *mysqlmodel.AdminUser) dto.AdminInfo {
|
||||
return dto.AdminInfo{
|
||||
ID: a.ID,
|
||||
Username: a.Username,
|
||||
Nickname: a.Nickname,
|
||||
Role: a.Role,
|
||||
Status: a.Status,
|
||||
LastLoginTime: a.LastLoginTime,
|
||||
CreatedAt: a.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package service
|
||||
|
||||
// 本文件集中定义管理后台各业务域的状态/类型枚举常量,消除魔法数字。
|
||||
// 取值与业务服务(zt-closet-backend)internal/service/constants.go 及各表字段注释保持一致。
|
||||
|
||||
// ===== 管理员 AdminUser =====
|
||||
const (
|
||||
AdminRoleSuper int8 = 1 // 超级管理员
|
||||
AdminRoleNormal int8 = 2 // 普通管理员
|
||||
|
||||
AdminStatusEnabled int8 = 1 // 启用
|
||||
AdminStatusDisabled int8 = 2 // 禁用
|
||||
)
|
||||
|
||||
// ===== 用户 User =====
|
||||
const (
|
||||
UserStatusNormal int8 = 1 // 正常
|
||||
UserStatusTempBan int8 = 2 // 临时禁用
|
||||
UserStatusPermBan int8 = 3 // 永久禁用
|
||||
UserStatusDeactivated int8 = 4 // 已注销
|
||||
|
||||
VIPTypeNone int8 = 1 // 非会员
|
||||
VIPTypeMonthly int8 = 2 // 月度会员
|
||||
VIPTypeYearly int8 = 3 // 年度会员
|
||||
VIPTypeTrial int8 = 4 // 体验会员
|
||||
)
|
||||
|
||||
// ===== 配额 Quota =====
|
||||
const (
|
||||
QuotaTypeCos int8 = 1 // COS 上传
|
||||
QuotaTypeAI int8 = 2 // AI 推荐
|
||||
QuotaTypeOutfitShare int8 = 3 // 社区分享
|
||||
QuotaTypeClosetShare int8 = 4 // 衣柜分享
|
||||
|
||||
// 额度凭证来源(quota_grants.source)
|
||||
QuotaGrantSourceMember int8 = 1 // 会员包(跟随 VIP 有效期)
|
||||
QuotaGrantSourceAddon int8 = 2 // 加量包(独立有效期)
|
||||
QuotaGrantSourceAdReward int8 = 3 // 广告激励(当天有效)
|
||||
|
||||
QuotaChangeTargetAdjust int8 = 1 // 配额调整
|
||||
QuotaChangeTargetUse int8 = 2 // 使用
|
||||
|
||||
QuotaChangeTypeManual int8 = 5 // 人工调整
|
||||
)
|
||||
|
||||
// 免费额度内置兜底值(与业务服务 DefaultQuota* 对齐;仅当 Quota 配置段未配置时使用)。
|
||||
const (
|
||||
DefaultQuotaCos int64 = 20 // 免费上传额度(永久)
|
||||
DefaultQuotaAI int64 = 3 // 免费 AI 推荐额度(每日)
|
||||
DefaultQuotaOutfitShare int64 = 10 // 免费社区分享额度(每日)
|
||||
DefaultQuotaClosetShare int64 = 5 // 免费衣柜分享额度(每日)
|
||||
)
|
||||
|
||||
// ===== VIP 流水 =====
|
||||
const (
|
||||
VIPChangeTypeManual int8 = 5 // 人工调整
|
||||
|
||||
VIPSourceManual int8 = 4 // 人工调整
|
||||
)
|
||||
|
||||
// ===== 商品 Goods =====
|
||||
const (
|
||||
GoodsTypeMember int8 = 1 // 会员包
|
||||
GoodsTypeUpload int8 = 2 // 上传包
|
||||
GoodsTypeAIRecommend int8 = 3 // AI 推荐包
|
||||
GoodsTypeClosetShare int8 = 4 // 衣柜分享包
|
||||
GoodsTypeOutfitShare int8 = 5 // 搭配分享包
|
||||
|
||||
GoodsStatusOnShelf int8 = 1 // 上架
|
||||
GoodsStatusOffShelf int8 = 2 // 下架
|
||||
|
||||
// 虚拟支付道具发布状态
|
||||
GoodsProductUnpublished int8 = 0 // 未发布
|
||||
GoodsProductPublishing int8 = 1 // 发布中(上传/发布任务处理中)
|
||||
GoodsProductPublished int8 = 2 // 已发布
|
||||
GoodsProductPublishFail int8 = 3 // 发布失败
|
||||
)
|
||||
|
||||
// ===== 订单 Order =====
|
||||
const (
|
||||
OrderStatusPending int8 = 1 // 待支付
|
||||
OrderStatusPaid int8 = 2 // 已支付
|
||||
OrderStatusCancelled int8 = 3 // 已取消
|
||||
OrderStatusExpired int8 = 4 // 已过期
|
||||
OrderStatusRefunded int8 = 5 // 已退款
|
||||
|
||||
PayChannelWechat int8 = 1 // 微信支付(普通 JSAPI)
|
||||
PayChannelVirtual int8 = 2 // 微信虚拟支付(XPay)
|
||||
|
||||
// 虚拟支付平台(由前端按 systemInfo.platform 上报)
|
||||
XPayPlatformAndroid = "android"
|
||||
XPayPlatformIOS = "ios"
|
||||
XPayPlatformHarmony = "harmony"
|
||||
XPayPlatformWindows = "windows"
|
||||
)
|
||||
|
||||
// ===== 退款 Refund =====
|
||||
const (
|
||||
RefundStatusPending int8 = 0 // 待处理
|
||||
RefundStatusProcessing int8 = 1 // 退款中
|
||||
RefundStatusSuccess int8 = 2 // 退款成功
|
||||
RefundStatusFailed int8 = 3 // 退款失败
|
||||
)
|
||||
|
||||
// ===== 穿搭 Outfit =====
|
||||
const (
|
||||
OutfitSourceUser int8 = 1 // 用户创建
|
||||
OutfitSourceAI int8 = 2 // AI 推荐
|
||||
|
||||
OutfitPrivate int8 = 1 // 不公开
|
||||
OutfitPublic int8 = 2 // 公开
|
||||
|
||||
OutfitAuditPending int8 = 1 // 待审核
|
||||
OutfitAuditPass int8 = 2 // 审核通过
|
||||
OutfitAuditReject int8 = 3 // 审核不通过
|
||||
|
||||
OutfitStatusNormal int8 = 1 // 正常
|
||||
OutfitStatusBanned int8 = 2 // 封禁
|
||||
OutfitStatusUnsaved int8 = 3 // 待用户保存
|
||||
)
|
||||
|
||||
// ===== 衣柜分享 ClosetShare =====
|
||||
const (
|
||||
ShareTypePartial int8 = 1 // 部分衣物分享
|
||||
ShareTypeSnapshot int8 = 2 // 全量快照分享
|
||||
|
||||
ShareStatusNormal int8 = 1 // 正常
|
||||
ShareStatusCancelled int8 = 2 // 用户取消
|
||||
ShareStatusExpired int8 = 3 // 已过期
|
||||
ShareStatusBanned int8 = 4 // 管理员封禁
|
||||
)
|
||||
|
||||
// ===== 衣物 Clothes 图片处理状态 =====
|
||||
const (
|
||||
ImageProcessMatting int8 = 1 // 抠图中
|
||||
ImageProcessClassifying int8 = 2 // 分类中
|
||||
ImageProcessQueuing int8 = 3 // 排队中
|
||||
ImageProcessSuccess int8 = 4 // 成功
|
||||
ImageProcessFailed int8 = 5 // 失败
|
||||
)
|
||||
|
||||
// ===== 通知 Notification =====
|
||||
const (
|
||||
NotificationTypeSystem int8 = 3 // 系统通知
|
||||
|
||||
NotificationUnread int8 = 1 // 未读
|
||||
NotificationRead int8 = 2 // 已读
|
||||
|
||||
NotificationSenderSystem int64 = 0 // 系统发送者 ID
|
||||
)
|
||||
|
||||
// ===== 审核日志 AuditLog =====
|
||||
const (
|
||||
AuditResultPass int8 = 1 // 通过
|
||||
AuditResultReject int8 = 2 // 驳回
|
||||
)
|
||||
@@ -0,0 +1,372 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// ContentService 内容审核服务(穿搭/衣物/衣柜分享)。
|
||||
type ContentService struct {
|
||||
outfitDao *mysqldao.OutfitDao
|
||||
clothesDao *mysqldao.ClothesDao
|
||||
shareDao *mysqldao.ClosetShareDao
|
||||
}
|
||||
|
||||
// NewContentService 创建内容审核服务。
|
||||
func NewContentService() *ContentService {
|
||||
return &ContentService{
|
||||
outfitDao: mysqldao.NewOutfitDao(),
|
||||
clothesDao: mysqldao.NewClothesDao(),
|
||||
shareDao: mysqldao.NewClosetShareDao(),
|
||||
}
|
||||
}
|
||||
|
||||
// OutfitList 分页查询穿搭(管理端全量视角)。
|
||||
func (s *ContentService) OutfitList(ctx context.Context, request *dto.OutfitListRequest) (*dto.OutfitListResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminOutfitList")
|
||||
defer span.End()
|
||||
|
||||
page, pageSize := normalizePage(request.Page, request.PageSize)
|
||||
|
||||
list, total, err := s.outfitDao.OutfitList(ctx, mysqldao.OutfitListParams{
|
||||
UserID: request.UserID,
|
||||
IsPublic: request.IsPublic,
|
||||
AuditStatus: request.AuditStatus,
|
||||
Status: request.Status,
|
||||
Keyword: strings.TrimSpace(request.Keyword),
|
||||
Page: page,
|
||||
Size: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
items := make([]dto.OutfitListItem, 0, len(list))
|
||||
for i := range list {
|
||||
items = append(items, buildOutfitListItem(&list[i]))
|
||||
}
|
||||
return &dto.OutfitListResponse{
|
||||
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
|
||||
List: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OutfitDetail 穿搭详情(含衣物明细)。
|
||||
func (s *ContentService) OutfitDetail(ctx context.Context, outfitUUID string) (*dto.OutfitDetailResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminOutfitDetail")
|
||||
defer span.End()
|
||||
|
||||
outfit, err := s.outfitDao.OutfitGetByUUID(ctx, outfitUUID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, errcode.OutfitNotFound
|
||||
}
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
items, err := s.outfitDao.OutfitItemListByOutfitID(ctx, outfit.ID)
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
clothesIDs := make([]int64, 0, len(items))
|
||||
for _, it := range items {
|
||||
clothesIDs = append(clothesIDs, it.ClothesID)
|
||||
}
|
||||
clothesList, err := s.clothesDao.ClothesListByIDs(ctx, clothesIDs)
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
// 衣物可能被软删,按 ID 索引过滤。
|
||||
clothesMap := make(map[int64]mysqlmodel.Clothes, len(clothesList))
|
||||
for _, cl := range clothesList {
|
||||
clothesMap[cl.ID] = cl
|
||||
}
|
||||
|
||||
detailItems := make([]dto.OutfitClothesItem, 0, len(items))
|
||||
for _, it := range items {
|
||||
cl, ok := clothesMap[it.ClothesID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
detailItems = append(detailItems, dto.OutfitClothesItem{
|
||||
ClothesUUID: cl.ClothesUUID,
|
||||
Name: cl.Name,
|
||||
CategoryMain: cl.CategoryMain,
|
||||
CategorySub: cl.CategorySub,
|
||||
ProcessedImage: cos.GetCosUrl(cl.ProcessedImage),
|
||||
ImageProcessStatus: cl.ImageProcessStatus,
|
||||
})
|
||||
}
|
||||
|
||||
return &dto.OutfitDetailResponse{
|
||||
OutfitListItem: buildOutfitListItem(outfit),
|
||||
Items: detailItems,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OutfitAudit 穿搭审核:通过(audit 1→2)/驳回(audit 1→3),写审核日志。
|
||||
func (s *ContentService) OutfitAudit(ctx context.Context, adminID int64, outfitUUID string, request *dto.OutfitAuditRequest) *errcode.Error {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminOutfitAudit")
|
||||
defer span.End()
|
||||
|
||||
outfit, err := s.outfitDao.OutfitGetByUUID(ctx, outfitUUID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return errcode.OutfitNotFound
|
||||
}
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
if outfit.AuditStatus != OutfitAuditPending {
|
||||
return errcode.OutfitAuditInvalid.WithDetails("仅待审核状态的穿搭可审核")
|
||||
}
|
||||
|
||||
auditResult := AuditResultPass
|
||||
reason := ""
|
||||
if request.Result == AuditResultReject {
|
||||
auditResult = AuditResultReject
|
||||
reason = strings.TrimSpace(request.Reason)
|
||||
if reason == "" {
|
||||
reason = "未通过审核"
|
||||
}
|
||||
}
|
||||
|
||||
log := &mysqlmodel.OutfitAuditLog{
|
||||
AuditTime: nowUnix(),
|
||||
}
|
||||
log.CreatedBy = adminID
|
||||
if err := s.outfitDao.OutfitAuditWithLog(ctx, outfit.ID, auditResult, reason, log); err != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OutfitBan 封禁穿搭(status → 2 封禁)。
|
||||
func (s *ContentService) OutfitBan(ctx context.Context, adminID int64, outfitUUID string, request *dto.OutfitBanRequest) *errcode.Error {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminOutfitBan")
|
||||
defer span.End()
|
||||
|
||||
outfit, err := s.outfitDao.OutfitGetByUUID(ctx, outfitUUID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return errcode.OutfitNotFound
|
||||
}
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
if outfit.Status == OutfitStatusBanned {
|
||||
return nil // 幂等。
|
||||
}
|
||||
if err := s.outfitDao.OutfitUpdateByID(ctx, outfit.ID, map[string]any{
|
||||
"status": OutfitStatusBanned,
|
||||
"updated_by": adminID,
|
||||
}); err != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OutfitUnban 解封穿搭(status → 1 正常)。
|
||||
func (s *ContentService) OutfitUnban(ctx context.Context, adminID int64, outfitUUID string) *errcode.Error {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminOutfitUnban")
|
||||
defer span.End()
|
||||
|
||||
outfit, err := s.outfitDao.OutfitGetByUUID(ctx, outfitUUID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return errcode.OutfitNotFound
|
||||
}
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
if outfit.Status != OutfitStatusBanned {
|
||||
return nil // 幂等。
|
||||
}
|
||||
if err := s.outfitDao.OutfitUpdateByID(ctx, outfit.ID, map[string]any{
|
||||
"status": OutfitStatusNormal,
|
||||
"updated_by": adminID,
|
||||
}); err != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClothesList 分页查询衣物。
|
||||
func (s *ContentService) ClothesList(ctx context.Context, request *dto.ClothesListRequest) (*dto.ClothesListResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminClothesList")
|
||||
defer span.End()
|
||||
|
||||
page, pageSize := normalizePage(request.Page, request.PageSize)
|
||||
|
||||
list, total, err := s.clothesDao.ClothesList(ctx, mysqldao.ClothesListParams{
|
||||
UserID: request.UserID,
|
||||
ImageProcessStatus: request.ImageProcessStatus,
|
||||
CategoryMain: strings.TrimSpace(request.CategoryMain),
|
||||
Keyword: strings.TrimSpace(request.Keyword),
|
||||
Page: page,
|
||||
Size: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
items := make([]dto.ClothesItem, 0, len(list))
|
||||
for i := range list {
|
||||
items = append(items, buildClothesItem(&list[i]))
|
||||
}
|
||||
return &dto.ClothesListResponse{
|
||||
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
|
||||
List: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ClothesDetail 衣物详情。
|
||||
func (s *ContentService) ClothesDetail(ctx context.Context, clothesUUID string) (*dto.ClothesItem, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminClothesDetail")
|
||||
defer span.End()
|
||||
|
||||
clothes, err := s.clothesDao.ClothesGetByUUID(ctx, clothesUUID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, errcode.ClothesNotFound
|
||||
}
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
item := buildClothesItem(clothes)
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
// ShareList 分页查询衣柜分享。
|
||||
func (s *ContentService) ShareList(ctx context.Context, request *dto.ClosetShareListRequest) (*dto.ClosetShareListResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminShareList")
|
||||
defer span.End()
|
||||
|
||||
page, pageSize := normalizePage(request.Page, request.PageSize)
|
||||
|
||||
list, total, err := s.shareDao.ClosetShareList(ctx, mysqldao.ClosetShareListParams{
|
||||
UserID: request.UserID,
|
||||
Status: request.Status,
|
||||
Page: page,
|
||||
Size: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
items := make([]dto.ClosetShareItem, 0, len(list))
|
||||
for i := range list {
|
||||
items = append(items, buildShareItem(&list[i]))
|
||||
}
|
||||
return &dto.ClosetShareListResponse{
|
||||
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
|
||||
List: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ShareBan 封禁分享(status → 4 封禁)。
|
||||
func (s *ContentService) ShareBan(ctx context.Context, adminID int64, shareUUID string) *errcode.Error {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminShareBan")
|
||||
defer span.End()
|
||||
|
||||
share, err := s.shareDao.ClosetShareGetByUUID(ctx, shareUUID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return errcode.ShareNotFound
|
||||
}
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
if share.Status == ShareStatusBanned {
|
||||
return nil // 幂等。
|
||||
}
|
||||
if err := s.shareDao.ClosetShareUpdateStatus(ctx, shareUUID, ShareStatusBanned); err != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShareUnban 解封分享(status → 1 正常)。
|
||||
func (s *ContentService) ShareUnban(ctx context.Context, adminID int64, shareUUID string) *errcode.Error {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminShareUnban")
|
||||
defer span.End()
|
||||
|
||||
share, err := s.shareDao.ClosetShareGetByUUID(ctx, shareUUID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return errcode.ShareNotFound
|
||||
}
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
if share.Status != ShareStatusBanned {
|
||||
return nil // 幂等。
|
||||
}
|
||||
if err := s.shareDao.ClosetShareUpdateStatus(ctx, shareUUID, ShareStatusNormal); err != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildOutfitListItem 穿搭模型 → 列表项。
|
||||
func buildOutfitListItem(o *mysqlmodel.Outfit) dto.OutfitListItem {
|
||||
return dto.OutfitListItem{
|
||||
ID: o.ID,
|
||||
OutfitUUID: o.OutfitUUID,
|
||||
UserID: o.UserID,
|
||||
OutfitName: o.OutfitName,
|
||||
OutfitDesc: o.OutfitDesc,
|
||||
Scene: o.Scene,
|
||||
Source: o.Source,
|
||||
IsPublic: o.IsPublic,
|
||||
AuditStatus: o.AuditStatus,
|
||||
Status: o.Status,
|
||||
CreatedAt: o.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// buildClothesItem 衣物模型 → 响应。
|
||||
func buildClothesItem(c *mysqlmodel.Clothes) dto.ClothesItem {
|
||||
return dto.ClothesItem{
|
||||
ID: c.ID,
|
||||
ClothesUUID: c.ClothesUUID,
|
||||
UserID: c.UserID,
|
||||
Name: c.Name,
|
||||
CategoryMain: c.CategoryMain,
|
||||
CategorySub: c.CategorySub,
|
||||
Style: c.Style,
|
||||
ColorMain: c.ColorMain,
|
||||
ColorSub: c.ColorSub,
|
||||
Thickness: c.Thickness,
|
||||
Version: c.Version,
|
||||
Material: c.Material,
|
||||
Pattern: c.Pattern,
|
||||
SleeveType: c.SleeveType,
|
||||
ClothesType: c.ClothesType,
|
||||
NecklineType: c.NecklineType,
|
||||
Formality: c.Formality,
|
||||
Occasions: c.Occasions,
|
||||
VisualImpact: c.VisualImpact,
|
||||
Remark: c.Remark,
|
||||
ProcessedImage: cos.GetCosUrl(c.ProcessedImage),
|
||||
ImageSize: c.ImageSize,
|
||||
ImageProcessStatus: c.ImageProcessStatus,
|
||||
FailReason: c.FailReason,
|
||||
CreatedAt: c.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// buildShareItem 分享模型 → 响应。
|
||||
func buildShareItem(s *mysqlmodel.ClosetShare) dto.ClosetShareItem {
|
||||
return dto.ClosetShareItem{
|
||||
ID: s.ID,
|
||||
ShareUUID: s.ShareUUID,
|
||||
UserID: s.UserID,
|
||||
ShareType: s.ShareType,
|
||||
ExpireTime: s.ExpireTime,
|
||||
Status: s.Status,
|
||||
CreatedAt: s.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"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/pkg/errcode"
|
||||
)
|
||||
|
||||
// DashboardService 仪表盘统计服务。
|
||||
type DashboardService struct {
|
||||
statsDao *mysqldao.StatsDao
|
||||
}
|
||||
|
||||
// NewDashboardService 创建仪表盘服务。
|
||||
func NewDashboardService() *DashboardService {
|
||||
return &DashboardService{statsDao: mysqldao.NewStatsDao()}
|
||||
}
|
||||
|
||||
// Summary 聚合仪表盘核心指标:用户/订单/营收/待办。
|
||||
func (s *DashboardService) Summary(ctx context.Context) (*dto.DashboardResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.DashboardSummary")
|
||||
defer span.End()
|
||||
|
||||
todayStart := todayStartUnix()
|
||||
|
||||
userStats, err := s.statsDao.UserStatsGet(ctx, todayStart)
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
userStatusMap, err := s.statsDao.UserStatsGetByStatus(ctx)
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
orderStats, err := s.statsDao.OrderStatsGet(ctx)
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
revenueStats, err := s.statsDao.RevenueStatsGet(ctx, todayStart)
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
todoStats, err := s.statsDao.TodoStatsGet(ctx)
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
contentStats, err := s.statsDao.ContentStatsGet(ctx)
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
now := nowUnix()
|
||||
vipStats, err := s.statsDao.VIPStatsGet(ctx, now, now+7*86400)
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
goodsStats, err := s.statsDao.GoodsStatsGet(ctx)
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
usageStats, err := s.statsDao.UsageStatsGet(ctx, todayStatDate())
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
socialStats, err := s.statsDao.SocialStatsGet(ctx)
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
return &dto.DashboardResponse{
|
||||
Users: dto.DashboardUsers{
|
||||
Total: userStats.Total,
|
||||
TodayNew: userStats.TodayNew,
|
||||
Normal: userStatusMap[UserStatusNormal],
|
||||
TempBan: userStatusMap[UserStatusTempBan],
|
||||
PermBan: userStatusMap[UserStatusPermBan],
|
||||
Deactivated: userStatusMap[UserStatusDeactivated],
|
||||
},
|
||||
Orders: dto.DashboardOrders{
|
||||
Total: orderStats.Total,
|
||||
Pending: orderStats.Pending,
|
||||
Paid: orderStats.Paid,
|
||||
Cancelled: orderStats.Cancelled,
|
||||
Expired: orderStats.Expired,
|
||||
Refunded: orderStats.Refunded,
|
||||
},
|
||||
Revenue: dto.DashboardRevenue{
|
||||
TotalRevenue: revenueStats.TotalRevenue,
|
||||
TodayRevenue: revenueStats.TodayRevenue,
|
||||
RefundTotal: revenueStats.RefundTotal,
|
||||
},
|
||||
Todo: dto.DashboardTodo{
|
||||
RefundPending: todoStats.RefundPending,
|
||||
OutfitPendingAudit: todoStats.OutfitPendingAudit,
|
||||
GoodsOnShelf: todoStats.GoodsOnShelf,
|
||||
},
|
||||
Content: dto.DashboardContent{
|
||||
Outfit: dto.DashboardOutfitStats{
|
||||
Total: contentStats.OutfitTotal,
|
||||
Pending: contentStats.OutfitPending,
|
||||
Passed: contentStats.OutfitPassed,
|
||||
Rejected: contentStats.OutfitRejected,
|
||||
Banned: contentStats.OutfitBanned,
|
||||
},
|
||||
Clothes: dto.DashboardClothesStats{
|
||||
Total: contentStats.ClothesTotal,
|
||||
Processing: contentStats.ClothesProcessing,
|
||||
Success: contentStats.ClothesSuccess,
|
||||
Failed: contentStats.ClothesFailed,
|
||||
},
|
||||
Share: dto.DashboardShareStats{
|
||||
Total: contentStats.ShareTotal,
|
||||
Normal: contentStats.ShareNormal,
|
||||
Cancelled: contentStats.ShareCancelled,
|
||||
Expired: contentStats.ShareExpired,
|
||||
Banned: contentStats.ShareBanned,
|
||||
},
|
||||
},
|
||||
VIP: dto.DashboardVIP{
|
||||
MemberTotal: vipStats.MemberTotal,
|
||||
Monthly: vipStats.Monthly,
|
||||
Yearly: vipStats.Yearly,
|
||||
Trial: vipStats.Trial,
|
||||
ExpiringSoon: vipStats.ExpiringSoon,
|
||||
},
|
||||
Goods: dto.DashboardGoods{
|
||||
Total: goodsStats.Total,
|
||||
OnShelf: goodsStats.OnShelf,
|
||||
OffShelf: goodsStats.OffShelf,
|
||||
ByType: buildGoodsTypeCounts(goodsStats.ByType),
|
||||
},
|
||||
Usage: dto.DashboardUsage{
|
||||
AIRecommend: usageStats.AIRecommend,
|
||||
ClosetShare: usageStats.ClosetShare,
|
||||
OutfitShare: usageStats.OutfitShare,
|
||||
},
|
||||
Social: dto.DashboardSocial{
|
||||
CosImageCount: socialStats.CosImageCount,
|
||||
LikeCount: socialStats.LikeCount,
|
||||
CollectCount: socialStats.CollectCount,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildGoodsTypeCounts 商品类型数量 DAO → DTO。
|
||||
func buildGoodsTypeCounts(in []mysqldao.GoodsTypeCount) []dto.GoodsTypeCount {
|
||||
out := make([]dto.GoodsTypeCount, 0, len(in))
|
||||
for _, v := range in {
|
||||
out = append(out, dto.GoodsTypeCount{Type: v.Type, Count: v.Count})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Trends 返回近 N 天趋势(新增用户/已支付订单量/营收,按天补零对齐)。
|
||||
func (s *DashboardService) Trends(ctx context.Context, days int) (*dto.TrendResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.DashboardTrends")
|
||||
defer span.End()
|
||||
|
||||
if days <= 0 {
|
||||
days = 30
|
||||
}
|
||||
if days > 90 {
|
||||
days = 90
|
||||
}
|
||||
|
||||
startTs := todayStartUnix() - int64(days-1)*86400
|
||||
stats, err := s.statsDao.TrendStatsGet(ctx, startTs)
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
loc := time.Now().Location()
|
||||
startDay := time.Unix(startTs, 0).In(loc)
|
||||
points := make([]dto.TrendPoint, 0, days)
|
||||
for i := 0; i < days; i++ {
|
||||
date := startDay.AddDate(0, 0, i).Format("2006-01-02")
|
||||
points = append(points, dto.TrendPoint{
|
||||
Date: date,
|
||||
NewUsers: stats.NewUsers[date],
|
||||
Orders: stats.Orders[date],
|
||||
Revenue: stats.Revenue[date],
|
||||
})
|
||||
}
|
||||
|
||||
return &dto.TrendResponse{Points: points}, nil
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
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/errcode"
|
||||
)
|
||||
|
||||
// GoodsService 商品管理服务。
|
||||
type GoodsService struct {
|
||||
goodsDao *mysqldao.GoodsDao
|
||||
}
|
||||
|
||||
// NewGoodsService 创建商品服务。
|
||||
func NewGoodsService() *GoodsService {
|
||||
return &GoodsService{goodsDao: mysqldao.NewGoodsDao()}
|
||||
}
|
||||
|
||||
// List 分页查询商品(管理端含下架商品)。
|
||||
func (s *GoodsService) List(ctx context.Context, request *dto.GoodsListRequest) (*dto.GoodsListResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminGoodsList")
|
||||
defer span.End()
|
||||
|
||||
page, pageSize := normalizePage(request.Page, request.PageSize)
|
||||
|
||||
list, total, err := s.goodsDao.GoodsList(ctx, mysqldao.GoodsListParams{
|
||||
GoodsType: request.GoodsType,
|
||||
Status: request.Status,
|
||||
Keyword: strings.TrimSpace(request.Keyword),
|
||||
Page: page,
|
||||
Size: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
items := make([]dto.GoodsItem, 0, len(list))
|
||||
for i := range list {
|
||||
items = append(items, buildGoodsItem(&list[i]))
|
||||
}
|
||||
return &dto.GoodsListResponse{
|
||||
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
|
||||
List: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Create 创建商品。
|
||||
func (s *GoodsService) Create(ctx context.Context, adminID int64, request *dto.GoodsCreateRequest) (*dto.GoodsItem, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminGoodsCreate")
|
||||
defer span.End()
|
||||
|
||||
code := strings.TrimSpace(request.GoodsCode)
|
||||
if code == "" {
|
||||
return nil, errcode.InvalidParams.WithDetails("商品编码不能为空")
|
||||
}
|
||||
if request.DiscountPrice > request.Price {
|
||||
return nil, errcode.GoodsPriceInvalid.WithDetails("售价不能高于原价")
|
||||
}
|
||||
|
||||
// 编码唯一性校验(含软删,防历史编码占用)。
|
||||
if _, err := s.goodsDao.GoodsGetByCode(ctx, code); err == nil {
|
||||
return nil, errcode.GoodsCodeExists
|
||||
} else if !isNotFound(err) {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
status := request.Status
|
||||
if status == 0 {
|
||||
status = GoodsStatusOffShelf // 新建默认下架,运营确认后再上架。
|
||||
}
|
||||
|
||||
goods := &mysqlmodel.Goods{
|
||||
GoodsCode: code,
|
||||
GoodsName: strings.TrimSpace(request.GoodsName),
|
||||
GoodsType: request.GoodsType,
|
||||
Price: request.Price,
|
||||
DiscountPrice: request.DiscountPrice,
|
||||
MemberDays: request.MemberDays,
|
||||
CosQuota: request.UploadQuota,
|
||||
AIRecommendQuota: request.AIRecommendQuota,
|
||||
ClosetShareQuota: request.ClosetShareQuota,
|
||||
OutfitShareQuota: request.OutfitShareQuota,
|
||||
ValidDays: request.ValidDays,
|
||||
Description: request.Description,
|
||||
Status: status,
|
||||
}
|
||||
goods.CreatedBy = adminID
|
||||
goods.UpdatedBy = adminID
|
||||
if err := s.goodsDao.GoodsCreate(ctx, goods); err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
item := buildGoodsItem(goods)
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
// Update 更新商品(传什么改什么;不修改编码与类型)。
|
||||
func (s *GoodsService) Update(ctx context.Context, adminID int64, id int64, request *dto.GoodsUpdateRequest) *errcode.Error {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminGoodsUpdate")
|
||||
defer span.End()
|
||||
|
||||
if _, err := s.goodsDao.GoodsGetByID(ctx, id); err != nil {
|
||||
if isNotFound(err) {
|
||||
return errcode.GoodsNotFound
|
||||
}
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
|
||||
updates := map[string]any{"updated_by": adminID}
|
||||
if request.GoodsName != nil {
|
||||
updates["goods_name"] = strings.TrimSpace(*request.GoodsName)
|
||||
}
|
||||
if request.Price != nil {
|
||||
updates["price"] = *request.Price
|
||||
}
|
||||
if request.DiscountPrice != nil {
|
||||
updates["discount_price"] = *request.DiscountPrice
|
||||
}
|
||||
if request.MemberDays != nil {
|
||||
updates["member_days"] = *request.MemberDays
|
||||
}
|
||||
if request.UploadQuota != nil {
|
||||
updates["cos_quota"] = *request.UploadQuota
|
||||
}
|
||||
if request.AIRecommendQuota != nil {
|
||||
updates["ai_recommend_quota"] = *request.AIRecommendQuota
|
||||
}
|
||||
if request.ClosetShareQuota != nil {
|
||||
updates["closet_share_quota"] = *request.ClosetShareQuota
|
||||
}
|
||||
if request.OutfitShareQuota != nil {
|
||||
updates["outfit_share_quota"] = *request.OutfitShareQuota
|
||||
}
|
||||
if request.ValidDays != nil {
|
||||
updates["valid_days"] = *request.ValidDays
|
||||
}
|
||||
if request.Description != nil {
|
||||
updates["description"] = *request.Description
|
||||
}
|
||||
if len(updates) == 1 {
|
||||
return nil // 无可更新字段。
|
||||
}
|
||||
if err := s.goodsDao.GoodsUpdate(ctx, id, updates); err != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateStatus 商品上下架。
|
||||
func (s *GoodsService) UpdateStatus(ctx context.Context, adminID int64, id int64, request *dto.GoodsStatusRequest) *errcode.Error {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminGoodsUpdateStatus")
|
||||
defer span.End()
|
||||
|
||||
if _, err := s.goodsDao.GoodsGetByID(ctx, id); err != nil {
|
||||
if isNotFound(err) {
|
||||
return errcode.GoodsNotFound
|
||||
}
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
if err := s.goodsDao.GoodsUpdate(ctx, id, map[string]any{"status": request.Status, "updated_by": adminID}); err != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 软删除商品(历史订单已快照商品信息,删除不影响历史订单)。
|
||||
func (s *GoodsService) Delete(ctx context.Context, adminID int64, id int64) *errcode.Error {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminGoodsDelete")
|
||||
defer span.End()
|
||||
|
||||
if _, err := s.goodsDao.GoodsGetByID(ctx, id); err != nil {
|
||||
if isNotFound(err) {
|
||||
return errcode.GoodsNotFound
|
||||
}
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
if err := s.goodsDao.GoodsSoftDelete(ctx, id); err != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildGoodsItem 商品模型 → 响应(注意 dto.UploadQuota 对应 model.CosQuota)。
|
||||
func buildGoodsItem(g *mysqlmodel.Goods) dto.GoodsItem {
|
||||
return dto.GoodsItem{
|
||||
ID: g.ID,
|
||||
GoodsCode: g.GoodsCode,
|
||||
GoodsName: g.GoodsName,
|
||||
GoodsType: g.GoodsType,
|
||||
Price: g.Price,
|
||||
DiscountPrice: g.DiscountPrice,
|
||||
MemberDays: g.MemberDays,
|
||||
UploadQuota: g.CosQuota,
|
||||
AIRecommendQuota: g.AIRecommendQuota,
|
||||
ClosetShareQuota: g.ClosetShareQuota,
|
||||
OutfitShareQuota: g.OutfitShareQuota,
|
||||
ValidDays: g.ValidDays,
|
||||
Description: g.Description,
|
||||
Status: g.Status,
|
||||
ProductID: g.ProductID,
|
||||
ProductStatus: g.ProductStatus,
|
||||
CreatedAt: g.CreatedAt,
|
||||
UpdatedAt: g.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"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/internal/redisdao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
|
||||
)
|
||||
|
||||
// broadcastBatchSize 全量广播每批处理的用户数(分批遍历用户 + 分批插入,避免大事务与内存暴涨)。
|
||||
const broadcastBatchSize = 500
|
||||
|
||||
// NotificationService 通知推送服务。
|
||||
type NotificationService struct {
|
||||
notificationDao *mysqldao.NotificationDao
|
||||
userDao *mysqldao.UserDao
|
||||
}
|
||||
|
||||
// NewNotificationService 创建通知服务。
|
||||
func NewNotificationService() *NotificationService {
|
||||
return &NotificationService{
|
||||
notificationDao: mysqldao.NewNotificationDao(),
|
||||
userDao: mysqldao.NewUserDao(),
|
||||
}
|
||||
}
|
||||
|
||||
// Send 向单个用户发送系统通知(写 user_notifications + Redis 未读计数自增)。
|
||||
func (s *NotificationService) Send(ctx context.Context, adminID int64, request *dto.NotificationSendRequest) *errcode.Error {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminNotificationSend")
|
||||
defer span.End()
|
||||
|
||||
// 校验接收用户存在。
|
||||
if _, err := s.userDao.UserGetByID(ctx, request.UserID); err != nil {
|
||||
if isNotFound(err) {
|
||||
return errcode.UserNotFound
|
||||
}
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
|
||||
notification := buildSystemNotification(request.UserID, request.Title, request.Content)
|
||||
if err := s.notificationDao.NotificationBatchCreate(ctx, []mysqlmodel.UserNotification{notification}); err != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
s.incrUnread(ctx, request.UserID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Broadcast 全量广播系统通知:分批遍历用户写入,返回发送人数。
|
||||
func (s *NotificationService) Broadcast(ctx context.Context, adminID int64, request *dto.NotificationBroadcastRequest) (*dto.NotificationBroadcastResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminNotificationBroadcast")
|
||||
defer span.End()
|
||||
|
||||
title := strings.TrimSpace(request.Title)
|
||||
content := strings.TrimSpace(request.Content)
|
||||
|
||||
var sent int64
|
||||
var lastID int64
|
||||
for {
|
||||
ids, err := s.userDao.UserIDListAll(ctx, lastID, broadcastBatchSize)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "broadcast fetch user ids failed",
|
||||
"sent", sent, "error", err)
|
||||
return &dto.NotificationBroadcastResponse{SentCount: sent}, errcode.DatabaseError
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
batch := make([]mysqlmodel.UserNotification, 0, len(ids))
|
||||
for _, uid := range ids {
|
||||
batch = append(batch, buildSystemNotification(uid, title, content))
|
||||
}
|
||||
if err := s.notificationDao.NotificationBatchCreate(ctx, batch); err != nil {
|
||||
slog.ErrorContext(ctx, "broadcast insert batch failed",
|
||||
"sent", sent, "error", err)
|
||||
return &dto.NotificationBroadcastResponse{SentCount: sent}, errcode.DatabaseError
|
||||
}
|
||||
sent += int64(len(ids))
|
||||
|
||||
// best-effort 未读计数自增。
|
||||
for _, uid := range ids {
|
||||
s.incrUnread(ctx, uid)
|
||||
}
|
||||
|
||||
lastID = ids[len(ids)-1]
|
||||
if len(ids) < broadcastBatchSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return &dto.NotificationBroadcastResponse{SentCount: sent}, nil
|
||||
}
|
||||
|
||||
// incrUnread 未读计数自增(best-effort,失败仅告警,不影响主流程)。
|
||||
func (s *NotificationService) incrUnread(ctx context.Context, userID int64) {
|
||||
if _, err := redisdao.NotificationUnreadIncr(ctx, userID); err != nil {
|
||||
slog.WarnContext(ctx, "incr notification unread failed", "user_id", userID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildSystemNotification 构造系统通知记录(msg_type=3、sender_id=0、未读)。
|
||||
func buildSystemNotification(userID int64, title, content string) mysqlmodel.UserNotification {
|
||||
return mysqlmodel.UserNotification{
|
||||
NotificationUUID: genUUID(),
|
||||
UserID: userID,
|
||||
MsgType: NotificationTypeSystem,
|
||||
SenderID: NotificationSenderSystem,
|
||||
Title: strings.TrimSpace(title),
|
||||
Content: strings.TrimSpace(content),
|
||||
IsRead: NotificationUnread,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
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/errcode"
|
||||
)
|
||||
|
||||
// OrderService 订单与退款管理服务。
|
||||
type OrderService struct {
|
||||
orderDao *mysqldao.OrderDao
|
||||
refundDao *mysqldao.RefundDao
|
||||
}
|
||||
|
||||
// NewOrderService 创建订单服务。
|
||||
func NewOrderService() *OrderService {
|
||||
return &OrderService{
|
||||
orderDao: mysqldao.NewOrderDao(),
|
||||
refundDao: mysqldao.NewRefundDao(),
|
||||
}
|
||||
}
|
||||
|
||||
// List 分页查询订单。
|
||||
func (s *OrderService) List(ctx context.Context, request *dto.OrderListRequest) (*dto.OrderListResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminOrderList")
|
||||
defer span.End()
|
||||
|
||||
page, pageSize := normalizePage(request.Page, request.PageSize)
|
||||
|
||||
list, total, err := s.orderDao.OrderList(ctx, mysqldao.OrderListParams{
|
||||
OrderNo: strings.TrimSpace(request.OrderNo),
|
||||
UserID: request.UserID,
|
||||
Status: request.Status,
|
||||
Page: page,
|
||||
Size: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
items := make([]dto.OrderItem, 0, len(list))
|
||||
for i := range list {
|
||||
items = append(items, buildOrderItem(&list[i]))
|
||||
}
|
||||
return &dto.OrderListResponse{
|
||||
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
|
||||
List: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Get 订单详情。
|
||||
func (s *OrderService) Get(ctx context.Context, orderNo string) (*dto.OrderItem, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminOrderGet")
|
||||
defer span.End()
|
||||
|
||||
order, err := s.orderDao.OrderGetByOrderNo(ctx, orderNo)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, errcode.OrderNotFound
|
||||
}
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
item := buildOrderItem(order)
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
// RefundList 分页查询退款单。
|
||||
func (s *OrderService) RefundList(ctx context.Context, request *dto.RefundListRequest) (*dto.RefundListResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminRefundList")
|
||||
defer span.End()
|
||||
|
||||
page, pageSize := normalizePage(request.Page, request.PageSize)
|
||||
|
||||
// status 未传时 query 绑定为 nil,与「待处理=0」天然区分。
|
||||
withStatus := request.Status != nil
|
||||
var status int8
|
||||
if withStatus {
|
||||
status = *request.Status
|
||||
}
|
||||
|
||||
list, total, err := s.refundDao.RefundList(ctx, mysqldao.RefundListParams{
|
||||
Status: status,
|
||||
WithStatus: withStatus,
|
||||
OrderNo: strings.TrimSpace(request.OrderNo),
|
||||
Page: page,
|
||||
Size: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
items := make([]dto.RefundItem, 0, len(list))
|
||||
for i := range list {
|
||||
items = append(items, buildRefundItem(&list[i]))
|
||||
}
|
||||
return &dto.RefundListResponse{
|
||||
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
|
||||
List: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RefundApprove 审核通过:待处理(0) → 退款中(1)。
|
||||
// 实际打款由运营在微信商户平台人工完成,业务服务退款回调负责后续「成功(2)」流转与权益回收。
|
||||
func (s *OrderService) RefundApprove(ctx context.Context, adminID int64, refundNo string, request *dto.RefundAuditRequest) *errcode.Error {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminRefundApprove")
|
||||
defer span.End()
|
||||
|
||||
if _, err := s.refundDao.RefundGetByRefundNo(ctx, refundNo); err != nil {
|
||||
if isNotFound(err) {
|
||||
return errcode.RefundNotFound
|
||||
}
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
|
||||
ok, err := s.refundDao.RefundTransition(ctx, refundNo, RefundStatusPending, RefundStatusProcessing, "")
|
||||
if err != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
if !ok {
|
||||
return errcode.RefundStatusInvalid.WithDetails("仅待处理状态的退款单可审核")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefundReject 审核驳回:待处理(0) → 失败(3),记录驳回原因。
|
||||
func (s *OrderService) RefundReject(ctx context.Context, adminID int64, refundNo string, request *dto.RefundAuditRequest) *errcode.Error {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdminRefundReject")
|
||||
defer span.End()
|
||||
|
||||
if _, err := s.refundDao.RefundGetByRefundNo(ctx, refundNo); err != nil {
|
||||
if isNotFound(err) {
|
||||
return errcode.RefundNotFound
|
||||
}
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
|
||||
ok, err := s.refundDao.RefundTransition(ctx, refundNo, RefundStatusPending, RefundStatusFailed, strings.TrimSpace(request.Reason))
|
||||
if err != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
if !ok {
|
||||
return errcode.RefundStatusInvalid.WithDetails("仅待处理状态的退款单可审核")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildOrderItem 订单模型 → 响应。
|
||||
func buildOrderItem(o *mysqlmodel.Order) dto.OrderItem {
|
||||
return dto.OrderItem{
|
||||
OrderNo: o.OrderNo,
|
||||
UserID: o.UserID,
|
||||
GoodsID: o.GoodsID,
|
||||
GoodsName: o.GoodsName,
|
||||
UnitPrice: o.UnitPrice,
|
||||
TotalAmount: o.TotalAmount,
|
||||
PayAmount: o.PayAmount,
|
||||
PayChannel: o.PayChannel,
|
||||
OrderStatus: o.OrderStatus,
|
||||
TransactionID: o.TransactionID,
|
||||
MemberDays: o.MemberDays,
|
||||
UploadQuota: o.CosQuota,
|
||||
AIRecommendQuota: o.AIRecommendQuota,
|
||||
ClosetShareQuota: o.ClosetShareQuota,
|
||||
OutfitShareQuota: o.OutfitShareQuota,
|
||||
ExpireTime: o.ExpireTime,
|
||||
PayTime: o.PayTime,
|
||||
RefundTime: o.RefundTime,
|
||||
RefundAmount: o.RefundAmount,
|
||||
Remark: o.Remark,
|
||||
XPayProductID: o.XPayProductID,
|
||||
XPayPlatform: o.XPayPlatform,
|
||||
XPayEnv: o.XPayEnv,
|
||||
CreatedAt: o.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// buildRefundItem 退款单模型 → 响应。
|
||||
func buildRefundItem(r *mysqlmodel.RefundFlowLog) dto.RefundItem {
|
||||
return dto.RefundItem{
|
||||
RefundNo: r.RefundNo,
|
||||
OrderNo: r.OrderNo,
|
||||
UserID: r.UserID,
|
||||
RefundAmount: r.RefundAmount,
|
||||
RefundReason: r.RefundReason,
|
||||
Status: r.Status,
|
||||
ApplyTime: r.ApplyTime,
|
||||
SuccessTime: r.SuccessTime,
|
||||
FailReason: r.FailReason,
|
||||
CreatedAt: r.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Package service 承载管理后台业务逻辑层,编排 dto、mysqldao、redisdao 与 pkg 能力。
|
||||
//
|
||||
// 约定:
|
||||
// - 每个业务域一个 Service 结构体,NewXxxService() 构造,内部持有所需 DAO。
|
||||
// - 方法统一返回 (*dto.XxxResponse, *errcode.Error),将底层 error 映射为语义化错误。
|
||||
// - redis 未命中返回 redis.Nil,按缓存未命中处理,不视为错误。
|
||||
// - 所有管理写操作在 service 层做权限与状态校验(AuthZ)。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/middleware"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
|
||||
"github.com/google/uuid"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// isNotFound 判断 gorm 是否为「记录不存在」。
|
||||
func isNotFound(err error) bool {
|
||||
return errors.Is(err, gorm.ErrRecordNotFound)
|
||||
}
|
||||
|
||||
// isCacheMiss 判断 redis 是否为「未命中」。
|
||||
func isCacheMiss(err error) bool {
|
||||
return errors.Is(err, redis.Nil)
|
||||
}
|
||||
|
||||
// 分页默认值。
|
||||
const (
|
||||
defaultPage = 1
|
||||
defaultPageSize = 10
|
||||
maxPageSize = 50
|
||||
)
|
||||
|
||||
// normalizePage 规整分页参数,返回可直接用于 DAO 的 page/pageSize。
|
||||
func normalizePage(page, pageSize int) (int, int) {
|
||||
if page <= 0 {
|
||||
page = defaultPage
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = defaultPageSize
|
||||
}
|
||||
if pageSize > maxPageSize {
|
||||
pageSize = maxPageSize
|
||||
}
|
||||
return page, pageSize
|
||||
}
|
||||
|
||||
// nowUnix 当前秒级时间戳。
|
||||
func nowUnix() int64 {
|
||||
return time.Now().Unix()
|
||||
}
|
||||
|
||||
// todayStartUnix 返回本地时区今日 0 点的秒级时间戳。
|
||||
func todayStartUnix() int64 {
|
||||
now := time.Now()
|
||||
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
return start.Unix()
|
||||
}
|
||||
|
||||
// todayStatDate 返回本地时区今日的 YYYYMMDD 整数(user_daily_usages 统计日期)。
|
||||
func todayStatDate() int64 {
|
||||
now := time.Now()
|
||||
return int64(now.Year()*10000 + int(now.Month())*100 + now.Day())
|
||||
}
|
||||
|
||||
// genUUID 生成新的 UUID 字符串。
|
||||
func genUUID() string {
|
||||
return uuid.NewString()
|
||||
}
|
||||
|
||||
// requireAdminID 从 context 取管理员 ID(由中间件注入),缺失返回未授权错误。
|
||||
func requireAdminID(ctx context.Context) (int64, *errcode.Error) {
|
||||
adminID, ok := middleware.GetAdminID(ctx)
|
||||
if !ok || adminID <= 0 {
|
||||
return 0, errcode.Unauthorized
|
||||
}
|
||||
return adminID, nil
|
||||
}
|
||||
@@ -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