first commit

This commit is contained in:
2026-09-11 01:47:20 +08:00
commit 303207d3cc
74 changed files with 8348 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
package dto
// AdminLoginRequest 管理员登录请求。
type AdminLoginRequest struct {
Username string `json:"username" binding:"required,max=32"`
Password string `json:"password" binding:"required,min=6,max=64"`
}
// AdminInfo 管理员信息。
type AdminInfo struct {
ID int64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Role int8 `json:"role"` // 1=超级管理员 2=普通管理员
Status int8 `json:"status"` // 1=启用 2=禁用
LastLoginTime int64 `json:"last_login_time"`
CreatedAt int64 `json:"created_at"`
}
// AdminLoginResponse 登录响应(双 token + 管理员信息)。
type AdminLoginResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpireTime int64 `json:"expire_time"` // access_token 过期时间(秒)
AdminInfo AdminInfo `json:"admin_info"`
}
// AdminRefreshTokenRequest 刷新 token 请求。
type AdminRefreshTokenRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
AccessToken string `json:"access_token"` // 可选:传入后立即吊销旧 access token
}
// AdminRefreshTokenResponse 刷新 token 响应。
type AdminRefreshTokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpireTime int64 `json:"expire_time"`
}
// AdminChangePasswordRequest 修改自己密码请求。
type AdminChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required,min=6,max=64"`
NewPassword string `json:"new_password" binding:"required,min=6,max=64"`
}
// AdminListRequest 管理员列表请求。
type AdminListRequest struct {
PageRequest
Status int8 `form:"status" binding:"omitempty,oneof=1 2"` // 1=启用 2=禁用;不传=全部
}
// AdminListResponse 管理员列表响应。
type AdminListResponse struct {
PageResponse
List []AdminInfo `json:"list"`
}
// AdminCreateRequest 创建管理员请求(仅超级管理员)。
type AdminCreateRequest struct {
Username string `json:"username" binding:"required,min=3,max=32"`
Password string `json:"password" binding:"required,min=6,max=64"`
Nickname string `json:"nickname" binding:"omitempty,max=32"`
Role int8 `json:"role" binding:"required,oneof=1 2"`
}
// AdminUpdateRequest 更新管理员请求(仅超级管理员;传什么改什么)。
type AdminUpdateRequest struct {
Nickname string `json:"nickname" binding:"omitempty,max=32"`
Password string `json:"password" binding:"omitempty,min=6,max=64"`
Role int8 `json:"role" binding:"omitempty,oneof=1 2"`
Status int8 `json:"status" binding:"omitempty,oneof=1 2"`
}
+14
View File
@@ -0,0 +1,14 @@
package dto
// PageRequest 分页请求参数(GET query,嵌入各列表请求)
type PageRequest struct {
Page int `form:"page" binding:"omitempty,min=1"`
PageSize int `form:"page_size" binding:"omitempty,min=1,max=50"`
}
// PageResponse 分页响应(嵌入各列表响应)
type PageResponse struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
+127
View File
@@ -0,0 +1,127 @@
package dto
// OutfitListRequest 穿搭列表请求。
type OutfitListRequest struct {
PageRequest
UserID int64 `form:"user_id"`
IsPublic int8 `form:"is_public" binding:"omitempty,oneof=1 2"`
AuditStatus int8 `form:"audit_status" binding:"omitempty,oneof=1 2 3"`
Status int8 `form:"status" binding:"omitempty,oneof=1 2 3"`
Keyword string `form:"keyword"`
}
// OutfitListItem 穿搭列表项。
type OutfitListItem struct {
ID int64 `json:"id"`
OutfitUUID string `json:"outfit_uuid"`
UserID int64 `json:"user_id"`
OutfitName string `json:"outfit_name"`
OutfitDesc string `json:"outfit_desc"`
Scene string `json:"scene"`
Source int8 `json:"source"` // 1=用户创建 2=AI 推荐
IsPublic int8 `json:"is_public"` // 1=不公开 2=公开
AuditStatus int8 `json:"audit_status"` // 1=待审核 2=审核通过 3=审核不通过
Status int8 `json:"status"` // 1=正常 2=封禁 3=待用户保存
CreatedAt int64 `json:"created_at"`
}
// OutfitListResponse 穿搭列表响应。
type OutfitListResponse struct {
PageResponse
List []OutfitListItem `json:"list"`
}
// OutfitClothesItem 穿搭衣物明细项。
type OutfitClothesItem struct {
ClothesUUID string `json:"clothes_uuid"`
Name string `json:"name"`
CategoryMain string `json:"category_main"`
CategorySub string `json:"category_sub"`
ProcessedImage string `json:"processed_image"`
ImageProcessStatus int8 `json:"image_process_status"`
}
// OutfitDetailResponse 穿搭详情响应。
type OutfitDetailResponse struct {
OutfitListItem
Items []OutfitClothesItem `json:"items"`
}
// OutfitAuditRequest 穿搭审核请求。
type OutfitAuditRequest struct {
Result int8 `json:"result" binding:"required,oneof=1 2"` // 1=通过 2=驳回
Reason string `json:"reason" binding:"omitempty,max=200"` // 驳回原因(result=2 时建议填写)
}
// OutfitBanRequest 穿搭封禁/解封请求。
type OutfitBanRequest struct {
Reason string `json:"reason" binding:"omitempty,max=200"`
}
// ClothesListRequest 衣物列表请求。
type ClothesListRequest struct {
PageRequest
UserID int64 `form:"user_id"`
ImageProcessStatus int8 `form:"image_process_status" binding:"omitempty,oneof=1 2 3 4 5"`
CategoryMain string `form:"category_main"`
Keyword string `form:"keyword"`
}
// ClothesItem 衣物信息。
type ClothesItem struct {
ID int64 `json:"id"`
ClothesUUID string `json:"clothes_uuid"`
UserID int64 `json:"user_id"`
Name string `json:"name"`
CategoryMain string `json:"category_main"`
CategorySub string `json:"category_sub"`
Style string `json:"style"`
ColorMain string `json:"color_main"`
ColorSub string `json:"color_sub"`
Thickness string `json:"thickness"`
Version string `json:"version"`
Material string `json:"material"`
Pattern string `json:"pattern"`
SleeveType string `json:"sleeve_type"`
ClothesType string `json:"clothes_type"`
NecklineType string `json:"neckline_type"`
Formality string `json:"formality"`
Occasions string `json:"occasions"`
VisualImpact string `json:"visual_impact"`
Remark string `json:"remark"`
ProcessedImage string `json:"processed_image"`
ImageSize int64 `json:"image_size"`
ImageProcessStatus int8 `json:"image_process_status"`
FailReason string `json:"fail_reason"`
CreatedAt int64 `json:"created_at"`
}
// ClothesListResponse 衣物列表响应。
type ClothesListResponse struct {
PageResponse
List []ClothesItem `json:"list"`
}
// ClosetShareListRequest 衣柜分享列表请求。
type ClosetShareListRequest struct {
PageRequest
UserID int64 `form:"user_id"`
Status int8 `form:"status" binding:"omitempty,oneof=1 2 3 4"`
}
// ClosetShareItem 衣柜分享信息。
type ClosetShareItem struct {
ID int64 `json:"id"`
ShareUUID string `json:"share_uuid"`
UserID int64 `json:"user_id"`
ShareType int8 `json:"share_type"` // 1=部分衣物分享 2=全量快照分享
ExpireTime int64 `json:"expire_time"`
Status int8 `json:"status"` // 1=正常 2=已取消 3=已过期 4=已封禁
CreatedAt int64 `json:"created_at"`
}
// ClosetShareListResponse 衣柜分享列表响应。
type ClosetShareListResponse struct {
PageResponse
List []ClosetShareItem `json:"list"`
}
+146
View File
@@ -0,0 +1,146 @@
package dto
// DashboardResponse 仪表盘统计响应。
type DashboardResponse struct {
Users DashboardUsers `json:"users"`
Orders DashboardOrders `json:"orders"`
Revenue DashboardRevenue `json:"revenue"`
Todo DashboardTodo `json:"todo"`
Content DashboardContent `json:"content"`
VIP DashboardVIP `json:"vip"`
Goods DashboardGoods `json:"goods"`
Usage DashboardUsage `json:"usage"`
Social DashboardSocial `json:"social"`
}
// DashboardUsers 用户统计。
type DashboardUsers struct {
Total int64 `json:"total"` // 用户总数
TodayNew int64 `json:"today_new"` // 今日新增
Normal int64 `json:"normal"` // 正常
TempBan int64 `json:"temp_ban"` // 临时禁用
PermBan int64 `json:"perm_ban"` // 永久禁用
Deactivated int64 `json:"deactivated"` // 已注销
}
// DashboardOrders 订单统计。
type DashboardOrders struct {
Total int64 `json:"total"` // 订单总数
Pending int64 `json:"pending"` // 待支付
Paid int64 `json:"paid"` // 已支付
Cancelled int64 `json:"cancelled"` // 已取消
Expired int64 `json:"expired"` // 已过期
Refunded int64 `json:"refunded"` // 已退款
}
// DashboardRevenue 营收统计(单位:分)。
type DashboardRevenue struct {
TotalRevenue int64 `json:"total_revenue"` // 累计营收
TodayRevenue int64 `json:"today_revenue"` // 今日营收
RefundTotal int64 `json:"refund_total"` // 累计退款
}
// DashboardTodo 待办统计。
type DashboardTodo struct {
RefundPending int64 `json:"refund_pending"` // 待处理退款
OutfitPendingAudit int64 `json:"outfit_pending_audit"` // 待审核穿搭
GoodsOnShelf int64 `json:"goods_on_shelf"` // 上架商品数
}
// ===== 内容审核概况 =====
// DashboardContent 内容审核概况。
type DashboardContent struct {
Outfit DashboardOutfitStats `json:"outfit"`
Clothes DashboardClothesStats `json:"clothes"`
Share DashboardShareStats `json:"share"`
}
// DashboardOutfitStats 穿搭审核统计。
type DashboardOutfitStats struct {
Total int64 `json:"total"` // 穿搭总数
Pending int64 `json:"pending"` // 待审核
Passed int64 `json:"passed"` // 审核通过
Rejected int64 `json:"rejected"` // 审核不通过
Banned int64 `json:"banned"` // 封禁
}
// DashboardClothesStats 衣物图片处理统计。
type DashboardClothesStats struct {
Total int64 `json:"total"` // 衣物总数
Processing int64 `json:"processing"` // 处理中(抠图/分类/排队)
Success int64 `json:"success"` // 处理成功
Failed int64 `json:"failed"` // 处理失败
}
// DashboardShareStats 衣柜分享统计。
type DashboardShareStats struct {
Total int64 `json:"total"` // 分享总数
Normal int64 `json:"normal"` // 正常
Cancelled int64 `json:"cancelled"` // 用户取消
Expired int64 `json:"expired"` // 已过期
Banned int64 `json:"banned"` // 管理员封禁
}
// ===== VIP 会员概况 =====
// DashboardVIP 会员概况。
type DashboardVIP struct {
MemberTotal int64 `json:"member_total"` // 有效会员总数(月度/年度/体验)
Monthly int64 `json:"monthly"` // 月度会员
Yearly int64 `json:"yearly"` // 年度会员
Trial int64 `json:"trial"` // 体验会员
ExpiringSoon int64 `json:"expiring_soon"` // 7天内即将到期
}
// ===== 商品分类统计 =====
// DashboardGoods 商品统计。
type DashboardGoods struct {
Total int64 `json:"total"`
OnShelf int64 `json:"on_shelf"`
OffShelf int64 `json:"off_shelf"`
ByType []GoodsTypeCount `json:"by_type"`
}
// GoodsTypeCount 商品类型数量。
type GoodsTypeCount struct {
Type int8 `json:"type"` // 1会员包 2上传包 3AI推荐包 4衣柜分享包 5搭配分享包
Count int64 `json:"count"`
}
// ===== 趋势 =====
// TrendRequest 趋势查询参数。
type TrendRequest struct {
Days int `form:"days" binding:"omitempty,min=1,max=90"`
}
// TrendResponse 趋势响应。
type TrendResponse struct {
Points []TrendPoint `json:"points"`
}
// TrendPoint 单日趋势点。
type TrendPoint struct {
Date string `json:"date"` // YYYY-MM-DD
NewUsers int64 `json:"new_users"` // 新增用户
Orders int64 `json:"orders"` // 已支付订单量
Revenue int64 `json:"revenue"` // 营收(分)
}
// ===== 今日消耗与互动 =====
// DashboardUsage 今日消耗统计。
type DashboardUsage struct {
AIRecommend int64 `json:"ai_recommend"` // 今日AI推荐消耗次数
ClosetShare int64 `json:"closet_share"` // 今日衣柜分享消耗次数
OutfitShare int64 `json:"outfit_share"` // 今日社区分享消耗次数
}
// DashboardSocial 内容互动统计。
type DashboardSocial struct {
CosImageCount int64 `json:"cos_image_count"` // COS总图数
LikeCount int64 `json:"like_count"` // 点赞数
CollectCount int64 `json:"collect_count"` // 收藏数
}
+73
View File
@@ -0,0 +1,73 @@
package dto
// GoodsListRequest 商品列表请求。
type GoodsListRequest struct {
PageRequest
GoodsType int8 `form:"goods_type" binding:"omitempty,oneof=1 2 3 4 5"`
Status int8 `form:"status" binding:"omitempty,oneof=1 2"` // 1=上架 2=下架;不传=全部
Keyword string `form:"keyword"`
}
// GoodsItem 商品信息。
type GoodsItem struct {
ID int64 `json:"id"`
GoodsCode string `json:"goods_code"`
GoodsName string `json:"goods_name"`
GoodsType int8 `json:"goods_type"` // 1=会员包 2=上传包 3=AI 推荐包 4=衣柜分享包 5=搭配分享包
Price int64 `json:"price"` // 原价(分)
DiscountPrice int64 `json:"discount_price"`
MemberDays int64 `json:"member_days"`
UploadQuota int64 `json:"upload_quota"` // 上传额度(对应业务表 cos_quota)
AIRecommendQuota int64 `json:"ai_recommend_quota"` // AI 推荐额度
ClosetShareQuota int64 `json:"closet_share_quota"` // 衣柜分享额度
OutfitShareQuota int64 `json:"outfit_share_quota"` // 搭配分享额度
ValidDays int64 `json:"valid_days"` // 赠送额度有效期天数(加量包;会员包 0=跟随 VIP)
Description string `json:"description"`
Status int8 `json:"status"` // 1=上架 2=下架
ProductID string `json:"product_id"` // 虚拟支付道具ID(只读,空=未发布)
ProductStatus int8 `json:"product_status"` // 虚拟支付道具发布状态 0=未发布 1=发布中 2=已发布 3=发布失败(只读)
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// GoodsListResponse 商品列表响应。
type GoodsListResponse struct {
PageResponse
List []GoodsItem `json:"list"`
}
// GoodsCreateRequest 创建商品请求。
type GoodsCreateRequest struct {
GoodsCode string `json:"goods_code" binding:"required,max=64"`
GoodsName string `json:"goods_name" binding:"required,max=64"`
GoodsType int8 `json:"goods_type" binding:"required,oneof=1 2 3 4 5"`
Price int64 `json:"price" binding:"required,min=0,max=100000000"`
DiscountPrice int64 `json:"discount_price" binding:"required,min=0,max=100000000"`
MemberDays int64 `json:"member_days" binding:"omitempty,min=0"`
UploadQuota int64 `json:"upload_quota" binding:"omitempty,min=0"`
AIRecommendQuota int64 `json:"ai_recommend_quota" binding:"omitempty,min=0"`
ClosetShareQuota int64 `json:"closet_share_quota" binding:"omitempty,min=0"`
OutfitShareQuota int64 `json:"outfit_share_quota" binding:"omitempty,min=0"`
ValidDays int64 `json:"valid_days" binding:"omitempty,min=0,max=3650"`
Description string `json:"description" binding:"omitempty,max=500"`
Status int8 `json:"status" binding:"omitempty,oneof=1 2"`
}
// GoodsUpdateRequest 更新商品请求(传什么改什么)。
type GoodsUpdateRequest struct {
GoodsName *string `json:"goods_name" binding:"omitempty,max=64"`
Price *int64 `json:"price" binding:"omitempty,min=0,max=100000000"`
DiscountPrice *int64 `json:"discount_price" binding:"omitempty,min=0,max=100000000"`
MemberDays *int64 `json:"member_days" binding:"omitempty,min=0"`
UploadQuota *int64 `json:"upload_quota" binding:"omitempty,min=0"`
AIRecommendQuota *int64 `json:"ai_recommend_quota" binding:"omitempty,min=0"`
ClosetShareQuota *int64 `json:"closet_share_quota" binding:"omitempty,min=0"`
OutfitShareQuota *int64 `json:"outfit_share_quota" binding:"omitempty,min=0"`
ValidDays *int64 `json:"valid_days" binding:"omitempty,min=0,max=3650"`
Description *string `json:"description" binding:"omitempty,max=500"`
}
// GoodsStatusRequest 商品上下架请求。
type GoodsStatusRequest struct {
Status int8 `json:"status" binding:"required,oneof=1 2"` // 1=上架 2=下架
}
+19
View File
@@ -0,0 +1,19 @@
package dto
// NotificationSendRequest 单用户通知请求。
type NotificationSendRequest struct {
UserID int64 `json:"user_id" binding:"required,min=1"`
Title string `json:"title" binding:"required,max=64"`
Content string `json:"content" binding:"required,max=500"`
}
// NotificationBroadcastRequest 全量广播请求。
type NotificationBroadcastRequest struct {
Title string `json:"title" binding:"required,max=64"`
Content string `json:"content" binding:"required,max=500"`
}
// NotificationBroadcastResponse 广播结果。
type NotificationBroadcastResponse struct {
SentCount int64 `json:"sent_count"` // 已发送用户数
}
+76
View File
@@ -0,0 +1,76 @@
package dto
// OrderListRequest 订单列表请求。
type OrderListRequest struct {
PageRequest
OrderNo string `form:"order_no"` // 订单号(精确)
UserID int64 `form:"user_id"` // 用户 ID
Status int8 `form:"status" binding:"omitempty,oneof=1 2 3 4 5"`
}
// OrderItem 订单信息(金额单位:分)。
type OrderItem struct {
OrderNo string `json:"order_no"`
UserID int64 `json:"user_id"`
GoodsID int64 `json:"goods_id"`
GoodsName string `json:"goods_name"`
UnitPrice int64 `json:"unit_price"`
TotalAmount int64 `json:"total_amount"`
PayAmount int64 `json:"pay_amount"`
PayChannel int8 `json:"pay_channel"` // 1=微信支付 2=微信虚拟支付
OrderStatus int8 `json:"order_status"`
TransactionID string `json:"transaction_id"`
MemberDays int64 `json:"member_days"`
UploadQuota int64 `json:"upload_quota"`
AIRecommendQuota int64 `json:"ai_recommend_quota"`
ClosetShareQuota int64 `json:"closet_share_quota"`
OutfitShareQuota int64 `json:"outfit_share_quota"`
ExpireTime int64 `json:"expire_time"`
PayTime int64 `json:"pay_time"`
RefundTime int64 `json:"refund_time"`
RefundAmount int64 `json:"refund_amount"`
Remark string `json:"remark"`
XPayProductID string `json:"xpay_product_id"` // 虚拟支付道具ID(pay_channel=2 时使用)
XPayPlatform string `json:"xpay_platform"` // 虚拟支付平台 android/ios/harmony/windows
XPayEnv int8 `json:"xpay_env"` // 虚拟支付环境 0=现网 1=沙箱
CreatedAt int64 `json:"created_at"`
}
// OrderListResponse 订单列表响应。
type OrderListResponse struct {
PageResponse
List []OrderItem `json:"list"`
}
// RefundListRequest 退款单列表请求。
// Status 用指针区分「未传(nil,不过滤)」与「显式传 0(筛选待处理)」。
type RefundListRequest struct {
PageRequest
Status *int8 `form:"status" binding:"omitempty,oneof=0 1 2 3"` // 0=待处理 1=退款中 2=成功 3=失败;不传=全部
OrderNo string `form:"order_no"`
}
// RefundItem 退款单信息。
type RefundItem struct {
RefundNo string `json:"refund_no"`
OrderNo string `json:"order_no"`
UserID int64 `json:"user_id"`
RefundAmount int64 `json:"refund_amount"`
RefundReason string `json:"refund_reason"`
Status int8 `json:"status"` // 0=待处理 1=退款中 2=退款成功 3=退款失败
ApplyTime int64 `json:"apply_time"`
SuccessTime int64 `json:"success_time"`
FailReason string `json:"fail_reason"`
CreatedAt int64 `json:"created_at"`
}
// RefundListResponse 退款单列表响应。
type RefundListResponse struct {
PageResponse
List []RefundItem `json:"list"`
}
// RefundAuditRequest 退款审核请求。
type RefundAuditRequest struct {
Reason string `json:"reason" binding:"required,max=200"` // 通过=审核备注;驳回=驳回原因
}
+91
View File
@@ -0,0 +1,91 @@
package dto
// UserListRequest 用户列表请求。
type UserListRequest struct {
PageRequest
Keyword string `form:"keyword"` // 模糊匹配昵称/手机号/OpenID
Status int8 `form:"status" binding:"omitempty,oneof=1 2 3 4"` // 1=正常 2=临时禁用 3=永久禁用 4=已注销
}
// UserListItem 用户列表项。
type UserListItem struct {
ID int64 `json:"id"`
UserUUID string `json:"user_uuid"`
Username string `json:"username"`
Avatar string `json:"avatar"`
Sex int8 `json:"sex"`
Phone string `json:"phone"`
Status int8 `json:"status"` // 1=正常 2=临时禁用 3=永久禁用 4=已注销
BanReason string `json:"ban_reason"`
BanStart int64 `json:"ban_start"`
BanExpire int64 `json:"ban_expire"`
VIPType int8 `json:"vip_type"` // 1=非会员 2=月度会员 3=年度会员 4=体验会员
VIPExpire int64 `json:"vip_expire"` // VIP 到期时间(秒),已过期时归一为 0
LastLoginTime int64 `json:"last_login_time"`
CreatedAt int64 `json:"created_at"`
}
// UserListResponse 用户列表响应。
type UserListResponse struct {
PageResponse
List []UserListItem `json:"list"`
}
// QuotaItem 用户某类额度。
// Cos(quota_type=1):Total=永久总额度、Used=永久累计已用、Remain=永久剩余;
// AI/社区/衣柜(2/3/4):Total=每日总额度、Used=当日已用、Remain=当日剩余(次日 0 点重置)。
type QuotaItem struct {
QuotaType int8 `json:"quota_type"` // 1=上传 2=AI 3=社区分享 4=衣柜分享
Total int64 `json:"total"` // 总额度(Cos 永久 / 每日额度)
Used int64 `json:"used"` // 已用(Cos 永久累计 / 当日已用)
Remain int64 `json:"remain"` // 剩余
}
// UserGrantItem 用户额度凭证(有有效期赠送额度)。
type UserGrantItem struct {
ID int64 `json:"id"`
QuotaType int8 `json:"quota_type"` // 1=上传 2=AI 3=社区分享 4=衣柜分享
Amount int64 `json:"amount"` // 赠送总量(Cos=一次性库存;AI/社区/衣柜=有效期内每日加量)
Used int64 `json:"used"` // 已用量(仅 Cos 扣减)
Source int8 `json:"source"` // 1=会员包 2=加量包 3=广告激励
ExpireTime int64 `json:"expire_time"` // 到期时间(秒,0=跟随 VIP)
RefID string `json:"ref_id"` // 关联ID(订单号/广告记录ID/人工发放标识)
CreatedAt int64 `json:"created_at"`
}
// UserDetailResponse 用户详情响应。
type UserDetailResponse struct {
UserListItem
Quotas []QuotaItem `json:"quotas"`
Grants []UserGrantItem `json:"grants"`
}
// UserBanRequest 封禁用户请求。
type UserBanRequest struct {
BanType int8 `json:"ban_type" binding:"required,oneof=1 2"` // 1=临时禁用 2=永久禁用
BanDays int `json:"ban_days" binding:"omitempty,min=1,max=3650"` // 临时禁用天数(ban_type=1 时必填)
Reason string `json:"reason" binding:"required,max=200"`
}
// UserUnbanRequest 解封用户请求。
type UserUnbanRequest struct {
Reason string `json:"reason" binding:"omitempty,max=200"`
}
// UserQuotaAdjustRequest 额度发放请求(发放有有效期额度凭证)。
// Cos(quota_type=1):Amount 为一次性库存,ExpireDays 天内有效;
// AI/社区/衣柜(2/3/4):Amount 为有效期内每天额外赠送次数,ExpireDays 天内有效。
type UserQuotaAdjustRequest struct {
QuotaType int8 `json:"quota_type" binding:"required,oneof=1 2 3 4"` // 1=上传 2=AI 3=社区分享 4=衣柜分享
Amount int64 `json:"amount" binding:"required,min=1,max=100000000"` // 发放数量
ExpireDays int64 `json:"expire_days" binding:"required,min=1,max=3650"` // 有效期天数
}
// UserVIPAdjustRequest VIP 人工调整请求。
// 叠加语义(与购买发放一致):vip_expire = max(当前到期, now) + vip_days*86400。
// vip_days=0 表示取消会员(vip_type 归 1、到期清零)。
type UserVIPAdjustRequest struct {
VIPType int8 `json:"vip_type" binding:"required,oneof=1 2 3 4"` // 1=非会员 2=月度会员 3=年度会员 4=体验会员
VIPDays int64 `json:"vip_days" binding:"omitempty,min=0,max=3650"`
Reason string `json:"reason" binding:"required,max=200"`
}
+50
View File
@@ -0,0 +1,50 @@
package middleware
import (
"strings"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
"github.com/gin-gonic/gin"
)
// CORS 跨域中间件。
// 允许来源通过 Server.Domain 配置,自动放行其 http/https 两种协议的 Origin。
// 未配置时回退为 *(保持兼容)。
func CORS() gin.HandlerFunc {
domain := ""
if global.ServerSetting != nil {
domain = strings.TrimSpace(global.ServerSetting.Domain)
}
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
allowOrigin := "*"
if domain != "" && domain != "*" {
allowOrigin = ""
if originAllowed(origin, domain) {
allowOrigin = origin
}
}
if allowOrigin != "" {
c.Header("Access-Control-Allow-Origin", allowOrigin)
}
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization")
c.Header("Access-Control-Expose-Headers", "Content-Length")
c.Header("Access-Control-Max-Age", "86400")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
// originAllowed 判断 Origin 是否命中配置域名(http/https,忽略尾部斜杠)。
func originAllowed(origin, domain string) bool {
o := strings.TrimSuffix(strings.TrimSpace(origin), "/")
return o == "https://"+domain || o == "http://"+domain
}
+137
View File
@@ -0,0 +1,137 @@
package middleware
import (
"context"
"errors"
"log/slog"
"strings"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqldao"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/redisdao"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/app"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/lzcwyjwt"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
type ctxKey string
const (
adminIDKey ctxKey = "adminID"
)
const (
// adminRoleSuper 超级管理员角色(账号管理接口专用)。
adminRoleSuper int8 = 1
// adminStatusEnabled 启用状态。
adminStatusEnabled int8 = 1
)
// JWTAuth 管理员鉴权中间件:
// 解析 Bearer token → Redis 会话校验(失败 fail-open)→ 数据库状态回查(禁用即拒绝)→ AdminID 注入 context。
func JWTAuth() gin.HandlerFunc {
return func(c *gin.Context) {
response := app.NewResponse(c)
// 1. 从 Authorization 头取 token:Bearer xxx.yyy.zzz
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
response.ToErrorResponse(errcode.UnauthorizedTokenMissing)
c.Abort()
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("invalid Authorization format"))
c.Abort()
return
}
// 2. 解析 token
claims, err := lzcwyjwt.ParseAccessToken(parts[1])
if err != nil {
switch {
case errors.Is(err, lzcwyjwt.ErrTokenExpired):
response.ToErrorResponse(errcode.UnauthorizedTokenTimeout)
case errors.Is(err, lzcwyjwt.ErrTokenWrongType):
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("token type mismatch"))
default:
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails(err.Error()))
}
c.Abort()
return
}
// 2.5 校验 access token 是否仍在会话中(登出/失效后立即拒绝)。
sessionID, serr := redisdao.AuthAdminIDByAccess(c.Request.Context(), parts[1])
if serr != nil {
if errors.Is(serr, redis.Nil) {
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("token session invalid"))
c.Abort()
return
}
// Redis 其它错误降级放行(fail-open):优先保证可用性;
// 被禁用/删除的管理员仍会被下方 DB 回查拒绝,不会因本降级放行而绕过。
slog.WarnContext(c.Request.Context(), "check admin token session failed, allow by fallback", "error", serr)
} else if sessionID != claims.AdminID {
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("token user mismatch"))
c.Abort()
return
}
// 3. 数据库回查管理员状态(禁用/删除立即生效)。
admin, dbErr := mysqldao.NewAdminDao().AdminGetByID(c.Request.Context(), claims.AdminID)
if dbErr != nil {
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("管理员不存在"))
c.Abort()
return
}
if admin.Status != adminStatusEnabled {
response.ToErrorResponse(errcode.AdminDisabled)
c.Abort()
return
}
// 4. 把 adminID 注入 context
ctx := context.WithValue(c.Request.Context(), adminIDKey, claims.AdminID)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
// AdminSuperAuth 超级管理员权限校验(在 JWTAuth 之后使用)。
func AdminSuperAuth() gin.HandlerFunc {
return func(c *gin.Context) {
response := app.NewResponse(c)
adminID, ok := GetAdminID(c.Request.Context())
if !ok {
response.ToErrorResponse(errcode.Unauthorized)
c.Abort()
return
}
admin, err := mysqldao.NewAdminDao().AdminGetByID(c.Request.Context(), adminID)
if err != nil {
response.ToErrorResponse(errcode.AdminNotFound)
c.Abort()
return
}
if admin.Role != adminRoleSuper {
response.ToErrorResponse(errcode.Forbidden.WithDetails("仅超级管理员可操作"))
c.Abort()
return
}
c.Next()
}
}
// GetAdminID 从 context 取登录管理员 ID(在 handler 里用)。
func GetAdminID(ctx context.Context) (int64, bool) {
uid, ok := ctx.Value(adminIDKey).(int64)
return uid, ok
}
+55
View File
@@ -0,0 +1,55 @@
package middleware
import (
"log/slog"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// sanitizeQuery 对 query 中的敏感参数值脱敏,避免日志泄露 token/code/密钥等。
func sanitizeQuery(query string) string {
if query == "" {
return ""
}
values, err := url.ParseQuery(query)
if err != nil {
return "***"
}
sensitive := map[string]bool{
"token": true, "code": true, "secret": true, "password": true,
"access_token": true, "refresh_token": true, "key": true,
}
for k := range values {
if sensitive[strings.ToLower(k)] {
values.Set(k, "***")
}
}
return values.Encode()
}
func GinSlogMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := sanitizeQuery(c.Request.URL.RawQuery)
// 处理请求
c.Next()
// 请求结束后打日志
slog.InfoContext(c.Request.Context(), "GIN_REQUEST",
"request_id", GetRequestID(c.Request.Context()),
"method", c.Request.Method,
"path", path,
"query", query,
"status", c.Writer.Status(),
"ip", c.ClientIP(),
"latency", time.Since(start).String(),
"user_agent", c.Request.UserAgent(),
)
}
}
+16
View File
@@ -0,0 +1,16 @@
package middleware
import (
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
)
// GinOTelMiddleware 为每个 HTTP 请求自动开启 server span。
func GinOTelMiddleware() gin.HandlerFunc {
name := "zt-closet-manage-backend"
if global.OtelSetting != nil && global.OtelSetting.ServiceName != "" {
name = global.OtelSetting.ServiceName
}
return otelgin.Middleware(name)
}
+37
View File
@@ -0,0 +1,37 @@
package middleware
import (
"context"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
const requestIDKey ctxKey = "requestID"
// RequestID 从请求头取 X-Request-ID,没有则生成 UUID。
// 注入 context 并写入响应头,便于全链路追踪。
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
rid := c.GetHeader("X-Request-ID")
if rid == "" {
rid = uuid.New().String()
}
// 写入 gin context(供 handler 取用)
c.Set("requestID", rid)
// 写入 request context(供 service/dao 的 slog 取用)
ctx := context.WithValue(c.Request.Context(), requestIDKey, rid)
c.Request = c.Request.WithContext(ctx)
// 响应头
c.Header("X-Request-ID", rid)
c.Next()
}
}
// GetRequestID 从 context 取出 request-id。
func GetRequestID(ctx context.Context) string {
rid, _ := ctx.Value(requestIDKey).(string)
return rid
}
+103
View File
@@ -0,0 +1,103 @@
package mysqldao
import (
"context"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
"gorm.io/gorm"
)
// AdminDao 管理员账号数据访问
type AdminDao struct {
db *gorm.DB
}
// NewAdminDao 创建管理员 DAO
func NewAdminDao() *AdminDao {
return &AdminDao{db: global.MysqlEngine}
}
// AdminGetByID 按主键 ID 查询管理员
func (d *AdminDao) AdminGetByID(ctx context.Context, id int64) (*mysqlmodel.AdminUser, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.AdminGetByID")
defer span.End()
var admin mysqlmodel.AdminUser
if err := d.db.WithContext(ctx).First(&admin, id).Error; err != nil {
span.RecordError(err)
return nil, err
}
return &admin, nil
}
// AdminGetByUsername 按登录账号查询管理员(含软删除)
func (d *AdminDao) AdminGetByUsername(ctx context.Context, username string) (*mysqlmodel.AdminUser, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.AdminGetByUsername")
defer span.End()
var admin mysqlmodel.AdminUser
if err := d.db.WithContext(ctx).Where("username = ?", username).First(&admin).Error; err != nil {
span.RecordError(err)
return nil, err
}
return &admin, nil
}
// AdminList 分页查询管理员列表(status 为 0 不筛选)
func (d *AdminDao) AdminList(ctx context.Context, status int8, page, pageSize int) ([]mysqlmodel.AdminUser, int64, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.AdminList")
defer span.End()
var list []mysqlmodel.AdminUser
var total int64
q := d.db.WithContext(ctx).Model(&mysqlmodel.AdminUser{})
if status != 0 {
q = q.Where("status = ?", status)
}
if err := q.Count(&total).Error; err != nil {
span.RecordError(err)
return nil, 0, err
}
if err := q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&list).Error; err != nil {
span.RecordError(err)
return nil, 0, err
}
return list, total, nil
}
// AdminCreate 创建管理员账号
func (d *AdminDao) AdminCreate(ctx context.Context, admin *mysqlmodel.AdminUser) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.AdminCreate")
defer span.End()
if err := d.db.WithContext(ctx).Create(admin).Error; err != nil {
span.RecordError(err)
return err
}
return nil
}
// AdminUpdate 按主键更新管理员字段
func (d *AdminDao) AdminUpdate(ctx context.Context, id int64, updates map[string]any) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.AdminUpdate")
defer span.End()
if err := d.db.WithContext(ctx).Model(&mysqlmodel.AdminUser{}).Where("id = ?", id).Updates(updates).Error; err != nil {
span.RecordError(err)
return err
}
return nil
}
// AdminSoftDelete 软删除管理员账号
func (d *AdminDao) AdminSoftDelete(ctx context.Context, id int64) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.AdminSoftDelete")
defer span.End()
if err := d.db.WithContext(ctx).Where("id = ?", id).Delete(&mysqlmodel.AdminUser{}).Error; err != nil {
span.RecordError(err)
return err
}
return nil
}
+79
View File
@@ -0,0 +1,79 @@
package mysqldao
import (
"context"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
"gorm.io/gorm"
)
// ClosetShareDao 衣柜分享数据访问
type ClosetShareDao struct {
db *gorm.DB
}
// NewClosetShareDao 创建衣柜分享 DAO
func NewClosetShareDao() *ClosetShareDao {
return &ClosetShareDao{db: global.MysqlEngine}
}
// ClosetShareListParams 分享分页列表筛选参数。
type ClosetShareListParams struct {
UserID int64 // 用户 ID,0 表示全部
Status int8 // 分享状态,0 表示全部
Page int
Size int
}
// ClosetShareList 分页查询衣柜分享
func (d *ClosetShareDao) ClosetShareList(ctx context.Context, p ClosetShareListParams) ([]mysqlmodel.ClosetShare, int64, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareList")
defer span.End()
var list []mysqlmodel.ClosetShare
var total int64
q := d.db.WithContext(ctx).Model(&mysqlmodel.ClosetShare{})
if p.UserID != 0 {
q = q.Where("user_id = ?", p.UserID)
}
if p.Status != 0 {
q = q.Where("status = ?", p.Status)
}
if err := q.Count(&total).Error; err != nil {
span.RecordError(err)
return nil, 0, err
}
if err := q.Offset((p.Page - 1) * p.Size).Limit(p.Size).Order("id DESC").Find(&list).Error; err != nil {
span.RecordError(err)
return nil, 0, err
}
return list, total, nil
}
// ClosetShareGetByUUID 按分享 UUID 查询
func (d *ClosetShareDao) ClosetShareGetByUUID(ctx context.Context, shareUUID string) (*mysqlmodel.ClosetShare, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareGetByUUID")
defer span.End()
var share mysqlmodel.ClosetShare
if err := d.db.WithContext(ctx).Where("share_uuid = ?", shareUUID).First(&share).Error; err != nil {
span.RecordError(err)
return nil, err
}
return &share, nil
}
// ClosetShareUpdateStatus 更新分享状态(封禁/解封)
func (d *ClosetShareDao) ClosetShareUpdateStatus(ctx context.Context, shareUUID string, status int8) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareUpdateStatus")
defer span.End()
if err := d.db.WithContext(ctx).Model(&mysqlmodel.ClosetShare{}).
Where("share_uuid = ?", shareUUID).
UpdateColumn("status", status).Error; err != nil {
span.RecordError(err)
return err
}
return nil
}
+89
View File
@@ -0,0 +1,89 @@
package mysqldao
import (
"context"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
"gorm.io/gorm"
)
// ClothesDao 衣物数据访问
type ClothesDao struct {
db *gorm.DB
}
// NewClothesDao 创建衣物 DAO
func NewClothesDao() *ClothesDao {
return &ClothesDao{db: global.MysqlEngine}
}
// ClothesListParams 衣物分页列表筛选参数。
type ClothesListParams struct {
UserID int64 // 用户 ID,0 表示全部
ImageProcessStatus int8 // AI 处理状态,0 表示全部
CategoryMain string // 大类筛选
Keyword string // 模糊匹配名称
Page int
Size int
}
// ClothesList 分页查询衣物
func (d *ClothesDao) ClothesList(ctx context.Context, p ClothesListParams) ([]mysqlmodel.Clothes, int64, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesList")
defer span.End()
var list []mysqlmodel.Clothes
var total int64
q := d.db.WithContext(ctx).Model(&mysqlmodel.Clothes{})
if p.UserID != 0 {
q = q.Where("user_id = ?", p.UserID)
}
if p.ImageProcessStatus != 0 {
q = q.Where("image_process_status = ?", p.ImageProcessStatus)
}
if p.CategoryMain != "" {
q = q.Where("category_main = ?", p.CategoryMain)
}
if p.Keyword != "" {
q = q.Where("name LIKE ?", "%"+p.Keyword+"%")
}
if err := q.Count(&total).Error; err != nil {
span.RecordError(err)
return nil, 0, err
}
if err := q.Offset((p.Page - 1) * p.Size).Limit(p.Size).Order("id DESC").Find(&list).Error; err != nil {
span.RecordError(err)
return nil, 0, err
}
return list, total, nil
}
// ClothesGetByUUID 按衣物 UUID 查询
func (d *ClothesDao) ClothesGetByUUID(ctx context.Context, clothesUUID string) (*mysqlmodel.Clothes, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesGetByUUID")
defer span.End()
var clothes mysqlmodel.Clothes
if err := d.db.WithContext(ctx).Where("clothes_uuid = ?", clothesUUID).First(&clothes).Error; err != nil {
span.RecordError(err)
return nil, err
}
return &clothes, nil
}
// ClothesListByIDs 按主键 ID 批量查询衣物(穿搭详情聚合用)
func (d *ClothesDao) ClothesListByIDs(ctx context.Context, ids []int64) ([]mysqlmodel.Clothes, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesListByIDs")
defer span.End()
if len(ids) == 0 {
return []mysqlmodel.Clothes{}, nil
}
var list []mysqlmodel.Clothes
if err := d.db.WithContext(ctx).Where("id IN ?", ids).Find(&list).Error; err != nil {
span.RecordError(err)
return nil, err
}
return list, nil
}
+10
View File
@@ -0,0 +1,10 @@
package mysqldao
import (
"github.com/google/uuid"
)
// genFlowUUID 生成流水 UUID(人工调整流水 order_no 兜底用)。
func genFlowUUID() string {
return uuid.NewString()
}
+119
View File
@@ -0,0 +1,119 @@
package mysqldao
import (
"context"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
"gorm.io/gorm"
)
// GoodsDao 商品数据访问
type GoodsDao struct {
db *gorm.DB
}
// NewGoodsDao 创建商品 DAO
func NewGoodsDao() *GoodsDao {
return &GoodsDao{db: global.MysqlEngine}
}
// GoodsListParams 商品分页列表筛选参数。
type GoodsListParams struct {
GoodsType int8 // 商品类型筛选,0 表示全部
Status int8 // 状态筛选,0 表示全部(含下架)
Keyword string // 模糊匹配商品名称/编码
Page int
Size int
}
// GoodsList 分页查询商品(管理端含下架商品)
func (d *GoodsDao) GoodsList(ctx context.Context, p GoodsListParams) ([]mysqlmodel.Goods, int64, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.GoodsList")
defer span.End()
var list []mysqlmodel.Goods
var total int64
q := d.db.WithContext(ctx).Model(&mysqlmodel.Goods{})
if p.GoodsType != 0 {
q = q.Where("goods_type = ?", p.GoodsType)
}
if p.Status != 0 {
q = q.Where("status = ?", p.Status)
}
if p.Keyword != "" {
kw := "%" + p.Keyword + "%"
q = q.Where("goods_name LIKE ? OR goods_code LIKE ?", kw, kw)
}
if err := q.Count(&total).Error; err != nil {
span.RecordError(err)
return nil, 0, err
}
if err := q.Offset((p.Page - 1) * p.Size).Limit(p.Size).Order("id DESC").Find(&list).Error; err != nil {
span.RecordError(err)
return nil, 0, err
}
return list, total, nil
}
// GoodsGetByID 按主键 ID 查询商品
func (d *GoodsDao) GoodsGetByID(ctx context.Context, id int64) (*mysqlmodel.Goods, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.GoodsGetByID")
defer span.End()
var goods mysqlmodel.Goods
if err := d.db.WithContext(ctx).First(&goods, id).Error; err != nil {
span.RecordError(err)
return nil, err
}
return &goods, nil
}
// GoodsGetByCode 按商品编码查询商品(含软删,用于编码唯一性校验)
func (d *GoodsDao) GoodsGetByCode(ctx context.Context, code string) (*mysqlmodel.Goods, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.GoodsGetByCode")
defer span.End()
var goods mysqlmodel.Goods
if err := d.db.WithContext(ctx).Unscoped().Where("goods_code = ?", code).First(&goods).Error; err != nil {
span.RecordError(err)
return nil, err
}
return &goods, nil
}
// GoodsCreate 创建商品
func (d *GoodsDao) GoodsCreate(ctx context.Context, goods *mysqlmodel.Goods) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.GoodsCreate")
defer span.End()
if err := d.db.WithContext(ctx).Create(goods).Error; err != nil {
span.RecordError(err)
return err
}
return nil
}
// GoodsUpdate 按主键更新商品字段
func (d *GoodsDao) GoodsUpdate(ctx context.Context, id int64, updates map[string]any) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.GoodsUpdate")
defer span.End()
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Goods{}).Where("id = ?", id).Updates(updates).Error; err != nil {
span.RecordError(err)
return err
}
return nil
}
// GoodsSoftDelete 软删除商品
func (d *GoodsDao) GoodsSoftDelete(ctx context.Context, id int64) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.GoodsSoftDelete")
defer span.End()
if err := d.db.WithContext(ctx).Where("id = ?", id).Delete(&mysqlmodel.Goods{}).Error; err != nil {
span.RecordError(err)
return err
}
return nil
}
+34
View File
@@ -0,0 +1,34 @@
package mysqldao
import (
"context"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
"gorm.io/gorm"
)
// NotificationDao 通知数据访问
type NotificationDao struct {
db *gorm.DB
}
// NewNotificationDao 创建通知 DAO
func NewNotificationDao() *NotificationDao {
return &NotificationDao{db: global.MysqlEngine}
}
// NotificationBatchCreate 批量插入通知(单用户/分片广播共用)
func (d *NotificationDao) NotificationBatchCreate(ctx context.Context, notifications []mysqlmodel.UserNotification) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.NotificationBatchCreate")
defer span.End()
if len(notifications) == 0 {
return nil
}
if err := d.db.WithContext(ctx).CreateInBatches(notifications, 500).Error; err != nil {
span.RecordError(err)
return err
}
return nil
}
+82
View File
@@ -0,0 +1,82 @@
package mysqldao
import (
"context"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
"gorm.io/gorm"
)
// OrderDao 订单数据访问
type OrderDao struct {
db *gorm.DB
}
// NewOrderDao 创建订单 DAO
func NewOrderDao() *OrderDao {
return &OrderDao{db: global.MysqlEngine}
}
// OrderListParams 订单分页列表筛选参数。
type OrderListParams struct {
OrderNo string // 订单号(精确)
UserID int64 // 用户 ID,0 表示全部
Status int8 // 订单状态,0 表示全部
Page int
Size int
}
// OrderList 分页查询订单
func (d *OrderDao) OrderList(ctx context.Context, p OrderListParams) ([]mysqlmodel.Order, int64, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderList")
defer span.End()
var list []mysqlmodel.Order
var total int64
q := d.db.WithContext(ctx).Model(&mysqlmodel.Order{})
if p.OrderNo != "" {
q = q.Where("order_no = ?", p.OrderNo)
}
if p.UserID != 0 {
q = q.Where("user_id = ?", p.UserID)
}
if p.Status != 0 {
q = q.Where("order_status = ?", p.Status)
}
if err := q.Count(&total).Error; err != nil {
span.RecordError(err)
return nil, 0, err
}
if err := q.Offset((p.Page - 1) * p.Size).Limit(p.Size).Order("id DESC").Find(&list).Error; err != nil {
span.RecordError(err)
return nil, 0, err
}
return list, total, nil
}
// OrderGetByOrderNo 按订单号查询订单
func (d *OrderDao) OrderGetByOrderNo(ctx context.Context, orderNo string) (*mysqlmodel.Order, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderGetByOrderNo")
defer span.End()
var order mysqlmodel.Order
if err := d.db.WithContext(ctx).Where("order_no = ?", orderNo).First(&order).Error; err != nil {
span.RecordError(err)
return nil, err
}
return &order, nil
}
// OrderGetByID 按主键 ID 查询订单
func (d *OrderDao) OrderGetByID(ctx context.Context, id int64) (*mysqlmodel.Order, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderGetByID")
defer span.End()
var order mysqlmodel.Order
if err := d.db.WithContext(ctx).First(&order, id).Error; err != nil {
span.RecordError(err)
return nil, err
}
return &order, nil
}
+120
View File
@@ -0,0 +1,120 @@
package mysqldao
import (
"context"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
"gorm.io/gorm"
)
// OutfitDao 穿搭数据访问
type OutfitDao struct {
db *gorm.DB
}
// NewOutfitDao 创建穿搭 DAO
func NewOutfitDao() *OutfitDao {
return &OutfitDao{db: global.MysqlEngine}
}
// OutfitListParams 穿搭分页列表筛选参数。
type OutfitListParams struct {
UserID int64 // 用户 ID,0 表示全部
IsPublic int8 // 是否公开,0 表示全部
AuditStatus int8 // 审核状态,0 表示全部
Status int8 // 穿搭状态,0 表示全部
Keyword string // 模糊匹配穿搭名称
Page int
Size int
}
// OutfitList 分页查询穿搭(管理端全量视角)
func (d *OutfitDao) OutfitList(ctx context.Context, p OutfitListParams) ([]mysqlmodel.Outfit, int64, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitList")
defer span.End()
var list []mysqlmodel.Outfit
var total int64
q := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{})
if p.UserID != 0 {
q = q.Where("user_id = ?", p.UserID)
}
if p.IsPublic != 0 {
q = q.Where("is_public = ?", p.IsPublic)
}
if p.AuditStatus != 0 {
q = q.Where("audit_status = ?", p.AuditStatus)
}
if p.Status != 0 {
q = q.Where("status = ?", p.Status)
}
if p.Keyword != "" {
q = q.Where("outfit_name LIKE ?", "%"+p.Keyword+"%")
}
if err := q.Count(&total).Error; err != nil {
span.RecordError(err)
return nil, 0, err
}
if err := q.Offset((p.Page - 1) * p.Size).Limit(p.Size).Order("id DESC").Find(&list).Error; err != nil {
span.RecordError(err)
return nil, 0, err
}
return list, total, nil
}
// OutfitGetByUUID 按穿搭 UUID 查询
func (d *OutfitDao) OutfitGetByUUID(ctx context.Context, outfitUUID string) (*mysqlmodel.Outfit, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitGetByUUID")
defer span.End()
var outfit mysqlmodel.Outfit
if err := d.db.WithContext(ctx).Where("outfit_uuid = ?", outfitUUID).First(&outfit).Error; err != nil {
span.RecordError(err)
return nil, err
}
return &outfit, nil
}
// OutfitItemListByOutfitID 查询穿搭衣物明细
func (d *OutfitDao) OutfitItemListByOutfitID(ctx context.Context, outfitID int64) ([]mysqlmodel.OutfitItem, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitItemListByOutfitID")
defer span.End()
var items []mysqlmodel.OutfitItem
if err := d.db.WithContext(ctx).Where("outfit_id = ?", outfitID).Find(&items).Error; err != nil {
span.RecordError(err)
return nil, err
}
return items, nil
}
// OutfitUpdateByID 按主键更新穿搭字段
func (d *OutfitDao) OutfitUpdateByID(ctx context.Context, id int64, updates map[string]any) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitUpdateByID")
defer span.End()
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{}).Where("id = ?", id).Updates(updates).Error; err != nil {
span.RecordError(err)
return err
}
return nil
}
// OutfitAuditWithLog 事务内更新穿搭审核状态并写审核日志。
func (d *OutfitDao) OutfitAuditWithLog(ctx context.Context, outfitID int64, auditResult int8, rejectReason string, log *mysqlmodel.OutfitAuditLog) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitAuditWithLog")
defer span.End()
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&mysqlmodel.Outfit{}).
Where("id = ?", outfitID).
UpdateColumn("audit_status", auditResult).Error; err != nil {
return err
}
log.TargetID = outfitID
log.AuditResult = auditResult
log.RejectReason = rejectReason
return tx.Create(log).Error
})
}
+101
View File
@@ -0,0 +1,101 @@
package mysqldao
import (
"context"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
"gorm.io/gorm"
)
// QuotaGrantDao 额度凭证数据访问(有有效期赠送额度)。
type QuotaGrantDao struct {
db *gorm.DB
}
// NewQuotaGrantDao 创建额度凭证 DAO。
func NewQuotaGrantDao() *QuotaGrantDao {
return &QuotaGrantDao{db: global.MysqlEngine}
}
// QuotaGrantCreate 创建额度凭证(ref_id+quota_type 唯一索引兜底幂等,冲突返回 1062)。
func (d *QuotaGrantDao) QuotaGrantCreate(ctx context.Context, grant *mysqlmodel.QuotaGrant) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantCreate")
defer span.End()
if err := d.db.WithContext(ctx).Create(grant).Error; err != nil {
span.RecordError(err)
return err
}
return nil
}
// QuotaGrantListByUserID 查询用户全部凭证(未删除),按 id 倒序。
func (d *QuotaGrantDao) QuotaGrantListByUserID(ctx context.Context, userID int64) ([]mysqlmodel.QuotaGrant, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantListByUserID")
defer span.End()
var list []mysqlmodel.QuotaGrant
if err := d.db.WithContext(ctx).Where("user_id = ?", userID).Order("id DESC").Find(&list).Error; err != nil {
span.RecordError(err)
return nil, err
}
return list, nil
}
// QuotaGrantGetByID 按主键查询凭证(未删除)。
func (d *QuotaGrantDao) QuotaGrantGetByID(ctx context.Context, id int64) (*mysqlmodel.QuotaGrant, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantGetByID")
defer span.End()
var grant mysqlmodel.QuotaGrant
if err := d.db.WithContext(ctx).First(&grant, id).Error; err != nil {
span.RecordError(err)
return nil, err
}
return &grant, nil
}
// QuotaGrantSoftDelete 软删除凭证(按 id + user_id 归属校验)。
func (d *QuotaGrantDao) QuotaGrantSoftDelete(ctx context.Context, id, userID int64) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantSoftDelete")
defer span.End()
if err := d.db.WithContext(ctx).
Where("id = ? AND user_id = ?", id, userID).
Delete(&mysqlmodel.QuotaGrant{}).Error; err != nil {
span.RecordError(err)
return err
}
return nil
}
// QuotaGetByUserIDAndType 查询用户某类配额(Cos 永久额度的已用/底值)。
func (d *QuotaGrantDao) QuotaGetByUserIDAndType(ctx context.Context, userID int64, quotaType int8) (*mysqlmodel.Quota, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGetByUserIDAndType")
defer span.End()
var quota mysqlmodel.Quota
if err := d.db.WithContext(ctx).
Where("user_id = ? AND quota_type = ?", userID, quotaType).
First(&quota).Error; err != nil {
span.RecordError(err)
return nil, err
}
return &quota, nil
}
// UserDailyUsageGetByUserIDAndDate 查询用户某日使用计数(当日额度已用,Redis 异常时 MySQL 兜底)。
func (d *QuotaGrantDao) UserDailyUsageGetByUserIDAndDate(ctx context.Context, userID, statDate int64) (*mysqlmodel.UserDailyUsage, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserDailyUsageGetByUserIDAndDate")
defer span.End()
var usage mysqlmodel.UserDailyUsage
if err := d.db.WithContext(ctx).
Where("user_id = ? AND stat_date = ?", userID, statDate).
First(&usage).Error; err != nil {
span.RecordError(err)
return nil, err
}
return &usage, nil
}
+85
View File
@@ -0,0 +1,85 @@
package mysqldao
import (
"context"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
"gorm.io/gorm"
)
// RefundDao 退款单数据访问
type RefundDao struct {
db *gorm.DB
}
// NewRefundDao 创建退款 DAO
func NewRefundDao() *RefundDao {
return &RefundDao{db: global.MysqlEngine}
}
// RefundListParams 退款单分页列表筛选参数。
type RefundListParams struct {
Status int8 // 退款状态(WithStatus 为 true 时生效)
WithStatus bool // 是否按状态筛选(退款状态 0=待处理 是合法值,需显式区分「不过滤」)
OrderNo string // 订单号(精确)
Page int
Size int
}
// RefundList 分页查询退款单
func (d *RefundDao) RefundList(ctx context.Context, p RefundListParams) ([]mysqlmodel.RefundFlowLog, int64, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.RefundList")
defer span.End()
var list []mysqlmodel.RefundFlowLog
var total int64
q := d.db.WithContext(ctx).Model(&mysqlmodel.RefundFlowLog{})
if p.WithStatus {
q = q.Where("status = ?", p.Status)
}
if p.OrderNo != "" {
q = q.Where("order_no = ?", p.OrderNo)
}
if err := q.Count(&total).Error; err != nil {
span.RecordError(err)
return nil, 0, err
}
if err := q.Offset((p.Page - 1) * p.Size).Limit(p.Size).Order("id DESC").Find(&list).Error; err != nil {
span.RecordError(err)
return nil, 0, err
}
return list, total, nil
}
// RefundGetByRefundNo 按退款单号查询
func (d *RefundDao) RefundGetByRefundNo(ctx context.Context, refundNo string) (*mysqlmodel.RefundFlowLog, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.RefundGetByRefundNo")
defer span.End()
var refund mysqlmodel.RefundFlowLog
if err := d.db.WithContext(ctx).Where("refund_no = ?", refundNo).First(&refund).Error; err != nil {
span.RecordError(err)
return nil, err
}
return &refund, nil
}
// RefundTransition 条件更新退款状态(仅 from 状态可流转到 to,行锁防并发重复审核)。
func (d *RefundDao) RefundTransition(ctx context.Context, refundNo string, from, to int8, failReason string) (bool, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.RefundTransition")
defer span.End()
updates := map[string]any{"status": to}
if failReason != "" {
updates["fail_reason"] = failReason
}
res := d.db.WithContext(ctx).Model(&mysqlmodel.RefundFlowLog{}).
Where("refund_no = ? AND status = ?", refundNo, from).
Updates(updates)
if res.Error != nil {
span.RecordError(res.Error)
return false, res.Error
}
return res.RowsAffected > 0, nil
}
+518
View File
@@ -0,0 +1,518 @@
package mysqldao
import (
"context"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
"gorm.io/gorm"
)
// StatsDao 仪表盘聚合统计数据访问
type StatsDao struct {
db *gorm.DB
}
// NewStatsDao 创建统计 DAO
func NewStatsDao() *StatsDao {
return &StatsDao{db: global.MysqlEngine}
}
// UserStats 用户统计。
type UserStats struct {
Total int64 // 用户总数
TodayNew int64 // 今日新增
Normal int64 // 正常
TempBan int64 // 临时禁用
PermBan int64 // 永久禁用
Deactivated int64 // 已注销
}
// OrderStats 订单统计。
type OrderStats struct {
Total int64 // 订单总数
Pending int64 // 待支付
Paid int64 // 已支付
Cancelled int64 // 已取消
Expired int64 // 已过期
Refunded int64 // 已退款
}
// RevenueStats 营收统计(单位分)。
type RevenueStats struct {
TotalRevenue int64 // 累计营收(已支付+已退款订单实付金额)
TodayRevenue int64 // 今日营收(今日支付订单实付金额)
RefundTotal int64 // 累计退款金额
}
// TodoStats 待办统计。
type TodoStats struct {
RefundPending int64 // 待处理退款数
OutfitPendingAudit int64 // 待审核穿搭数
GoodsOnShelf int64 // 上架商品数
}
// UserStatsGet 聚合用户总数与今日新增
func (d *StatsDao) UserStatsGet(ctx context.Context, todayStart int64) (*UserStats, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserStatsGet")
defer span.End()
s := &UserStats{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).Count(&s.Total).Error; err != nil {
span.RecordError(err)
return nil, err
}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).Where("created_at >= ?", todayStart).Count(&s.TodayNew).Error; err != nil {
span.RecordError(err)
return nil, err
}
return s, nil
}
// UserStatsGetByStatus 按状态统计用户数(单次 group by)
func (d *StatsDao) UserStatsGetByStatus(ctx context.Context) (map[int8]int64, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserStatsGetByStatus")
defer span.End()
rows := []struct {
Status int8
Cnt int64
}{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).
Select("status, COUNT(*) AS cnt").
Group("status").
Scan(&rows).Error; err != nil {
span.RecordError(err)
return nil, err
}
m := make(map[int8]int64, len(rows))
for _, r := range rows {
m[r.Status] = r.Cnt
}
return m, nil
}
// OrderStatsGet 聚合订单状态统计(单次 group by)
func (d *StatsDao) OrderStatsGet(ctx context.Context) (*OrderStats, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderStatsGet")
defer span.End()
rows := []struct {
OrderStatus int8
Cnt int64
}{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}).
Select("order_status, COUNT(*) AS cnt").
Group("order_status").
Scan(&rows).Error; err != nil {
span.RecordError(err)
return nil, err
}
s := &OrderStats{}
for _, r := range rows {
s.Total += r.Cnt
switch r.OrderStatus {
case 1:
s.Pending = r.Cnt
case 2:
s.Paid = r.Cnt
case 3:
s.Cancelled = r.Cnt
case 4:
s.Expired = r.Cnt
case 5:
s.Refunded = r.Cnt
}
}
return s, nil
}
// RevenueStatsGet 聚合营收统计(已支付与已退款订单均计营收;今日按支付时间统计)
func (d *StatsDao) RevenueStatsGet(ctx context.Context, todayStart int64) (*RevenueStats, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.RevenueStatsGet")
defer span.End()
s := &RevenueStats{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}).
Where("order_status IN ?", []int8{2, 5}).
Select("COALESCE(SUM(pay_amount), 0)").
Scan(&s.TotalRevenue).Error; err != nil {
span.RecordError(err)
return nil, err
}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}).
Where("order_status IN ? AND pay_time >= ?", []int8{2, 5}, todayStart).
Select("COALESCE(SUM(pay_amount), 0)").
Scan(&s.TodayRevenue).Error; err != nil {
span.RecordError(err)
return nil, err
}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}).
Where("order_status = ?", int8(5)).
Select("COALESCE(SUM(refund_amount), 0)").
Scan(&s.RefundTotal).Error; err != nil {
span.RecordError(err)
return nil, err
}
return s, nil
}
// TodoStatsGet 聚合待办统计
func (d *StatsDao) TodoStatsGet(ctx context.Context) (*TodoStats, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.TodoStatsGet")
defer span.End()
s := &TodoStats{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.RefundFlowLog{}).
Where("status = ?", int8(0)).
Count(&s.RefundPending).Error; err != nil {
span.RecordError(err)
return nil, err
}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{}).
Where("is_public = ? AND audit_status = ?", int8(2), int8(1)).
Count(&s.OutfitPendingAudit).Error; err != nil {
span.RecordError(err)
return nil, err
}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Goods{}).
Where("status = ?", int8(1)).
Count(&s.GoodsOnShelf).Error; err != nil {
span.RecordError(err)
return nil, err
}
return s, nil
}
// countByInt8 通用分组计数行(小整型枚举 + 计数)。
type countByInt8 struct {
K int8
Cnt int64
}
// dateCount 按日分组计数行。
type dateCount struct {
D string
Cnt int64
}
// dateRevenue 按日分组营收行。
type dateRevenue struct {
D string
Cnt int64
Rev int64
}
// ContentStats 内容审核统计。
type ContentStats struct {
OutfitTotal int64
OutfitPending int64
OutfitPassed int64
OutfitRejected int64
OutfitBanned int64
ClothesTotal int64
ClothesProcessing int64
ClothesSuccess int64
ClothesFailed int64
ShareTotal int64
ShareNormal int64
ShareCancelled int64
ShareExpired int64
ShareBanned int64
}
// VIPStats 会员统计。
type VIPStats struct {
MemberTotal int64
Monthly int64
Yearly int64
Trial int64
ExpiringSoon int64
}
// GoodsTypeCount 商品类型数量。
type GoodsTypeCount struct {
Type int8
Count int64
}
// GoodsStats 商品统计。
type GoodsStats struct {
Total int64
OnShelf int64
OffShelf int64
ByType []GoodsTypeCount
}
// TrendStats 趋势原始聚合(按 YYYY-MM-DD 分组)。
type TrendStats struct {
NewUsers map[string]int64 // 新增用户
Orders map[string]int64 // 已支付订单量
Revenue map[string]int64 // 营收(分)
}
// ContentStatsGet 聚合内容审核概况:穿搭/衣物/衣柜分享。
func (d *StatsDao) ContentStatsGet(ctx context.Context) (*ContentStats, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.ContentStatsGet")
defer span.End()
s := &ContentStats{}
// 穿搭:audit_status 分布 + 封禁数
outfitRows := []countByInt8{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{}).
Select("audit_status AS k, COUNT(*) AS cnt").
Group("audit_status").
Scan(&outfitRows).Error; err != nil {
span.RecordError(err)
return nil, err
}
for _, r := range outfitRows {
s.OutfitTotal += r.Cnt
switch r.K {
case 1:
s.OutfitPending = r.Cnt
case 2:
s.OutfitPassed = r.Cnt
case 3:
s.OutfitRejected = r.Cnt
}
}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{}).
Where("status = ?", int8(2)).
Count(&s.OutfitBanned).Error; err != nil {
span.RecordError(err)
return nil, err
}
// 衣物:image_process_status 分布(1/2/3 处理中,4 成功,5 失败)
clothesRows := []countByInt8{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Clothes{}).
Select("image_process_status AS k, COUNT(*) AS cnt").
Group("image_process_status").
Scan(&clothesRows).Error; err != nil {
span.RecordError(err)
return nil, err
}
for _, r := range clothesRows {
s.ClothesTotal += r.Cnt
switch r.K {
case 1, 2, 3:
s.ClothesProcessing += r.Cnt
case 4:
s.ClothesSuccess = r.Cnt
case 5:
s.ClothesFailed = r.Cnt
}
}
// 衣柜分享:status 分布
shareRows := []countByInt8{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.ClosetShare{}).
Select("status AS k, COUNT(*) AS cnt").
Group("status").
Scan(&shareRows).Error; err != nil {
span.RecordError(err)
return nil, err
}
for _, r := range shareRows {
s.ShareTotal += r.Cnt
switch r.K {
case 1:
s.ShareNormal = r.Cnt
case 2:
s.ShareCancelled = r.Cnt
case 3:
s.ShareExpired = r.Cnt
case 4:
s.ShareBanned = r.Cnt
}
}
return s, nil
}
// VIPStatsGet 聚合会员概况(有效会员 + 即将到期)。
func (d *StatsDao) VIPStatsGet(ctx context.Context, now, soonThreshold int64) (*VIPStats, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.VIPStatsGet")
defer span.End()
s := &VIPStats{}
activeVIP := []int8{2, 3, 4}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).
Where("vip_type IN ? AND vip_expire > ?", activeVIP, now).
Count(&s.MemberTotal).Error; err != nil {
span.RecordError(err)
return nil, err
}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).
Where("vip_type = ? AND vip_expire > ?", int8(2), now).
Count(&s.Monthly).Error; err != nil {
span.RecordError(err)
return nil, err
}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).
Where("vip_type = ? AND vip_expire > ?", int8(3), now).
Count(&s.Yearly).Error; err != nil {
span.RecordError(err)
return nil, err
}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).
Where("vip_type = ? AND vip_expire > ?", int8(4), now).
Count(&s.Trial).Error; err != nil {
span.RecordError(err)
return nil, err
}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).
Where("vip_type IN ? AND vip_expire >= ? AND vip_expire <= ?", activeVIP, now, soonThreshold).
Count(&s.ExpiringSoon).Error; err != nil {
span.RecordError(err)
return nil, err
}
return s, nil
}
// GoodsStatsGet 聚合商品统计(上架/下架 + 类型分布)。
func (d *StatsDao) GoodsStatsGet(ctx context.Context) (*GoodsStats, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.GoodsStatsGet")
defer span.End()
s := &GoodsStats{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Goods{}).Count(&s.Total).Error; err != nil {
span.RecordError(err)
return nil, err
}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Goods{}).
Where("status = ?", int8(1)).
Count(&s.OnShelf).Error; err != nil {
span.RecordError(err)
return nil, err
}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Goods{}).
Where("status = ?", int8(2)).
Count(&s.OffShelf).Error; err != nil {
span.RecordError(err)
return nil, err
}
rows := []countByInt8{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Goods{}).
Select("goods_type AS k, COUNT(*) AS cnt").
Group("goods_type").
Scan(&rows).Error; err != nil {
span.RecordError(err)
return nil, err
}
for _, r := range rows {
s.ByType = append(s.ByType, GoodsTypeCount{Type: r.K, Count: r.Cnt})
}
return s, nil
}
// TrendStatsGet 聚合近 N 天趋势(新增用户/已支付订单量/营收,按天分组)。
func (d *StatsDao) TrendStatsGet(ctx context.Context, startTs int64) (*TrendStats, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.TrendStatsGet")
defer span.End()
s := &TrendStats{
NewUsers: map[string]int64{},
Orders: map[string]int64{},
Revenue: map[string]int64{},
}
// 新增用户:按创建时间
userRows := []dateCount{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).
Where("created_at >= ?", startTs).
Select("DATE_FORMAT(FROM_UNIXTIME(created_at), '%Y-%m-%d') AS d, COUNT(*) AS cnt").
Group("d").
Scan(&userRows).Error; err != nil {
span.RecordError(err)
return nil, err
}
for _, r := range userRows {
s.NewUsers[r.D] = r.Cnt
}
// 已支付/已退款订单量 + 营收:按支付时间
orderRows := []dateRevenue{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}).
Where("order_status IN ? AND pay_time >= ?", []int8{2, 5}, startTs).
Select("DATE_FORMAT(FROM_UNIXTIME(pay_time), '%Y-%m-%d') AS d, COUNT(*) AS cnt, COALESCE(SUM(pay_amount), 0) AS rev").
Group("d").
Scan(&orderRows).Error; err != nil {
span.RecordError(err)
return nil, err
}
for _, r := range orderRows {
s.Orders[r.D] = r.Cnt
s.Revenue[r.D] = r.Rev
}
return s, nil
}
// UsageStats 今日消耗统计。
type UsageStats struct {
AIRecommend int64 // 今日AI推荐消耗次数
ClosetShare int64 // 今日衣柜分享消耗次数
OutfitShare int64 // 今日社区分享消耗次数
}
// SocialStats 内容互动统计。
type SocialStats struct {
CosImageCount int64 // COS总图数
LikeCount int64 // 点赞数
CollectCount int64 // 收藏数
}
// UsageStatsGet 聚合今日消耗次数(AI推荐/衣柜分享/社区分享,按 stat_date 单次求和)。
func (d *StatsDao) UsageStatsGet(ctx context.Context, statDate int64) (*UsageStats, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.UsageStatsGet")
defer span.End()
s := &UsageStats{}
row := struct {
AIRecommend int64
ClosetShare int64
OutfitShare int64
}{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.UserDailyUsage{}).
Where("stat_date = ?", statDate).
Select("COALESCE(SUM(ai_recommend_count), 0) AS ai_recommend, COALESCE(SUM(closet_share_count), 0) AS closet_share, COALESCE(SUM(outfit_share_count), 0) AS outfit_share").
Scan(&row).Error; err != nil {
span.RecordError(err)
return nil, err
}
s.AIRecommend = row.AIRecommend
s.ClosetShare = row.ClosetShare
s.OutfitShare = row.OutfitShare
return s, nil
}
// SocialStatsGet 聚合内容互动统计(COS 总图数/点赞数/收藏数)。
func (d *StatsDao) SocialStatsGet(ctx context.Context) (*SocialStats, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.SocialStatsGet")
defer span.End()
s := &SocialStats{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Clothes{}).
Where("processed_image <> ''").
Count(&s.CosImageCount).Error; err != nil {
span.RecordError(err)
return nil, err
}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Like{}).
Where("status = ?", int8(1)).
Count(&s.LikeCount).Error; err != nil {
span.RecordError(err)
return nil, err
}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Collect{}).
Where("status = ?", int8(1)).
Count(&s.CollectCount).Error; err != nil {
span.RecordError(err)
return nil, err
}
return s, nil
}
+144
View File
@@ -0,0 +1,144 @@
package mysqldao
import (
"context"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
"gorm.io/gorm"
)
// UserDao 用户数据访问
type UserDao struct {
db *gorm.DB
}
// NewUserDao 创建用户 DAO
func NewUserDao() *UserDao {
return &UserDao{db: global.MysqlEngine}
}
// UserListParams 用户分页列表筛选参数。
type UserListParams struct {
Keyword string // 模糊匹配昵称/手机号/OpenID(任一命中)
Status int8 // 状态筛选,0 表示全部
Page int
Size int
}
// UserList 分页查询用户(关键词 + 状态筛选)
func (d *UserDao) UserList(ctx context.Context, p UserListParams) ([]mysqlmodel.User, int64, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserList")
defer span.End()
var list []mysqlmodel.User
var total int64
q := d.db.WithContext(ctx).Model(&mysqlmodel.User{})
if p.Keyword != "" {
kw := "%" + p.Keyword + "%"
q = q.Where("username LIKE ? OR phone LIKE ? OR wx_open_id LIKE ?", kw, kw, kw)
}
if p.Status != 0 {
q = q.Where("status = ?", p.Status)
}
if err := q.Count(&total).Error; err != nil {
span.RecordError(err)
return nil, 0, err
}
if err := q.Offset((p.Page - 1) * p.Size).Limit(p.Size).Order("id DESC").Find(&list).Error; err != nil {
span.RecordError(err)
return nil, 0, err
}
return list, total, nil
}
// UserGetByID 按主键 ID 查询用户
func (d *UserDao) UserGetByID(ctx context.Context, userID int64) (*mysqlmodel.User, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserGetByID")
defer span.End()
var user mysqlmodel.User
if err := d.db.WithContext(ctx).First(&user, userID).Error; err != nil {
span.RecordError(err)
return nil, err
}
return &user, nil
}
// UserGetByUUID 按 UUID 查询用户
func (d *UserDao) UserGetByUUID(ctx context.Context, userUUID string) (*mysqlmodel.User, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserGetByUUID")
defer span.End()
var user mysqlmodel.User
if err := d.db.WithContext(ctx).Where("user_uuid = ?", userUUID).First(&user).Error; err != nil {
span.RecordError(err)
return nil, err
}
return &user, nil
}
// UserUpdateByID 按主键 ID 更新用户字段
func (d *UserDao) UserUpdateByID(ctx context.Context, userID int64, updates map[string]any) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserUpdateByID")
defer span.End()
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
span.RecordError(err)
return err
}
return nil
}
// QuotaListByUserID 查询用户的所有配额
func (d *UserDao) QuotaListByUserID(ctx context.Context, userID int64) ([]mysqlmodel.Quota, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaListByUserID")
defer span.End()
var quotas []mysqlmodel.Quota
if err := d.db.WithContext(ctx).Where("user_id = ?", userID).Find(&quotas).Error; err != nil {
span.RecordError(err)
return nil, err
}
return quotas, nil
}
// VIPAdjustWithFlow 事务内人工调整用户 VIP 并写流水。
func (d *UserDao) VIPAdjustWithFlow(ctx context.Context, user *mysqlmodel.User, flow *mysqlmodel.VIPFlowLog) error {
ctx, span := global.Tracer.Start(ctx, "mysqldao.VIPAdjustWithFlow")
defer span.End()
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&mysqlmodel.User{}).
Where("id = ?", user.ID).
Updates(map[string]any{
"vip_type": user.VIPType,
"vip_start": user.VIPStart,
"vip_expire": user.VIPExpire,
}).Error; err != nil {
return err
}
// 人工调整无订单号,用流水 UUID 填充 order_no 满足联合唯一索引。
if flow.OrderNo == "" {
flow.OrderNo = genFlowUUID()
}
return tx.Create(flow).Error
})
}
// UserIDListAll 分批遍历全部用户 ID(通知广播用)。
func (d *UserDao) UserIDListAll(ctx context.Context, lastID int64, limit int) ([]int64, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserIDListAll")
defer span.End()
var ids []int64
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).
Where("id > ?", lastID).
Order("id ASC").
Limit(limit).
Pluck("id", &ids).Error; err != nil {
span.RecordError(err)
return nil, err
}
return ids, nil
}
+18
View File
@@ -0,0 +1,18 @@
package mysqlmodel
// AdminUser 管理员账号(管理后台自建表,唯一不共享业务服务的表)。
// 字符串字段显式指定 type:varchar 长度:MySQL 无法对 TEXT/BLOB 列建普通唯一索引,
// 且 varchar 长度受 utf8mb4 索引 3072 字节上限约束。
type AdminUser struct {
Model
Username string `gorm:"column:username;type:varchar(32);uniqueIndex:uk_admin_username"` // 登录账号
PasswordHash string `gorm:"column:password_hash;type:varchar(100)"` // bcrypt 密码哈希(60 字符)
Nickname string `gorm:"column:nickname;type:varchar(32)"` // 显示名
Role int8 `gorm:"column:role"` // 角色 1=超级管理员 2=普通管理员
Status int8 `gorm:"column:status"` // 状态 1=启用 2=禁用
LastLoginTime int64 `gorm:"column:last_login_time"` // 最近登录时间
}
func (AdminUser) TableName() string {
return "admin_users"
}
+338
View File
@@ -0,0 +1,338 @@
package mysqlmodel
// 本文件为业务服务(zt-closet-backend)数据表模型的镜像定义。
// 管理后台与业务服务共用同一 MySQL 库,直接读写业务表;
// 字段与表名必须与业务服务 internal/mysqlmodel 保持一致。
// ===== 用户 =====
type User struct {
Model
UserUUID string `gorm:"column:user_uuid"` // 用户UUID
WxOpenID string `gorm:"column:wx_open_id;uniqueIndex:uk_user_wx_open_id"` // 微信openid
Username string `gorm:"column:username"` // 用户名
Avatar string `gorm:"column:avatar"` // 头像存储位置
Sex int8 `gorm:"column:sex"` // 性别 1=男 2=女
BirthDay int64 `gorm:"column:birth_day"` // 生日
Phone string `gorm:"column:phone"` // 手机号
IsNewUser bool `gorm:"column:is_new_user"` // 是否新用户
Status int8 `gorm:"column:status"` // 状态 1=正常 2=临时禁用 3=永久禁用 4=已注销
BanReason string `gorm:"column:ban_reason"` // 封禁原因
BanStart int64 `gorm:"column:ban_start"` // 封禁开始时间
BanExpire int64 `gorm:"column:ban_expire"` // 封禁到期时间
VIPType int8 `gorm:"column:vip_type"` // VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员
VIPStart int64 `gorm:"column:vip_start"` // VIP开始时间
VIPExpire int64 `gorm:"column:vip_expire"` // VIP到期时间
LastLoginTime int64 `gorm:"column:last_login_time"` // 最近登录时间
}
func (User) TableName() string {
return "users"
}
// ===== 商品 =====
type Goods struct {
Model
GoodsCode string `gorm:"column:goods_code"` // 商品编码(唯一)
GoodsName string `gorm:"column:goods_name"` // 商品名称
GoodsType int8 `gorm:"column:goods_type"` // 商品类型 1=会员包 2=上传包 3=AI推荐包 4=衣柜分享包 5=搭配分享包
Price int64 `gorm:"column:price"` // 商品原价(分)
DiscountPrice int64 `gorm:"column:discount_price"` // 商品售价(分)
MemberDays int64 `gorm:"column:member_days"` // 赠送会员天数
CosQuota int64 `gorm:"column:cos_quota"` // 赠送总上传额度
AIRecommendQuota int64 `gorm:"column:ai_recommend_quota"` // 赠送AI推荐额度
ClosetShareQuota int64 `gorm:"column:closet_share_quota"` // 赠送衣柜分享额度
OutfitShareQuota int64 `gorm:"column:outfit_share_quota"` // 赠送搭配分享额度
ValidDays int64 `gorm:"column:valid_days"` // 赠送额度有效期天数(加量包用;会员包为 0 表示跟随 VIP)
Description string `gorm:"column:description"` // 商品描述
Status int8 `gorm:"column:status"` // 状态 1=上架 2=下架
ProductID string `gorm:"column:product_id"` // 虚拟支付道具ID(空表示未发布)
ProductStatus int8 `gorm:"column:product_status"` // 虚拟支付道具发布状态 0=未发布 1=发布中 2=已发布 3=发布失败
}
func (Goods) TableName() string {
return "goods"
}
// ===== 订单 =====
type Order struct {
Model
// OrderNo 商户订单号;唯一索引保证订单号全局唯一。
OrderNo string `gorm:"column:order_no;uniqueIndex:uk_order_no"`
UserID int64 `gorm:"column:user_id"` // 下单用户ID
GoodsID int64 `gorm:"column:goods_id"` // 关联商品ID
GoodsName string `gorm:"column:goods_name"` // 商品名称快照
UnitPrice int64 `gorm:"column:unit_price"` // 商品单价(分)
TotalAmount int64 `gorm:"column:total_amount"` // 订单总金额(分)
PayAmount int64 `gorm:"column:pay_amount"` // 实付金额(分)
PayChannel int8 `gorm:"column:pay_channel"` // 支付渠道 1=微信支付 2=微信虚拟支付(XPay)
OrderStatus int8 `gorm:"column:order_status"` // 订单状态 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款
TransactionID string `gorm:"column:transaction_id"` // 微信支付流水号
MemberDays int64 `gorm:"column:member_days"` // 购买的会员天数(快照)
CosQuota int64 `gorm:"column:cos_quota"` // 赠送上传额度(快照)
AIRecommendQuota int64 `gorm:"column:ai_recommend_quota"` // 赠送AI推荐额度(快照)
ClosetShareQuota int64 `gorm:"column:closet_share_quota"` // 赠送衣柜分享额度(快照)
OutfitShareQuota int64 `gorm:"column:outfit_share_quota"` // 赠送搭配分享额度(快照)
ExpireTime int64 `gorm:"column:expire_time"` // 支付过期时间
PayTime int64 `gorm:"column:pay_time"` // 支付完成时间
RefundTime int64 `gorm:"column:refund_time"` // 退款时间
RefundAmount int64 `gorm:"column:refund_amount"` // 退款金额(分)
Remark string `gorm:"column:remark"` // 订单备注
CallbackRaw string `gorm:"column:callback_raw"` // 支付回调原始报文
XPayProductID string `gorm:"column:xpay_product_id"` // 虚拟支付道具ID(快照,pay_channel=2 时使用)
XPayPlatform string `gorm:"column:xpay_platform"` // 虚拟支付平台 android/ios/harmony/windows
XPayEnv int8 `gorm:"column:xpay_env"` // 虚拟支付环境 0=现网 1=沙箱
}
func (Order) TableName() string {
return "orders"
}
// ===== 退款 =====
type RefundFlowLog struct {
Model
OrderID int64 `gorm:"column:order_id"` // 关联订单ID
OrderNo string `gorm:"column:order_no"` // 商户订单号(冗余,方便查询)
UserID int64 `gorm:"column:user_id"` // 用户ID(冗余,方便按用户查)
// RefundNo 商户退款单号;唯一索引为退款申请防重/重试幂等依据。
RefundNo string `gorm:"column:refund_no;uniqueIndex:uk_refund_flow_no"`
WechatRefundNo string `gorm:"column:wechat_refund_no"` // 微信退款单号
RefundAmount int64 `gorm:"column:refund_amount"` // 本次退款金额(分)
RefundReason string `gorm:"column:refund_reason"` // 退款原因
Status int8 `gorm:"column:status"` // 退款状态 0=待处理 1=退款中 2=退款成功 3=退款失败
ApplyTime int64 `gorm:"column:apply_time"` // 申请时间
SuccessTime int64 `gorm:"column:success_time"` // 退款成功时间
FailReason string `gorm:"column:fail_reason"` // 失败原因
CallbackRaw string `gorm:"column:callback_raw"` // 微信退款回调原始报文
}
func (RefundFlowLog) TableName() string {
return "refund_flow_logs"
}
// ===== 穿搭 =====
type Outfit struct {
Model
OutfitUUID string `gorm:"column:outfit_uuid"` // 穿搭UUID
UserID int64 `gorm:"column:user_id"` // 所属用户ID
OutfitName string `gorm:"column:outfit_name"` // 穿搭名称
OutfitDesc string `gorm:"column:outfit_desc"` // 穿搭描述
Scene string `gorm:"column:scene"` // 适合场景
Source int8 `gorm:"column:source"` // 来源 1=用户创建 2=AI推荐
IsPublic int8 `gorm:"column:is_public"` // 是否公开 1=不公开 2=公开
AuditStatus int8 `gorm:"column:audit_status"` // 审核状态 1=待审核 2=审核通过 3=审核不通过
Status int8 `gorm:"column:status"` // 状态 1=正常 2=封禁 3=待用户保存
}
func (Outfit) TableName() string {
return "outfits"
}
type OutfitItem struct {
Model
OutfitID int64 `gorm:"column:outfit_id"` // 穿搭ID
ClothesID int64 `gorm:"column:clothes_id"` // 衣物ID
}
func (OutfitItem) TableName() string {
return "outfit_items"
}
type OutfitAuditLog struct {
Model
TargetID int64 `gorm:"column:target_id"` // 穿搭ID
AuditResult int8 `gorm:"column:audit_result"` // 审核结果 1=通过 2=驳回
RejectReason string `gorm:"column:reject_reason"` // 审核原因
AuditTime int64 `gorm:"column:audit_time"` // 审核时间
}
func (OutfitAuditLog) TableName() string {
return "outfit_audit_logs"
}
// ===== 衣物 =====
type Clothes struct {
Model
ClothesUUID string `gorm:"column:clothes_uuid"` // 衣物UUID
UserID int64 `gorm:"column:user_id"` // 所属用户ID
CategoryMain string `gorm:"column:category_main"` // 衣物大类 上装/下装/连衣裙/外套/鞋/配饰
CategorySub string `gorm:"column:category_sub"` // 衣物小类 T恤/牛仔裤/运动鞋/发带/戒指等
Style string `gorm:"column:style"` // 风格
ColorMain string `gorm:"column:color"` // 主颜色
ColorSub string `gorm:"column:color_sub"` // 副颜色
Thickness string `gorm:"column:thickness"` // 厚度
Version string `gorm:"column:version"` // 版型 宽松/修身/阔腿/紧身/常规合身
Material string `gorm:"column:material"` // 材质 牛仔布/针织/皮质/纯棉/羊毛等
Pattern string `gorm:"column:pattern"` // 花纹
SleeveType string `gorm:"column:sleeve_type"` // 袖型
ClothesType string `gorm:"column:clothes_type"` // 衣长类型 短款/常规/中长/长款
NecklineType string `gorm:"column:neckline_type"` // 领型
Formality string `gorm:"column:formality"` // 正式程度 1-5
Occasions string `gorm:"column:occasions"` // 适用场合
VisualImpact string `gorm:"column:visual_impact"` // 视觉冲击力 1-5
Name string `gorm:"column:name"` // 名称
Remark string `gorm:"column:remark"` // 备注
ProcessedImage string `gorm:"column:processed_image"` // 处理后图片存储位置 相对路径
ImageSize int64 `gorm:"column:image_size"` // 图片大小 单位:kb
ImageProcessStatus int8 `gorm:"column:image_process_status"` // 处理状态 1=抠图中 2=分类中 3=排队中 4=成功 5=失败
FailReason string `gorm:"column:fail_reason"` // 失败原因
}
func (Clothes) TableName() string {
return "clothes"
}
// ===== 衣柜分享 =====
type ClosetShare struct {
Model
ShareUUID string `gorm:"column:share_uuid"` // 分享UUID
UserID int64 `gorm:"column:user_id"` // 所属用户ID
ShareType int8 `gorm:"column:share_type"` // 分享类型 1=部分衣物分享 2=全量快照分享
ExpireTime int64 `gorm:"column:expire_time"` // 过期时间
Status int8 `gorm:"column:status"` // 分享状态 1=正常 2=用户取消 3=已过期 4=管理员封禁
}
func (ClosetShare) TableName() string {
return "closet_shares"
}
// ===== 额度 =====
type Quota struct {
Model
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_user_quota,priority:1"` // 用户ID
QuotaType int8 `gorm:"column:quota_type;uniqueIndex:uk_user_quota,priority:2"` // 1=cos 2=AI 3=社区分享 4=衣柜分享
Total int64 `gorm:"column:total"` // 免费额度底值(仅 Cos 永久额度使用;AI/社区/衣柜 每日额度不再落库)
Used int64 `gorm:"column:used"` // 免费额度已用(仅 Cos 使用,永久累计)
}
func (Quota) TableName() string {
return "user_quotas"
}
// QuotaGrant 有有效期额度凭证(会员包/加量包/广告激励)。
// AI/社区/衣柜 每日额度场景 Amount 表示「有效期内每天额外赠送次数」,Used 恒为 0;
// Cos 永久额度场景 Amount/Used 表示「一次性赠送库存及其已用」。
type QuotaGrant struct {
Model
UserID int64 `gorm:"column:user_id;index:idx_grant_user_type_expire,priority:1"` // 用户ID
QuotaType int8 `gorm:"column:quota_type;index:idx_grant_user_type_expire,priority:2;uniqueIndex:uk_grant_ref_type,priority:2"` // 额度类型 1=cos 2=AI 3=社区分享 4=衣柜分享
Amount int64 `gorm:"column:amount"` // 赠送总量
Used int64 `gorm:"column:used"` // 已用量(仅 Cos 扣减)
Source int8 `gorm:"column:source"` // 来源 1=会员包 2=加量包 3=广告激励
ExpireTime int64 `gorm:"column:expire_time;index:idx_grant_user_type_expire,priority:3"` // 到期时间(秒,0 表示跟随 VIP)
RefID string `gorm:"column:ref_id;type:varchar(64);uniqueIndex:uk_grant_ref_type,priority:1"` // 关联ID(订单号/广告记录ID)
}
func (QuotaGrant) TableName() string {
return "quota_grants"
}
// UserDailyUsage 用户每日使用计数。
type UserDailyUsage struct {
Model
// UserID 用户ID;与 StatDate 组成联合唯一索引,保证每日每用户仅一行。
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_daily_usage_user_date,priority:1"`
StatDate int64 `gorm:"column:stat_date;uniqueIndex:uk_daily_usage_user_date,priority:2"` // 统计日期(YYYYMMDD)
AIRecommendCount int64 `gorm:"column:ai_recommend_count"` // 当日AI推荐次数
OutfitShareCount int64 `gorm:"column:outfit_share_count"` // 当日社区分享次数
ClosetShareCount int64 `gorm:"column:closet_share_count"` // 当日衣柜分享次数
}
func (UserDailyUsage) TableName() string {
return "user_daily_usages"
}
type QuotaFlowLog struct {
Model
QuotaFlowUUID string `gorm:"column:quota_flow_uuid"` // 配额流水UUID
UserID int64 `gorm:"column:user_id"` // 用户ID
ChangeTarget int8 `gorm:"column:change_target"` // 调整额度类型 1=配额调整 2=使用
// ChangeType 变更类型 1=购买获赠 2=活动赠送 3=上传消耗 4=删除回退 5=人工调整 6=退款回收
ChangeType int8 `gorm:"column:change_type;uniqueIndex:uk_quota_flow_idem,priority:3"`
ChangeNum int64 `gorm:"column:change_num"` // 变更数量(正数增加,负数减少)
OldValue int64 `gorm:"column:old_value"` // 变更前数值
NewValue int64 `gorm:"column:new_value"` // 变更后数值
// OrderNo 关联订单号(购买/退款类);人工调整类由 DAO 写入流水 UUID 保证联合唯一索引不冲突。
OrderNo string `gorm:"column:order_no;uniqueIndex:uk_quota_flow_idem,priority:1"`
QuotaType int8 `gorm:"column:quota_type;uniqueIndex:uk_quota_flow_idem,priority:2"` // 关联业务类型 1=cos 2=AI 3=社区分享 4=衣柜分享
RelatedID int64 `gorm:"column:related_id"` // 关联业务ID
Reason string `gorm:"column:reason"` // 变更原因/备注
}
func (QuotaFlowLog) TableName() string {
return "quota_flow_logs"
}
// ===== VIP 流水 =====
type VIPFlowLog struct {
Model
UserID int64 `gorm:"column:user_id"` // 用户ID
// ChangeType 变更类型 1=开通 2=续费 3=活动赠送 4=到期失效 5=人工调整 6=退款回收
ChangeType int8 `gorm:"column:change_type;uniqueIndex:uk_vip_flow_idem,priority:2"`
OldVIPType int8 `gorm:"column:old_vip_type"` // 变更前VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员
NewVIPType int8 `gorm:"column:new_vip_type"` // 变更后VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员
OldVIPExpire int64 `gorm:"column:old_vip_expire"` // 变更前到期时间(秒)
NewVIPExpire int64 `gorm:"column:new_vip_expire"` // 变更后到期时间(秒)
// OrderNo 关联订单号(购买类变更);人工调整类由 DAO 写入流水 UUID 满足联合唯一索引。
OrderNo string `gorm:"column:order_no;uniqueIndex:uk_vip_flow_idem,priority:1"`
Source int8 `gorm:"column:source"` // 变更来源 1=个人支付 2=活动赠送 3=系统 4=人工调整
Reason string `gorm:"column:reason"` // 变更原因/备注
}
func (VIPFlowLog) TableName() string {
return "vip_flow_logs"
}
// ===== 通知 =====
type UserNotification struct {
Model
NotificationUUID string `gorm:"column:notification_uuid"` // 消息UUID
UserID int64 `gorm:"column:user_id"` // 接收用户ID
MsgType int8 `gorm:"column:msg_type"` // 消息类型 1=点赞通知 2=订单通知 3=系统通知
SenderID int64 `gorm:"column:sender_id"` // 发送者ID 系统消息为0
Title string `gorm:"column:title"` // 消息标题
Content string `gorm:"column:content"` // 消息内容
IsRead int8 `gorm:"column:is_read"` // 是否已读 1=未读 2=已读
ReadTime int64 `gorm:"column:read_time"` // 阅读时间
}
func (UserNotification) TableName() string {
return "user_notifications"
}
// ===== 点赞/收藏 =====
type Like struct {
Model
// LikeType 业务类型 1=社区穿搭;与 TargetID、UserID 组成联合唯一索引,支撑点赞/取消点赞 upsert。
LikeType int8 `gorm:"column:like_type;uniqueIndex:uk_like_user_target,priority:1"`
TargetID int64 `gorm:"column:target_id;uniqueIndex:uk_like_user_target,priority:2"` // 穿搭ID
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_like_user_target,priority:3"` // 点赞用户ID
Status int8 `gorm:"column:status"` // 状态 1=已点赞 0=已取消
}
func (Like) TableName() string {
return "likes"
}
type Collect struct {
Model
// CollectType 业务类型 1=社区穿搭;与 TargetID、UserID 组成联合唯一索引,支撑收藏/取消收藏 upsert。
CollectType int8 `gorm:"column:collect_type;uniqueIndex:uk_collect_user_target,priority:1"`
TargetID int64 `gorm:"column:target_id;uniqueIndex:uk_collect_user_target,priority:2"` // 业务目标ID
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_collect_user_target,priority:3"` // 收藏用户ID
Status int8 `gorm:"column:status"` // 状态 1=已收藏 0=已取消
}
func (Collect) TableName() string {
return "collects"
}
+60
View File
@@ -0,0 +1,60 @@
package mysqlmodel
import (
"context"
"fmt"
"time"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/setting"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/plugin/soft_delete"
)
type Model struct {
ID int64 `gorm:"column:id;primaryKey"` // 主键ID
CreatedAt int64 `gorm:"column:created_at"` // 创建时间
UpdatedAt int64 `gorm:"column:updated_at"` // 更新时间
DeletedAt soft_delete.DeletedAt `gorm:"column:deleted_at"` // 删除时间
CreatedBy int64 `gorm:"column:created_by"` // 创建人
UpdatedBy int64 `gorm:"column:updated_by"` // 更新人
DeletedBy int64 `gorm:"column:deleted_by"` // 删除人
}
// NewMysqlEngine 根据配置创建 GORM MySQL 连接并完成连接池初始化。
func NewMysqlEngine(mysqlSetting *setting.MysqlSettingS) (*gorm.DB, error) {
s := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=10s&readTimeout=10s&writeTimeout=10s",
mysqlSetting.UserName,
mysqlSetting.Password,
mysqlSetting.Host,
mysqlSetting.Port,
mysqlSetting.DBName,
)
db, err := gorm.Open(mysql.Open(s), &gorm.Config{})
if err != nil {
return nil, err
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxIdleConns(mysqlSetting.MaxIdleConns)
sqlDB.SetMaxOpenConns(mysqlSetting.MaxOpenConns)
sqlDB.SetConnMaxLifetime(time.Duration(mysqlSetting.ConnMaxLifeTime) * time.Second)
sqlDB.SetConnMaxIdleTime(time.Duration(mysqlSetting.ConnMaxIdleTime) * time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := sqlDB.PingContext(ctx); err != nil {
return nil, err
}
return db, nil
}
// AutoMigrateAdminTables 仅迁移管理后台自建的表(admin_users)。
// 业务表结构由业务服务的 golang-migrate 维护,管理后台不做 AutoMigrate,避免误改业务表。
func AutoMigrateAdminTables(db *gorm.DB) error {
return db.AutoMigrate(&AdminUser{})
}
+119
View File
@@ -0,0 +1,119 @@
package redisdao
import (
"context"
"fmt"
"time"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/redismodel"
"github.com/redis/go-redis/v9"
)
// authTokenSAddScript 向管理员 token 集合加入 refresh_token,仅在集合由空变为非空时设置 TTL,
// 避免每次登录都刷新集合 TTL 导致旧 token 条目长期驻留。
const authTokenSAddScript = `
local added = redis.call('SADD', KEYS[1], ARGV[1])
if added == 1 and redis.call('SCARD', KEYS[1]) == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
return added
`
// AuthTokenSet 登录成功后写入会话:
// - access_token -> admin_id
// - refresh_token -> admin_id
// - admin_id 的 token 集合加入 refresh_token(用于多端管理 / 登出)
func AuthTokenSet(ctx context.Context, adminID int64, accessToken, refreshToken string) error {
ctx, span := global.Tracer.Start(ctx, "redisdao.AuthTokenSet")
defer span.End()
accessKey := fmt.Sprintf(redismodel.KeyAuthAccess, accessToken)
refreshKey := fmt.Sprintf(redismodel.KeyAuthRefresh, refreshToken)
adminTokensKey := fmt.Sprintf(redismodel.KeyAuthUserTokens, adminID)
accessTTL := time.Duration(global.JWTSetting.AccessTokenExpire) * time.Second
refreshTTL := time.Duration(global.JWTSetting.RefreshTokenExpire) * time.Second
pipe := global.RedisEngine.TxPipeline()
pipe.Set(ctx, accessKey, adminID, accessTTL)
pipe.Set(ctx, refreshKey, adminID, refreshTTL)
pipe.Eval(ctx, authTokenSAddScript, []string{adminTokensKey}, refreshToken, int(refreshTTL.Seconds()))
if _, err := pipe.Exec(ctx); err != nil {
span.RecordError(err)
return err
}
return nil
}
// AuthTokenDel 登出/失效:删除 access、refresh 映射,并从管理员 token 集合移除 refresh_token
func AuthTokenDel(ctx context.Context, adminID int64, accessToken, refreshToken string) error {
ctx, span := global.Tracer.Start(ctx, "redisdao.AuthTokenDel")
defer span.End()
accessKey := fmt.Sprintf(redismodel.KeyAuthAccess, accessToken)
refreshKey := fmt.Sprintf(redismodel.KeyAuthRefresh, refreshToken)
adminTokensKey := fmt.Sprintf(redismodel.KeyAuthUserTokens, adminID)
pipe := global.RedisEngine.TxPipeline()
pipe.Del(ctx, accessKey)
pipe.Del(ctx, refreshKey)
pipe.SRem(ctx, adminTokensKey, refreshToken)
if _, err := pipe.Exec(ctx); err != nil {
span.RecordError(err)
return err
}
return nil
}
// AuthAdminIDByAccess 通过 access token 查询 admin_id,token 不存在或已过期返回 redis.Nil
func AuthAdminIDByAccess(ctx context.Context, accessToken string) (int64, error) {
ctx, span := global.Tracer.Start(ctx, "redisdao.AuthAdminIDByAccess")
defer span.End()
key := fmt.Sprintf(redismodel.KeyAuthAccess, accessToken)
adminID, err := global.RedisEngine.Get(ctx, key).Int64()
if err != nil && err != redis.Nil {
span.RecordError(err)
}
return adminID, err
}
// AuthAdminIDByRefresh 通过 refresh token 查询 admin_id,token 不存在或已过期返回 redis.Nil
func AuthAdminIDByRefresh(ctx context.Context, refreshToken string) (int64, error) {
ctx, span := global.Tracer.Start(ctx, "redisdao.AuthAdminIDByRefresh")
defer span.End()
key := fmt.Sprintf(redismodel.KeyAuthRefresh, refreshToken)
adminID, err := global.RedisEngine.Get(ctx, key).Int64()
if err != nil && err != redis.Nil {
span.RecordError(err)
}
return adminID, err
}
// AuthAdminTokensDelAll 失效某管理员全部 token(禁用/删除账号时调用)。
func AuthAdminTokensDelAll(ctx context.Context, adminID int64) error {
ctx, span := global.Tracer.Start(ctx, "redisdao.AuthAdminTokensDelAll")
defer span.End()
adminTokensKey := fmt.Sprintf(redismodel.KeyAuthUserTokens, adminID)
members, err := global.RedisEngine.SMembers(ctx, adminTokensKey).Result()
if err != nil && err != redis.Nil {
span.RecordError(err)
return err
}
pipe := global.RedisEngine.TxPipeline()
for _, refreshToken := range members {
pipe.Del(ctx, fmt.Sprintf(redismodel.KeyAuthRefresh, refreshToken))
}
// access token 没有全集索引(TTL 短,最多 AccessTokenExpire 秒后自然过期),
// 由 JWTAuth 的 DB 状态回查兜底拦截。
pipe.Del(ctx, adminTokensKey)
if _, err := pipe.Exec(ctx); err != nil {
span.RecordError(err)
return err
}
return nil
}
+44
View File
@@ -0,0 +1,44 @@
package redisdao
import (
"context"
"fmt"
"time"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
"github.com/redis/go-redis/v9"
)
// businessRedisPrefix 业务服务(zt-closet-backend)的 Redis key 前缀。
// 管理后台推送通知时,必须自增业务服务所读的同一未读计数 key,
// 保证小程序端未读角标实时一致。要求管理后台与业务服务共用同一 Redis 库(config.yaml 的 Redis.DB)。
const businessRedisPrefix = "ztcloset:"
// KeyNotificationUnread 业务服务的用户未读通知计数 key(格式保持一致)。
var KeyNotificationUnread = businessRedisPrefix + "notification:unread:%d"
// notificationUnreadTTL 未读数 key 的过期时间,与业务服务保持一致。
const notificationUnreadTTL = 7 * 24 * time.Hour
// notificationUnreadIncrScript 首次自增时设置过期时间,保证 INCR 与 EXPIRE 原子执行。
var notificationUnreadIncrScript = redis.NewScript(`
local v = redis.call('INCR', KEYS[1])
if v == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return v
`)
// NotificationUnreadIncr 用户未读消息数 +1,返回最新值
func NotificationUnreadIncr(ctx context.Context, userID int64) (int64, error) {
ctx, span := global.Tracer.Start(ctx, "redisdao.NotificationUnreadIncr")
defer span.End()
key := fmt.Sprintf(KeyNotificationUnread, userID)
val, err := notificationUnreadIncrScript.Run(ctx, global.RedisEngine, []string{key}, int(notificationUnreadTTL.Seconds())).Int64()
if err != nil {
span.RecordError(err)
return 0, err
}
return val, nil
}
+8
View File
@@ -0,0 +1,8 @@
package redismodel
// 认证模块:管理员 token 会话的 Redis key。
const (
KeyAuthAccess = RedisPrefix + "auth:access:%s" // access_token -> admin_id(String)
KeyAuthRefresh = RedisPrefix + "auth:refresh:%s" // refresh_token -> admin_id(String)
KeyAuthUserTokens = RedisPrefix + "auth:admin:tokens:%d" // admin_id -> token 集合(Set,支持多端登录)
)
+41
View File
@@ -0,0 +1,41 @@
package redismodel
import (
"context"
"fmt"
"log/slog"
"time"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/setting"
"github.com/redis/go-redis/v9"
)
// RedisPrefix 是所有 Redis key 的全局命名空间前缀,避免多服务共享 Redis 实例时发生 key 冲突。
const RedisPrefix = "ztclosetadmin:"
// NewRedisEngine 根据配置创建 Redis 客户端并验证连接。
func NewRedisEngine(s *setting.RedisSettingS) (*redis.Client, error) {
client := redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", s.Host, s.Port),
Password: s.Password,
DB: s.DB,
PoolSize: s.PoolSize,
MinIdleConns: s.MinIdleConns,
MaxIdleConns: s.MaxIdleConns,
MinRetryBackoff: time.Duration(s.MinRetryBackoff) * time.Millisecond,
MaxRetryBackoff: time.Duration(s.MaxRetryBackoff) * time.Millisecond,
MaxRetries: s.MaxRetries,
// 连接/读写/池超时配置单位为秒(与 config.yaml 注释一致)。
DialTimeout: time.Duration(s.DialTimeout) * time.Second,
ReadTimeout: time.Duration(s.ReadTimeout) * time.Second,
WriteTimeout: time.Duration(s.WriteTimeout) * time.Second,
PoolTimeout: time.Duration(s.PoolTimeout) * time.Second,
})
if err := client.Ping(context.Background()).Err(); err != nil {
return nil, fmt.Errorf("redis ping failed: %w", err)
}
slog.Info("redis connected", "addr", fmt.Sprintf("%s:%d", s.Host, s.Port))
return client, nil
}
+180
View File
@@ -0,0 +1,180 @@
package v1
import (
"strconv"
"strings"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/dto"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/middleware"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/service"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/app"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
"github.com/gin-gonic/gin"
)
// AdminHandler 管理员账号相关接口。
type AdminHandler struct {
svc *service.AdminService
}
// NewAdminHandler 创建管理员 handler。
func NewAdminHandler() *AdminHandler {
return &AdminHandler{svc: service.NewAdminService()}
}
// Login 管理员登录(公开)。
func (h *AdminHandler) Login(c *gin.Context) {
response := app.NewResponse(c)
var request dto.AdminLoginRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
out, e := h.svc.Login(c.Request.Context(), &request)
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
// RefreshToken 刷新 token(公开)。
func (h *AdminHandler) RefreshToken(c *gin.Context) {
response := app.NewResponse(c)
var request dto.AdminRefreshTokenRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
out, e := h.svc.RefreshToken(c.Request.Context(), &request)
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
// Logout 登出(需鉴权)。
func (h *AdminHandler) Logout(c *gin.Context) {
response := app.NewResponse(c)
var request struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
if err := c.ShouldBindJSON(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
accessToken := bearerToken(c)
if e := h.svc.Logout(c.Request.Context(), accessToken, request.RefreshToken); e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(nil)
}
// ChangePassword 修改自己密码(需鉴权)。
func (h *AdminHandler) ChangePassword(c *gin.Context) {
response := app.NewResponse(c)
var request dto.AdminChangePasswordRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
adminID, ok := middleware.GetAdminID(c.Request.Context())
if !ok {
response.ToErrorResponse(errcode.Unauthorized)
return
}
if e := h.svc.ChangePassword(c.Request.Context(), adminID, &request); e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(nil)
}
// List 管理员列表(仅超级管理员)。
func (h *AdminHandler) List(c *gin.Context) {
response := app.NewResponse(c)
var request dto.AdminListRequest
if err := c.ShouldBindQuery(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
out, e := h.svc.List(c.Request.Context(), &request)
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
// Create 创建管理员(仅超级管理员)。
func (h *AdminHandler) Create(c *gin.Context) {
response := app.NewResponse(c)
var request dto.AdminCreateRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
out, e := h.svc.Create(c.Request.Context(), &request)
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
// Update 更新管理员(仅超级管理员)。
func (h *AdminHandler) Update(c *gin.Context) {
response := app.NewResponse(c)
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
response.ToErrorResponse(errcode.InvalidParams.WithDetails("非法管理员 ID"))
return
}
var request dto.AdminUpdateRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
adminID, ok := middleware.GetAdminID(c.Request.Context())
if !ok {
response.ToErrorResponse(errcode.Unauthorized)
return
}
if e := h.svc.Update(c.Request.Context(), adminID, id, &request); e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(nil)
}
// Delete 删除管理员(仅超级管理员)。
func (h *AdminHandler) Delete(c *gin.Context) {
response := app.NewResponse(c)
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
response.ToErrorResponse(errcode.InvalidParams.WithDetails("非法管理员 ID"))
return
}
adminID, ok := middleware.GetAdminID(c.Request.Context())
if !ok {
response.ToErrorResponse(errcode.Unauthorized)
return
}
if e := h.svc.Delete(c.Request.Context(), adminID, id); e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(nil)
}
// bearerToken 从 Authorization 头提取裸 token。
func bearerToken(c *gin.Context) string {
authHeader := c.GetHeader("Authorization")
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) == 2 && parts[0] == "Bearer" {
return parts[1]
}
return ""
}
+155
View File
@@ -0,0 +1,155 @@
package v1
import (
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/dto"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/middleware"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/service"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/app"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
"github.com/gin-gonic/gin"
)
// ContentHandler 内容审核接口(穿搭/衣物/分享)。
type ContentHandler struct {
svc *service.ContentService
}
// NewContentHandler 创建内容审核 handler。
func NewContentHandler() *ContentHandler {
return &ContentHandler{svc: service.NewContentService()}
}
// OutfitList 穿搭列表。
func (h *ContentHandler) OutfitList(c *gin.Context) {
response := app.NewResponse(c)
var request dto.OutfitListRequest
if err := c.ShouldBindQuery(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
out, e := h.svc.OutfitList(c.Request.Context(), &request)
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
// OutfitDetail 穿搭详情。
func (h *ContentHandler) OutfitDetail(c *gin.Context) {
response := app.NewResponse(c)
out, e := h.svc.OutfitDetail(c.Request.Context(), c.Param("uuid"))
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
// OutfitAudit 穿搭审核(通过/驳回)。
func (h *ContentHandler) OutfitAudit(c *gin.Context) {
response := app.NewResponse(c)
var request dto.OutfitAuditRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
adminID, _ := middleware.GetAdminID(c.Request.Context())
if e := h.svc.OutfitAudit(c.Request.Context(), adminID, c.Param("uuid"), &request); e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(nil)
}
// OutfitBan 封禁穿搭。
func (h *ContentHandler) OutfitBan(c *gin.Context) {
response := app.NewResponse(c)
var request dto.OutfitBanRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
adminID, _ := middleware.GetAdminID(c.Request.Context())
if e := h.svc.OutfitBan(c.Request.Context(), adminID, c.Param("uuid"), &request); e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(nil)
}
// OutfitUnban 解封穿搭。
func (h *ContentHandler) OutfitUnban(c *gin.Context) {
response := app.NewResponse(c)
adminID, _ := middleware.GetAdminID(c.Request.Context())
if e := h.svc.OutfitUnban(c.Request.Context(), adminID, c.Param("uuid")); e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(nil)
}
// ClothesList 衣物列表。
func (h *ContentHandler) ClothesList(c *gin.Context) {
response := app.NewResponse(c)
var request dto.ClothesListRequest
if err := c.ShouldBindQuery(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
out, e := h.svc.ClothesList(c.Request.Context(), &request)
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
// ClothesDetail 衣物详情。
func (h *ContentHandler) ClothesDetail(c *gin.Context) {
response := app.NewResponse(c)
out, e := h.svc.ClothesDetail(c.Request.Context(), c.Param("uuid"))
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
// ShareList 衣柜分享列表。
func (h *ContentHandler) ShareList(c *gin.Context) {
response := app.NewResponse(c)
var request dto.ClosetShareListRequest
if err := c.ShouldBindQuery(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
out, e := h.svc.ShareList(c.Request.Context(), &request)
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
// ShareBan 封禁分享。
func (h *ContentHandler) ShareBan(c *gin.Context) {
response := app.NewResponse(c)
adminID, _ := middleware.GetAdminID(c.Request.Context())
if e := h.svc.ShareBan(c.Request.Context(), adminID, c.Param("uuid")); e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(nil)
}
// ShareUnban 解封分享。
func (h *ContentHandler) ShareUnban(c *gin.Context) {
response := app.NewResponse(c)
adminID, _ := middleware.GetAdminID(c.Request.Context())
if e := h.svc.ShareUnban(c.Request.Context(), adminID, c.Param("uuid")); e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(nil)
}
+46
View File
@@ -0,0 +1,46 @@
package v1
import (
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/dto"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/service"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/app"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
"github.com/gin-gonic/gin"
)
// DashboardHandler 仪表盘统计接口。
type DashboardHandler struct {
svc *service.DashboardService
}
// NewDashboardHandler 创建仪表盘 handler。
func NewDashboardHandler() *DashboardHandler {
return &DashboardHandler{svc: service.NewDashboardService()}
}
// Summary 仪表盘核心指标。
func (h *DashboardHandler) Summary(c *gin.Context) {
response := app.NewResponse(c)
out, e := h.svc.Summary(c.Request.Context())
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
// Trend 近 N 天趋势。
func (h *DashboardHandler) Trend(c *gin.Context) {
response := app.NewResponse(c)
var request dto.TrendRequest
if err := c.ShouldBindQuery(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
out, e := h.svc.Trends(c.Request.Context(), request.Days)
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
+113
View File
@@ -0,0 +1,113 @@
package v1
import (
"strconv"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/dto"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/middleware"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/service"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/app"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
"github.com/gin-gonic/gin"
)
// GoodsHandler 商品管理接口。
type GoodsHandler struct {
svc *service.GoodsService
}
// NewGoodsHandler 创建商品 handler。
func NewGoodsHandler() *GoodsHandler {
return &GoodsHandler{svc: service.NewGoodsService()}
}
// List 商品列表(含下架)。
func (h *GoodsHandler) List(c *gin.Context) {
response := app.NewResponse(c)
var request dto.GoodsListRequest
if err := c.ShouldBindQuery(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
out, e := h.svc.List(c.Request.Context(), &request)
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
// Create 创建商品。
func (h *GoodsHandler) Create(c *gin.Context) {
response := app.NewResponse(c)
var request dto.GoodsCreateRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
adminID, _ := middleware.GetAdminID(c.Request.Context())
out, e := h.svc.Create(c.Request.Context(), adminID, &request)
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
// Update 更新商品。
func (h *GoodsHandler) Update(c *gin.Context) {
response := app.NewResponse(c)
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
response.ToErrorResponse(errcode.InvalidParams.WithDetails("非法商品 ID"))
return
}
var request dto.GoodsUpdateRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
adminID, _ := middleware.GetAdminID(c.Request.Context())
if e := h.svc.Update(c.Request.Context(), adminID, id, &request); e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(nil)
}
// UpdateStatus 商品上下架。
func (h *GoodsHandler) UpdateStatus(c *gin.Context) {
response := app.NewResponse(c)
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
response.ToErrorResponse(errcode.InvalidParams.WithDetails("非法商品 ID"))
return
}
var request dto.GoodsStatusRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
adminID, _ := middleware.GetAdminID(c.Request.Context())
if e := h.svc.UpdateStatus(c.Request.Context(), adminID, id, &request); e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(nil)
}
// Delete 软删除商品。
func (h *GoodsHandler) Delete(c *gin.Context) {
response := app.NewResponse(c)
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
response.ToErrorResponse(errcode.InvalidParams.WithDetails("非法商品 ID"))
return
}
adminID, _ := middleware.GetAdminID(c.Request.Context())
if e := h.svc.Delete(c.Request.Context(), adminID, id); e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(nil)
}
+53
View File
@@ -0,0 +1,53 @@
package v1
import (
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/dto"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/middleware"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/service"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/app"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
"github.com/gin-gonic/gin"
)
// NotificationHandler 通知推送接口。
type NotificationHandler struct {
svc *service.NotificationService
}
// NewNotificationHandler 创建通知 handler。
func NewNotificationHandler() *NotificationHandler {
return &NotificationHandler{svc: service.NewNotificationService()}
}
// Send 向单个用户发送系统通知。
func (h *NotificationHandler) Send(c *gin.Context) {
response := app.NewResponse(c)
var request dto.NotificationSendRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
adminID, _ := middleware.GetAdminID(c.Request.Context())
if e := h.svc.Send(c.Request.Context(), adminID, &request); e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(nil)
}
// Broadcast 全量广播系统通知。
func (h *NotificationHandler) Broadcast(c *gin.Context) {
response := app.NewResponse(c)
var request dto.NotificationBroadcastRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
adminID, _ := middleware.GetAdminID(c.Request.Context())
out, e := h.svc.Broadcast(c.Request.Context(), adminID, &request)
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
+95
View File
@@ -0,0 +1,95 @@
package v1
import (
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/dto"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/middleware"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/service"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/app"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
"github.com/gin-gonic/gin"
)
// OrderHandler 订单与退款管理接口。
type OrderHandler struct {
svc *service.OrderService
}
// NewOrderHandler 创建订单 handler。
func NewOrderHandler() *OrderHandler {
return &OrderHandler{svc: service.NewOrderService()}
}
// List 订单列表。
func (h *OrderHandler) List(c *gin.Context) {
response := app.NewResponse(c)
var request dto.OrderListRequest
if err := c.ShouldBindQuery(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
out, e := h.svc.List(c.Request.Context(), &request)
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
// Get 订单详情。
func (h *OrderHandler) Get(c *gin.Context) {
response := app.NewResponse(c)
out, e := h.svc.Get(c.Request.Context(), c.Param("orderNo"))
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
// RefundList 退款单列表。
func (h *OrderHandler) RefundList(c *gin.Context) {
response := app.NewResponse(c)
var request dto.RefundListRequest
if err := c.ShouldBindQuery(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
out, e := h.svc.RefundList(c.Request.Context(), &request)
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
// RefundApprove 退款审核通过。
func (h *OrderHandler) RefundApprove(c *gin.Context) {
response := app.NewResponse(c)
var request dto.RefundAuditRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
adminID, _ := middleware.GetAdminID(c.Request.Context())
if e := h.svc.RefundApprove(c.Request.Context(), adminID, c.Param("refundNo"), &request); e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(nil)
}
// RefundReject 退款审核驳回。
func (h *OrderHandler) RefundReject(c *gin.Context) {
response := app.NewResponse(c)
var request dto.RefundAuditRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
adminID, _ := middleware.GetAdminID(c.Request.Context())
if e := h.svc.RefundReject(c.Request.Context(), adminID, c.Param("refundNo"), &request); e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(nil)
}
+129
View File
@@ -0,0 +1,129 @@
package v1
import (
"strconv"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/dto"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/middleware"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/service"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/app"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
"github.com/gin-gonic/gin"
)
// UserHandler 用户管理接口。
type UserHandler struct {
svc *service.UserService
}
// NewUserHandler 创建用户管理 handler。
func NewUserHandler() *UserHandler {
return &UserHandler{svc: service.NewUserService()}
}
// List 用户分页列表。
func (h *UserHandler) List(c *gin.Context) {
response := app.NewResponse(c)
var request dto.UserListRequest
if err := c.ShouldBindQuery(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
out, e := h.svc.List(c.Request.Context(), &request)
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
// Detail 用户详情(含四类额度)。
func (h *UserHandler) Detail(c *gin.Context) {
response := app.NewResponse(c)
out, e := h.svc.Detail(c.Request.Context(), c.Param("uuid"))
if e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(out)
}
// Ban 封禁用户。
func (h *UserHandler) Ban(c *gin.Context) {
response := app.NewResponse(c)
var request dto.UserBanRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
adminID, _ := middleware.GetAdminID(c.Request.Context())
if e := h.svc.Ban(c.Request.Context(), adminID, c.Param("uuid"), &request); e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(nil)
}
// Unban 解封用户。
func (h *UserHandler) Unban(c *gin.Context) {
response := app.NewResponse(c)
var request dto.UserUnbanRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
adminID, _ := middleware.GetAdminID(c.Request.Context())
if e := h.svc.Unban(c.Request.Context(), adminID, c.Param("uuid"), &request); e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(nil)
}
// QuotaAdjust 额度发放(有有效期额度凭证)。
func (h *UserHandler) QuotaAdjust(c *gin.Context) {
response := app.NewResponse(c)
var request dto.UserQuotaAdjustRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
adminID, _ := middleware.GetAdminID(c.Request.Context())
if e := h.svc.QuotaAdjust(c.Request.Context(), adminID, c.Param("uuid"), &request); e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(nil)
}
// VIPAdjust 会员人工调整。
func (h *UserHandler) VIPAdjust(c *gin.Context) {
response := app.NewResponse(c)
var request dto.UserVIPAdjustRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
return
}
adminID, _ := middleware.GetAdminID(c.Request.Context())
if e := h.svc.VIPAdjust(c.Request.Context(), adminID, c.Param("uuid"), &request); e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(nil)
}
// GrantRevoke 回收额度凭证。
func (h *UserHandler) GrantRevoke(c *gin.Context) {
response := app.NewResponse(c)
grantID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || grantID <= 0 {
response.ToErrorResponse(errcode.InvalidParams.WithDetails("非法凭证 ID"))
return
}
adminID, _ := middleware.GetAdminID(c.Request.Context())
if e := h.svc.GrantRevoke(c.Request.Context(), adminID, c.Param("uuid"), grantID); e != nil {
response.ToErrorResponse(e)
return
}
response.ToResponse(nil)
}
+109
View File
@@ -0,0 +1,109 @@
package routers
import (
"net/http"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/middleware"
v1 "git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/routers/api/v1"
"github.com/gin-gonic/gin"
)
// NewRouter 构建 gin 引擎:注册中间件链与 /api/v1 路由。
func NewRouter() *gin.Engine {
r := gin.New()
// 中间件链:Recovery -> RequestID -> OTel -> Slog -> CORS
r.Use(
gin.Recovery(),
middleware.RequestID(),
middleware.GinOTelMiddleware(),
middleware.GinSlogMiddleware(),
middleware.CORS(),
)
// 健康检查
r.GET("/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "pong"})
})
// handler 实例
adminHandler := v1.NewAdminHandler()
dashboardHandler := v1.NewDashboardHandler()
userHandler := v1.NewUserHandler()
goodsHandler := v1.NewGoodsHandler()
orderHandler := v1.NewOrderHandler()
contentHandler := v1.NewContentHandler()
notificationHandler := v1.NewNotificationHandler()
apiV1 := r.Group("/api/v1")
// ===== 公开路由(无需登录)=====
{
apiV1.POST("/admin/login", adminHandler.Login)
apiV1.POST("/admin/refresh-token", adminHandler.RefreshToken)
}
// ===== 鉴权路由(需登录)=====
auth := apiV1.Group("")
auth.Use(middleware.JWTAuth())
{
// 管理员(自己)
auth.POST("/admin/logout", adminHandler.Logout)
auth.PUT("/admin/password", adminHandler.ChangePassword)
// 仪表盘
auth.GET("/dashboard/summary", dashboardHandler.Summary)
auth.GET("/dashboard/trends", dashboardHandler.Trend)
// 用户管理
auth.GET("/users", userHandler.List)
auth.GET("/users/:uuid", userHandler.Detail)
auth.PUT("/users/:uuid/ban", userHandler.Ban)
auth.PUT("/users/:uuid/unban", userHandler.Unban)
auth.PUT("/users/:uuid/quota", userHandler.QuotaAdjust)
auth.PUT("/users/:uuid/vip", userHandler.VIPAdjust)
auth.DELETE("/users/:uuid/grants/:id", userHandler.GrantRevoke)
// 商品管理
auth.GET("/goods", goodsHandler.List)
auth.POST("/goods", goodsHandler.Create)
auth.PUT("/goods/:id", goodsHandler.Update)
auth.PUT("/goods/:id/status", goodsHandler.UpdateStatus)
auth.DELETE("/goods/:id", goodsHandler.Delete)
// 订单与退款
auth.GET("/orders", orderHandler.List)
auth.GET("/orders/:orderNo", orderHandler.Get)
auth.GET("/refunds", orderHandler.RefundList)
auth.POST("/refunds/:refundNo/approve", orderHandler.RefundApprove)
auth.POST("/refunds/:refundNo/reject", orderHandler.RefundReject)
// 内容审核
auth.GET("/outfits", contentHandler.OutfitList)
auth.GET("/outfits/:uuid", contentHandler.OutfitDetail)
auth.POST("/outfits/:uuid/audit", contentHandler.OutfitAudit)
auth.POST("/outfits/:uuid/ban", contentHandler.OutfitBan)
auth.POST("/outfits/:uuid/unban", contentHandler.OutfitUnban)
auth.GET("/clothes", contentHandler.ClothesList)
auth.GET("/clothes/:uuid", contentHandler.ClothesDetail)
auth.GET("/closet-shares", contentHandler.ShareList)
auth.POST("/closet-shares/:uuid/ban", contentHandler.ShareBan)
auth.POST("/closet-shares/:uuid/unban", contentHandler.ShareUnban)
// 通知推送
auth.POST("/notifications", notificationHandler.Send)
auth.POST("/notifications/broadcast", notificationHandler.Broadcast)
}
// ===== 超管专属路由 =====
super := apiV1.Group("")
super.Use(middleware.JWTAuth(), middleware.AdminSuperAuth())
{
super.GET("/admins", adminHandler.List)
super.POST("/admins", adminHandler.Create)
super.PUT("/admins/:id", adminHandler.Update)
super.DELETE("/admins/:id", adminHandler.Delete)
}
return r
}
+379
View File
@@ -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,
}
}
+156
View File
@@ -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 // 驳回
)
+372
View File
@@ -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,
}
}
+188
View File
@@ -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
}
+210
View File
@@ -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,
}
}
+118
View File
@@ -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,
}
}
+196
View File
@@ -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,
}
}
+83
View File
@@ -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
}
+406
View File
@@ -0,0 +1,406 @@
package service
import (
"context"
"strings"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/dto"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqldao"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/cos"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
)
// UserService 用户管理服务。
type UserService struct {
userDao *mysqldao.UserDao
quotaGrantDao *mysqldao.QuotaGrantDao
}
// NewUserService 创建用户管理服务。
func NewUserService() *UserService {
return &UserService{
userDao: mysqldao.NewUserDao(),
quotaGrantDao: mysqldao.NewQuotaGrantDao(),
}
}
// List 分页查询用户(关键词/状态筛选)。
func (s *UserService) List(ctx context.Context, request *dto.UserListRequest) (*dto.UserListResponse, *errcode.Error) {
ctx, span := global.Tracer.Start(ctx, "svc.AdminUserList")
defer span.End()
page, pageSize := normalizePage(request.Page, request.PageSize)
list, total, err := s.userDao.UserList(ctx, mysqldao.UserListParams{
Keyword: strings.TrimSpace(request.Keyword),
Status: request.Status,
Page: page,
Size: pageSize,
})
if err != nil {
return nil, errcode.DatabaseError
}
items := make([]dto.UserListItem, 0, len(list))
for i := range list {
items = append(items, buildUserListItem(&list[i]))
}
return &dto.UserListResponse{
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
List: items,
}, nil
}
// Detail 用户详情:完整展示永久额度(Cos)与每日额度(AI/社区/衣柜)+ 额度凭证列表。
func (s *UserService) Detail(ctx context.Context, userUUID string) (*dto.UserDetailResponse, *errcode.Error) {
ctx, span := global.Tracer.Start(ctx, "svc.AdminUserDetail")
defer span.End()
user, err := s.userDao.UserGetByUUID(ctx, userUUID)
if err != nil {
if isNotFound(err) {
return nil, errcode.UserNotFound
}
return nil, errcode.DatabaseError
}
now := nowUnix()
grants, err := s.quotaGrantDao.QuotaGrantListByUserID(ctx, user.ID)
if err != nil {
return nil, errcode.DatabaseError
}
// 有效凭证按类型聚合(expire_time=0 跟随 VIP)。
validAmount := map[int8]int64{}
validUsed := map[int8]int64{}
for _, g := range grants {
if grantExpired(g.ExpireTime, user.VIPExpire, now) {
continue
}
validAmount[g.QuotaType] += g.Amount
validUsed[g.QuotaType] += g.Used
}
// Cos 永久已用(user_quotas.used 永久累计)。
cosFreeUsed := int64(0)
if q, qerr := s.quotaGrantDao.QuotaGetByUserIDAndType(ctx, user.ID, QuotaTypeCos); qerr == nil {
cosFreeUsed = q.Used
}
// 当日已用(user_daily_usages,AI/社区/衣柜)。
dailyUsed := map[int8]int64{}
if usage, uerr := s.quotaGrantDao.UserDailyUsageGetByUserIDAndDate(ctx, user.ID, todayStatDate()); uerr == nil {
dailyUsed[QuotaTypeAI] = usage.AIRecommendCount
dailyUsed[QuotaTypeOutfitShare] = usage.OutfitShareCount
dailyUsed[QuotaTypeClosetShare] = usage.ClosetShareCount
}
types := []int8{QuotaTypeCos, QuotaTypeAI, QuotaTypeOutfitShare, QuotaTypeClosetShare}
quotaItems := make([]dto.QuotaItem, 0, len(types))
for _, quotaType := range types {
freeTotal := defaultQuotaValue(quotaType)
total := freeTotal + validAmount[quotaType]
var used int64
if quotaType == QuotaTypeCos {
used = cosFreeUsed + validUsed[quotaType]
} else {
used = dailyUsed[quotaType]
}
remain := total - used
if remain < 0 {
remain = 0
}
quotaItems = append(quotaItems, dto.QuotaItem{
QuotaType: quotaType,
Total: total,
Used: used,
Remain: remain,
})
}
grantItems := make([]dto.UserGrantItem, 0, len(grants))
for _, g := range grants {
grantItems = append(grantItems, dto.UserGrantItem{
ID: g.ID,
QuotaType: g.QuotaType,
Amount: g.Amount,
Used: g.Used,
Source: g.Source,
ExpireTime: g.ExpireTime,
RefID: g.RefID,
CreatedAt: g.CreatedAt,
})
}
item := buildUserListItem(user)
return &dto.UserDetailResponse{UserListItem: item, Quotas: quotaItems, Grants: grantItems}, nil
}
// Ban 封禁用户:临时(按天数)/永久,写封禁原因与时间。
func (s *UserService) Ban(ctx context.Context, adminID int64, userUUID string, request *dto.UserBanRequest) *errcode.Error {
ctx, span := global.Tracer.Start(ctx, "svc.AdminUserBan")
defer span.End()
user, err := s.userDao.UserGetByUUID(ctx, userUUID)
if err != nil {
if isNotFound(err) {
return errcode.UserNotFound
}
return errcode.DatabaseError
}
now := nowUnix()
updates := map[string]any{
"ban_reason": strings.TrimSpace(request.Reason),
"ban_start": now,
"updated_by": adminID,
}
switch request.BanType {
case UserStatusTempBan:
if request.BanDays <= 0 {
return errcode.InvalidParams.WithDetails("临时禁用需填写封禁天数")
}
updates["status"] = UserStatusTempBan
updates["ban_expire"] = now + int64(request.BanDays)*86400
case UserStatusPermBan:
updates["status"] = UserStatusPermBan
updates["ban_expire"] = 0
default:
return errcode.InvalidParams.WithDetails("封禁类型不合法")
}
if err := s.userDao.UserUpdateByID(ctx, user.ID, updates); err != nil {
return errcode.DatabaseError
}
return nil
}
// Unban 解封用户(恢复正常,清空封禁信息)。
func (s *UserService) Unban(ctx context.Context, adminID int64, userUUID string, request *dto.UserUnbanRequest) *errcode.Error {
ctx, span := global.Tracer.Start(ctx, "svc.AdminUserUnban")
defer span.End()
user, err := s.userDao.UserGetByUUID(ctx, userUUID)
if err != nil {
if isNotFound(err) {
return errcode.UserNotFound
}
return errcode.DatabaseError
}
if user.Status != UserStatusTempBan && user.Status != UserStatusPermBan {
return nil // 幂等:非封禁状态无需处理。
}
updates := map[string]any{
"status": UserStatusNormal,
"ban_reason": "",
"ban_start": 0,
"ban_expire": 0,
"updated_by": adminID,
}
if err := s.userDao.UserUpdateByID(ctx, user.ID, updates); err != nil {
return errcode.DatabaseError
}
return nil
}
// QuotaAdjust 额度发放:写入有有效期额度凭证(source=加量包)。
// Cos 为一次性库存,AI/社区/衣柜 为有效期内每日加量;expire_days 天内有效。
func (s *UserService) QuotaAdjust(ctx context.Context, adminID int64, userUUID string, request *dto.UserQuotaAdjustRequest) *errcode.Error {
ctx, span := global.Tracer.Start(ctx, "svc.AdminUserQuotaAdjust")
defer span.End()
user, err := s.userDao.UserGetByUUID(ctx, userUUID)
if err != nil {
if isNotFound(err) {
return errcode.UserNotFound
}
return errcode.DatabaseError
}
grant := &mysqlmodel.QuotaGrant{
UserID: user.ID,
QuotaType: request.QuotaType,
Amount: request.Amount,
Used: 0,
Source: QuotaGrantSourceAddon,
ExpireTime: nowUnix() + request.ExpireDays*86400,
RefID: "manual_" + genUUID(),
}
grant.CreatedBy = adminID
grant.UpdatedBy = adminID
if err := s.quotaGrantDao.QuotaGrantCreate(ctx, grant); err != nil {
return errcode.DatabaseError
}
return nil
}
// GrantRevoke 回收额度凭证(软删除,按 id + user_id 归属校验防越权)。
func (s *UserService) GrantRevoke(ctx context.Context, adminID int64, userUUID string, grantID int64) *errcode.Error {
ctx, span := global.Tracer.Start(ctx, "svc.AdminUserGrantRevoke")
defer span.End()
user, err := s.userDao.UserGetByUUID(ctx, userUUID)
if err != nil {
if isNotFound(err) {
return errcode.UserNotFound
}
return errcode.DatabaseError
}
if _, err := s.quotaGrantDao.QuotaGrantGetByID(ctx, grantID); err != nil {
if isNotFound(err) {
return errcode.UserGrantNotFound
}
return errcode.DatabaseError
}
if err := s.quotaGrantDao.QuotaGrantSoftDelete(ctx, grantID, user.ID); err != nil {
return errcode.DatabaseError
}
return nil
}
// VIPAdjust 人工调整用户会员(事务:更新会员字段 + 人工调整流水)。
// 叠加语义(与购买发放一致):vip_expire = max(当前到期, now) + vip_days*86400;vip_days=0 表示取消会员。
func (s *UserService) VIPAdjust(ctx context.Context, adminID int64, userUUID string, request *dto.UserVIPAdjustRequest) *errcode.Error {
ctx, span := global.Tracer.Start(ctx, "svc.AdminUserVIPAdjust")
defer span.End()
user, err := s.userDao.UserGetByUUID(ctx, userUUID)
if err != nil {
if isNotFound(err) {
return errcode.UserNotFound
}
return errcode.DatabaseError
}
now := nowUnix()
// 变更前快照(流水用)。
oldType := user.VIPType
oldExpire := user.VIPExpire
// 会员已过期时接口视角归一为非会员。
if oldExpire > 0 && oldExpire < now {
oldType = VIPTypeNone
}
var newType int8
var newExpire int64
if request.VIPDays <= 0 {
// 取消会员。
if request.VIPType != VIPTypeNone {
return errcode.InvalidParams.WithDetails("取消会员时 vip_type 应为 1")
}
newType = VIPTypeNone
newExpire = 0
} else {
if request.VIPType == VIPTypeNone {
return errcode.InvalidParams.WithDetails("赠送会员天数时 vip_type 不能为 1")
}
newType = request.VIPType
base := user.VIPExpire
if base < now {
base = now
}
newExpire = base + request.VIPDays*86400
}
flow := &mysqlmodel.VIPFlowLog{
UserID: user.ID,
ChangeType: VIPChangeTypeManual,
OldVIPType: oldType,
NewVIPType: newType,
OldVIPExpire: oldExpire,
Source: VIPSourceManual,
Reason: strings.TrimSpace(request.Reason),
}
// 会员已过期/从未开通时重置开始时间;续期保留原开始时间。
if newExpire > 0 && (oldExpire <= 0 || oldExpire <= now) {
user.VIPStart = now
}
user.VIPType = newType
user.VIPExpire = newExpire
if err := s.userDao.VIPAdjustWithFlow(ctx, user, flow); err != nil {
return errcode.DatabaseError
}
return nil
}
// defaultQuotaValue 返回某类额度的免费底值:
// 优先取 global.QuotaSetting 配置(与业务服务 UserDefaultQuota 免费值对齐),
// 未配置(指针为 nil)或负数时回落内置常量 DefaultQuota*。
func defaultQuotaValue(quotaType int8) int64 {
cfg := global.QuotaSetting
switch quotaType {
case QuotaTypeCos:
if cfg != nil && cfg.Cos != nil && *cfg.Cos >= 0 {
return *cfg.Cos
}
return DefaultQuotaCos
case QuotaTypeAI:
if cfg != nil && cfg.AI != nil && *cfg.AI >= 0 {
return *cfg.AI
}
return DefaultQuotaAI
case QuotaTypeOutfitShare:
if cfg != nil && cfg.OutfitShare != nil && *cfg.OutfitShare >= 0 {
return *cfg.OutfitShare
}
return DefaultQuotaOutfitShare
case QuotaTypeClosetShare:
if cfg != nil && cfg.ClosetShare != nil && *cfg.ClosetShare >= 0 {
return *cfg.ClosetShare
}
return DefaultQuotaClosetShare
default:
return 0
}
}
// grantExpired 判断凭证是否失效:expire_time=0 表示跟随 VIP,其余按到期时间判断。
func grantExpired(expireTime, vipExpire, now int64) bool {
if expireTime == 0 {
return vipExpire <= now
}
return expireTime <= now
}
// buildUserListItem 用户模型 → 列表项(会员已过期归一为非会员)。
func buildUserListItem(u *mysqlmodel.User) dto.UserListItem {
vipType := u.VIPType
vipExpire := u.VIPExpire
if vipType != VIPTypeNone && vipExpire > 0 && vipExpire < nowUnix() {
vipType = VIPTypeNone
vipExpire = 0
}
return dto.UserListItem{
ID: u.ID,
UserUUID: u.UserUUID,
Username: u.Username,
Avatar: toImageURL(u.Avatar),
Sex: u.Sex,
Phone: u.Phone,
Status: u.Status,
BanReason: u.BanReason,
BanStart: u.BanStart,
BanExpire: u.BanExpire,
VIPType: vipType,
VIPExpire: vipExpire,
LastLoginTime: u.LastLoginTime,
CreatedAt: u.CreatedAt,
}
}
// toImageURL 头像地址兼容转换:以 http(s) 开头的历史完整 URL 直接透传,
// 其余(COS 相对 key)转为带 CDN 鉴权签名的可访问 URL。
func toImageURL(key string) string {
if key == "" || strings.HasPrefix(key, "http://") || strings.HasPrefix(key, "https://") {
return key
}
return cos.GetCosUrl(key)
}