first commit
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
package dto
|
||||
|
||||
// AdRewardGrantRequest 广告激励额度发放请求。
|
||||
// RewardType 取值:cos / ai / outfit_share / closet_share,映射到额度类型。
|
||||
// 发放金额由服务端配置固定,前端不传金额,杜绝刷额度。
|
||||
type AdRewardGrantRequest struct {
|
||||
RewardType string `json:"reward_type" binding:"required"` // 激励类型
|
||||
RefID string `json:"ref_id" binding:"required"` // 广告记录ID(幂等键)
|
||||
}
|
||||
|
||||
// AdRewardGrantResponse 广告激励额度发放响应。
|
||||
type AdRewardGrantResponse struct {
|
||||
RefID string `json:"ref_id"` // 广告记录ID
|
||||
QuotaType int8 `json:"quota_type"` // 额度类型 1=cos 2=AI 3=社区分享 4=衣柜分享
|
||||
Amount int64 `json:"amount"` // 发放数量
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package dto
|
||||
|
||||
// 发起AI推荐
|
||||
type AIRecommendCreateRequest struct {
|
||||
SceneType int8 `json:"scene_type" binding:"required,oneof=1 2"` // 场景类型 1=每日推荐 2=个性化场景
|
||||
Scene string `json:"scene"` // 用户自定义场景描述
|
||||
}
|
||||
|
||||
// AI推荐结果
|
||||
type AIRecommendCreateResponse struct {
|
||||
OutfitUUID string `json:"outfit_uuid"` // 穿搭UUID
|
||||
OutfitName string `json:"outfit_name"` // 穿搭名称
|
||||
OutfitDesc string `json:"outfit_desc"` // 穿搭描述
|
||||
Scene string `json:"scene"` // 适合场景
|
||||
Source int8 `json:"source"` // 来源 1=用户创建 2=AI推荐
|
||||
Status int8 `json:"status"` // 状态 1=正常 2=封禁 3=待用户保存
|
||||
OutfitItem []OutfitItem `json:"outfit_item"`
|
||||
}
|
||||
|
||||
// AI推荐穿搭的衣物UUID
|
||||
type OutfitItem struct {
|
||||
ClothesUUID string `json:"clothes_uuid"` // 衣物UUID
|
||||
}
|
||||
|
||||
// 保存AI推荐穿搭到我的搭配
|
||||
type AIRecommendSaveRequest struct {
|
||||
OutfitUUID string `json:"outfit_uuid" binding:"required"` // 穿搭UUID
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package dto
|
||||
|
||||
// ClosetShareCreateRequest 创建衣柜分享
|
||||
type ClosetShareCreateRequest struct {
|
||||
ShareType int8 `json:"share_type" binding:"required,oneof=1 2"` // 1=部分衣物分享 2=全量快照分享
|
||||
ClothesUUIDs []string `json:"clothes_uuids"` // 部分分享时必填(service校验)
|
||||
ExpireTime int64 `json:"expire_time"` // 过期时间
|
||||
}
|
||||
|
||||
// ClosetShareCreateResponse 创建分享响应
|
||||
type ClosetShareCreateResponse struct {
|
||||
ShareUUID string `json:"share_uuid"`
|
||||
}
|
||||
|
||||
// ClosetShareDetailResponse 分享详情(访客视角)
|
||||
type ClosetShareDetailResponse struct {
|
||||
ShareUUID string `json:"share_uuid"`
|
||||
OwnerName string `json:"owner_name"`
|
||||
OwnerAvatar string `json:"owner_avatar"`
|
||||
Clothes []ClothesGetByUUIDResponse `json:"clothes"`
|
||||
ExpireTime int64 `json:"expire_time"`
|
||||
Status int8 `json:"status"` // 1=正常 2=已取消 3=已过期 4=已封禁
|
||||
IsOwner bool `json:"is_owner"`
|
||||
}
|
||||
|
||||
// ClosetShareItemResponse 分享列表项
|
||||
type ClosetShareItemResponse struct {
|
||||
ShareUUID string `json:"share_uuid"` // 分享UUID
|
||||
ShareType int8 `json:"share_type"` // 分享类型 1=部分衣物分享 2=全量快照分享
|
||||
ClothesCount int `json:"clothes_count"` // 衣物数量
|
||||
ExpireTime int64 `json:"expire_time"` // 过期时间
|
||||
Status int8 `json:"status"` // 1=正常 2=已取消 3=已过期 4=已封禁
|
||||
OwnerName string `json:"owner_name"` // 分享者昵称("分享给我的"列表返回)
|
||||
OwnerAvatar string `json:"owner_avatar"` // 分享者头像("分享给我的"列表返回)
|
||||
}
|
||||
|
||||
// ShareListRequest 我的分享列表
|
||||
type ClosetShareListRequest struct {
|
||||
PageRequest
|
||||
Status int8 `form:"status" json:"status"` // 状态 1=正常 2=已取消 3=已过期 4=已封禁
|
||||
}
|
||||
|
||||
// ShareListResponse 我的分享列表
|
||||
type ShareListResponse struct {
|
||||
PageResponse
|
||||
List []ClosetShareItemResponse `json:"list"`
|
||||
}
|
||||
|
||||
// ClosetShareCancelRequest 取消分享
|
||||
type ClosetShareCancelRequest struct {
|
||||
ShareUUID string `json:"share_uuid" binding:"required"`
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package dto
|
||||
|
||||
// ClothesUploadRequest 上传衣物图片
|
||||
type ClothesUploadRequest struct{}
|
||||
|
||||
// ClothesUploadResponse 上传衣物图片响应
|
||||
type ClothesUploadResponse struct {
|
||||
ClothesUUID string `json:"clothes_uuid"` // 衣物UUID
|
||||
ImageProcessStatus int8 `json:"image_process_status"` // 1=抠图中 2=分类中 3=排队中 4=成功 5=失败
|
||||
}
|
||||
|
||||
type ClothesProcessStatusGetByUUIDRequest struct {
|
||||
ClothesUUID string `json:"clothes_uuid" binding:"required"`
|
||||
}
|
||||
|
||||
type ClothesProcessStatusGetByUUIDResponse struct {
|
||||
ImageProcessStatus int8 `json:"image_process_status"` // 1=抠图中 2=分类中 3=排队中 4=成功 5=失败
|
||||
FailReason string `json:"fail_reason"`
|
||||
}
|
||||
|
||||
// ClothesDeleteByUUIDRequest 删除衣物
|
||||
type ClothesDeleteByUUIDRequest struct {
|
||||
ClothesUUID string `json:"clothes_uuid" binding:"required"`
|
||||
}
|
||||
|
||||
// ClothesDeleteResponse 删除衣物响应
|
||||
type ClothesDeleteByUUIDResponse struct{}
|
||||
|
||||
// ClothesUpdateByUUIDRequest 修改衣物
|
||||
type ClothesUpdateByUUIDRequest struct {
|
||||
ClothesUUID string `json:"clothes_uuid" binding:"required"`
|
||||
CategoryMain string `json:"category_main" binding:"max=64"` // 衣物大类 上装/下装/连衣裙/外套/鞋/配饰
|
||||
CategorySub string `json:"category_sub" binding:"max=64"` // 衣物小类 T恤/牛仔裤/运动鞋/发带/戒指等
|
||||
Style string `json:"style" binding:"max=64"` // 风格
|
||||
ColorMain string `json:"color" binding:"max=64"` // 主颜色
|
||||
ColorSub string `json:"color_sub" binding:"max=64"` // 副颜色
|
||||
Thickness string `json:"thickness" binding:"max=64"` // 厚度
|
||||
Version string `json:"version" binding:"max=64"` // 版型 宽松/修身/阔腿/紧身/常规合身
|
||||
Material string `json:"material" binding:"max=64"` // 材质 牛仔布/针织/皮质/纯棉/羊毛等
|
||||
Pattern string `json:"pattern" binding:"max=64"` // 花纹
|
||||
SleeveType string `json:"sleeve_type" binding:"max=64"` // 袖型
|
||||
ClothesType string `json:"clothes_type" binding:"max=64"` // 衣长类型 短款/常规/中长/长款
|
||||
NecklineType string `json:"neckline_type" binding:"max=64"` // 领型
|
||||
Formality string `json:"formality" binding:"max=16"` // 正式程度 1-5
|
||||
Occasions string `json:"occasions" binding:"max=255"` // 适用场合
|
||||
VisualImpact string `json:"visual_impact" binding:"max=16"` // 视觉冲击力 1-5
|
||||
Name string `json:"name" binding:"max=64"` // 名称
|
||||
Remark string `json:"remark" binding:"max=500"` // 备注
|
||||
}
|
||||
|
||||
type ClothesUpdateByUUIDResponse struct{}
|
||||
|
||||
type ClothesGetByUUIDRequest struct {
|
||||
ClothesUUID string `json:"clothes_uuid" binding:"required"` // 衣物UUID
|
||||
}
|
||||
|
||||
// ClothesGetByUUIDResponse 衣物信息
|
||||
type ClothesGetByUUIDResponse struct {
|
||||
ClothesUUID string `json:"clothes_uuid"`
|
||||
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"`
|
||||
Name string `json:"name"`
|
||||
Remark string `json:"remark"`
|
||||
ProcessedImage string `json:"processed_image"`
|
||||
}
|
||||
|
||||
// ClothesListRequest 衣物列表
|
||||
type ClothesListRequest struct {
|
||||
PageRequest
|
||||
CategoryMain string `form:"category_main"` // 按大类筛选
|
||||
CategorySub string `form:"category_sub"`
|
||||
}
|
||||
|
||||
// ClothesListResponse 衣物列表
|
||||
type ClothesListResponse struct {
|
||||
PageResponse
|
||||
List []ClothesGetByUUIDResponse `json:"list"`
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package dto
|
||||
|
||||
// 收藏/取消收藏
|
||||
type CollectRequest struct {
|
||||
TargetUUID string `json:"target_uuid" binding:"required"`
|
||||
Action int8 `json:"action" binding:"required,oneof=1 2"` // 1=收藏 2=取消
|
||||
}
|
||||
|
||||
type CollectResponse struct{}
|
||||
|
||||
type CollectListRequest struct {
|
||||
PageRequest
|
||||
}
|
||||
|
||||
type CollectListResponse struct {
|
||||
PageResponse
|
||||
List []OutfitGetByUUIDResponse `json:"list"`
|
||||
}
|
||||
|
||||
// CollectGetByTargetRequest 查询穿搭收藏状态
|
||||
type CollectGetByTargetRequest struct {
|
||||
TargetUUID string `json:"target_uuid" binding:"required"` // 穿搭UUID
|
||||
}
|
||||
|
||||
// CollectGetByTargetResponse 查询穿搭收藏状态
|
||||
type CollectGetByTargetResponse struct {
|
||||
UserIsCollected bool `json:"is_collected"` // 是否已收藏
|
||||
CollectCount int64 `json:"collect_count"` // 收藏数
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package dto
|
||||
|
||||
type GoodsGetByCodeRequest struct {
|
||||
GoodsCode string `json:"goods_code" binding:"required"`
|
||||
}
|
||||
|
||||
// GoodsGetByCodeResponse 商品信息
|
||||
type GoodsGetByCodeResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
GoodsCode string `json:"goods_code"`
|
||||
GoodsName string `json:"goods_name"`
|
||||
GoodsType int8 `json:"goods_type"` // 1=会员包 2=上传包
|
||||
Price int64 `json:"price"` // 原价(分)
|
||||
DiscountPrice int64 `json:"discount_price"` // 售价(分)
|
||||
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"`
|
||||
ValidDays int64 `json:"valid_days"` // 赠送额度有效期天数(加量包;会员包为 0 表示跟随 VIP)
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// GoodsListRequest 商品信息
|
||||
type GoodsListRequest struct {
|
||||
PageRequest
|
||||
GoodsType int8 `form:"goods_type" json:"goods_type"` // 1=会员包 2=上传包 3=AI推荐包 4=衣柜分享包 5=搭配分享包
|
||||
}
|
||||
|
||||
// GoodsListResponse 商品列表
|
||||
type GoodsListResponse struct {
|
||||
PageResponse
|
||||
List []GoodsGetByCodeResponse `json:"list"`
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package dto
|
||||
|
||||
type HoroscopeRequest struct{}
|
||||
|
||||
// HoroscopeResponse 今日运势
|
||||
type HoroscopeResponse struct {
|
||||
StatDate int64 `json:"stat_date"` // YYYYMMDD
|
||||
Zodiac string `json:"zodiac"` // 星座
|
||||
OverallScore int8 `json:"overall_score"` // 1-5分
|
||||
LoveScore int8 `json:"love_score"` // 1-5分
|
||||
CareerScore int8 `json:"career_score"` // 1-5分
|
||||
WealthScore int8 `json:"wealth_score"` // 1-5分
|
||||
DailyText string `json:"daily_text"` // 今日运势
|
||||
LuckyColor string `json:"lucky_color"` // 今日幸运色
|
||||
LuckyNumber string `json:"lucky_number"` // 今日幸运数字
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package dto
|
||||
|
||||
// LikeCreateRequest 点赞/取消点赞
|
||||
type LikeCreateRequest struct {
|
||||
TargetUUID string `json:"target_uuid" binding:"required"` // 穿搭UUID
|
||||
Action int8 `json:"action" binding:"required,oneof=1 2"` // 1=点赞 2=取消
|
||||
}
|
||||
|
||||
// LikeResponse 点赞/取消点赞响应
|
||||
type LikeResponse struct{}
|
||||
|
||||
// LikeGetByTargetIDRequest 获取穿搭的点赞数
|
||||
type LikeGetByTargetIDRequest struct {
|
||||
TargetUUID string `json:"target_uuid" binding:"required"` // 穿搭UUID
|
||||
}
|
||||
|
||||
// LikeGetByTargetIDResponse 获取穿搭的点赞数
|
||||
type LikeGetByTargetIDResponse struct {
|
||||
UserIsLiked bool `json:"is_liked"` // 是否已点赞
|
||||
LikeCount int64 `json:"like_count"` // 点赞数
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package dto
|
||||
|
||||
// NotificationListRequest 通知列表
|
||||
type NotificationListRequest struct {
|
||||
PageRequest
|
||||
IsRead int8 `form:"is_read" binding:"omitempty,oneof=1 2"` // 1=未读 2=已读,不传=全部
|
||||
MsgType int8 `form:"msg_type" binding:"omitempty,oneof=1 2 3"` // 1=点赞 2=订单 3=系统,不传=全部
|
||||
}
|
||||
|
||||
// NotificationGetByUUIDRequest 获取通知详情
|
||||
type NotificationGetByUUIDRequest struct {
|
||||
NotificationUUID string `json:"notification_uuid" binding:"required"`
|
||||
}
|
||||
|
||||
// NotificationGetByUUIDResponse 通知项
|
||||
type NotificationGetByUUIDResponse struct {
|
||||
NotificationUUID string `json:"notification_uuid"` // 消息UUID
|
||||
MsgType int8 `json:"msg_type"` // 1=点赞通知 2=订单通知 3=系统通知
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
IsRead int8 `json:"is_read"` // 1=未读 2=已读
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
// NotificationListResponse 通知列表
|
||||
type NotificationListResponse struct {
|
||||
PageResponse
|
||||
List []NotificationGetByUUIDResponse `json:"list"`
|
||||
}
|
||||
|
||||
// NotificationReadRequest 标记已读
|
||||
type NotificationReadRequest struct {
|
||||
NotificationUUID string `json:"notification_uuid" binding:"required"`
|
||||
}
|
||||
|
||||
// NotificationUnreadResponse 未读数
|
||||
type NotificationUnreadResponse struct {
|
||||
UnreadCount int64 `json:"unread_count"`
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package dto
|
||||
|
||||
// OrderCreateRequest 创建订单
|
||||
type OrderCreateRequest struct {
|
||||
GoodsCode string `json:"goods_code" binding:"required"`
|
||||
}
|
||||
|
||||
// OrderCreateResponse 创建订单响应
|
||||
type OrderCreateResponse struct {
|
||||
OrderNo string `json:"order_no"`
|
||||
PayAmount int64 `json:"pay_amount"` // 应付(分)
|
||||
ExpireTime int64 `json:"expire_time"` // 支付截止(秒),前端倒计时
|
||||
PayParams *WechatPayParams `json:"pay_params"` // 小程序拉起支付参数
|
||||
}
|
||||
|
||||
// WechatPayParams 微信小程序支付参数
|
||||
type WechatPayParams struct {
|
||||
TimeStamp string `json:"timeStamp"`
|
||||
NonceStr string `json:"nonceStr"`
|
||||
Package string `json:"package"`
|
||||
SignType string `json:"signType"`
|
||||
PaySign string `json:"paySign"`
|
||||
}
|
||||
|
||||
// VirtualOrderCreateRequest 创建虚拟支付订单(小程序虚拟支付,覆盖 iOS/Android 全终端)
|
||||
type VirtualOrderCreateRequest struct {
|
||||
GoodsCode string `json:"goods_code" binding:"required"` // 商品编码
|
||||
Platform string `json:"platform" binding:"required,oneof=android ios harmony windows"` // 支付平台(前端按 systemInfo.platform 上报)
|
||||
}
|
||||
|
||||
// VirtualOrderCreateResponse 虚拟支付订单响应(前端据此调 wx.requestVirtualPayment)
|
||||
type VirtualOrderCreateResponse struct {
|
||||
OrderNo string `json:"order_no"` // 业务订单号(作为 outTradeNo)
|
||||
PayAmount int64 `json:"pay_amount"` // 实付金额(分)
|
||||
ExpireTime int64 `json:"expire_time"` // 支付截止(秒),前端倒计时
|
||||
SignData string `json:"sign_data"` // 参与签名的 JSON 字符串(原样传给前端)
|
||||
PaySig string `json:"pay_sig"` // 支付签名
|
||||
Signature string `json:"signature"` // 用户态签名
|
||||
}
|
||||
|
||||
// OrderResponse 订单信息
|
||||
type OrderResponse struct {
|
||||
OrderNo string `json:"order_no"`
|
||||
GoodsName string `json:"goods_name"`
|
||||
UnitPrice int64 `json:"unit_price"` // 单价(分)
|
||||
PayAmount int64 `json:"pay_amount"` // 实付(分)
|
||||
PayChannel int8 `json:"pay_channel"` // 支付渠道 1=微信支付 2=虚拟支付
|
||||
OrderStatus int8 `json:"order_status"` // 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款
|
||||
MemberDays int64 `json:"member_days"`
|
||||
ExpireTime int64 `json:"expire_time"`
|
||||
PayTime int64 `json:"pay_time"`
|
||||
RefundTime int64 `json:"refund_time"`
|
||||
RefundAmount int64 `json:"refund_amount"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
// OrderListRequest 订单列表
|
||||
type OrderListRequest struct {
|
||||
PageRequest
|
||||
Status int8 `form:"status" binding:"omitempty,oneof=1 2 3 4 5"`
|
||||
}
|
||||
|
||||
// OrderListResponse 订单列表
|
||||
type OrderListResponse struct {
|
||||
PageResponse
|
||||
List []OrderResponse `json:"list"`
|
||||
}
|
||||
|
||||
// OrderCancelRequest 取消订单
|
||||
type OrderCancelRequest struct {
|
||||
OrderNo string `json:"order_no" binding:"required"`
|
||||
}
|
||||
|
||||
// RefundApplyRequest 申请退款
|
||||
type RefundApplyRequest struct {
|
||||
OrderNo string `json:"order_no" binding:"required"`
|
||||
Reason string `json:"reason" binding:"required,max=200"`
|
||||
}
|
||||
|
||||
// RefundResponse 退款信息
|
||||
type RefundResponse struct {
|
||||
RefundNo string `json:"refund_no"`
|
||||
RefundAmount int64 `json:"refund_amount"`
|
||||
Status int8 `json:"status"` // 0=待处理 1=退款中 2=成功 3=失败
|
||||
ApplyTime int64 `json:"apply_time"`
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package dto
|
||||
|
||||
// OutfitCreateRequest 创建穿搭
|
||||
type OutfitCreateRequest struct {
|
||||
OutfitName string `json:"outfit_name" binding:"required,max=32"` // 穿搭名称
|
||||
OutfitDesc string `json:"outfit_desc" binding:"max=500"` // 穿搭描述
|
||||
Scene string `json:"scene" binding:"max=100"` // 适合场景
|
||||
Source int8 `json:"source" binding:"oneof=1 2"` // 1=用户创建 2=AI推荐
|
||||
Items []OutfitItemRequest `json:"items" binding:"required,min=1,max=10"`
|
||||
}
|
||||
|
||||
// OutfitItemRequest 穿搭中的衣物
|
||||
type OutfitItemRequest struct {
|
||||
ClothesUUID string `json:"clothes_uuid" binding:"required"`
|
||||
}
|
||||
|
||||
// OutfitCreateResponse 创建穿搭响应
|
||||
type OutfitCreateResponse struct {
|
||||
OutfitUUID string `json:"outfit_uuid"`
|
||||
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=不通过
|
||||
Items []OutfitItemResponse `json:"items"`
|
||||
}
|
||||
|
||||
// OutfitItemResponse 穿搭明细
|
||||
type OutfitItemResponse struct {
|
||||
ClothesUUID string `json:"clothes_uuid"`
|
||||
ProcessedImage string `json:"processed_image"` // 处理后图片可访问 URL(无则空)
|
||||
}
|
||||
|
||||
// OutfitGetByUUIDRequest 获取穿搭详情
|
||||
type OutfitGetByUUIDRequest struct {
|
||||
OutfitUUID string `json:"outfit_uuid" binding:"required"` // 穿搭UUID
|
||||
}
|
||||
|
||||
// OutfitGetByUUIDResponse 获取穿搭详情响应
|
||||
type OutfitGetByUUIDResponse struct {
|
||||
OutfitCreateResponse
|
||||
}
|
||||
|
||||
// OutfitUpdateByUUIDRequest 修改穿搭
|
||||
type OutfitUpdateByUUIDRequest struct {
|
||||
OutfitUUID string `json:"outfit_uuid" binding:"required"` // 穿搭UUID
|
||||
OutfitName string `json:"outfit_name" binding:"required,max=32"` // 穿搭名称
|
||||
OutfitDesc string `json:"outfit_desc" binding:"max=500"` // 穿搭描述
|
||||
Scene string `json:"scene" binding:"max=100"` // 适合场景
|
||||
Items []OutfitItemRequest `json:"items" binding:"required,min=1,max=10"` // 穿搭中的衣物
|
||||
}
|
||||
|
||||
// OutfitUpdateByUUIDResponse 修改穿搭响应
|
||||
type OutfitUpdateByUUIDResponse struct{}
|
||||
|
||||
// OutfitListRequest 获取我的穿搭列表
|
||||
type OutfitListRequest struct {
|
||||
PageRequest
|
||||
IsPublic int8 `form:"is_public" json:"is_public"` // 1=不公开 2=公开
|
||||
Source int8 `form:"source" json:"source"` // 1=用户创建 2=AI推荐
|
||||
}
|
||||
|
||||
// OutfitListResponse 我的穿搭列表响应
|
||||
type OutfitListResponse struct {
|
||||
PageResponse
|
||||
List []OutfitCreateResponse `json:"list"`
|
||||
}
|
||||
|
||||
// OutfitDeleteRequest 删除穿搭
|
||||
type OutfitDeleteRequest struct {
|
||||
OutfitUUID string `json:"outfit_uuid" binding:"required"`
|
||||
}
|
||||
|
||||
// OutfitDeleteResponse 删除穿搭响应
|
||||
type OutfitDeleteResponse struct{}
|
||||
|
||||
type OutfitPublicRequest struct {
|
||||
OutfitUUID string `json:"outfit_uuid" binding:"required"` // 穿搭UUID
|
||||
IsPublic int8 `json:"is_public" binding:"required,oneof=1 2"` // 1=不公开 2=公开
|
||||
}
|
||||
|
||||
type OutfitPublicResponse struct{}
|
||||
@@ -0,0 +1,44 @@
|
||||
package dto
|
||||
|
||||
// QuotaInfoGetBySelfRequest 我的额度
|
||||
type QuotaInfoGetBySelfRequest struct{}
|
||||
|
||||
// QuotaInfoGetBySelfResponse 我的额度
|
||||
type QuotaInfoGetBySelfResponse struct {
|
||||
Quotas []QuotaItemResponse `json:"quotas"`
|
||||
}
|
||||
|
||||
// QuotaItemResponse 单项额度。
|
||||
// Cos(quota_type=1):Total=永久总额度、Used=永久累计已用、Remain=永久剩余;
|
||||
// AI/社区/衣柜(2/3/4):Total=每日总额度、Used=当日已用、Remain=当日剩余(次日 0 点重置)。
|
||||
type QuotaItemResponse struct {
|
||||
QuotaType int8 `json:"quota_type"` // 1=cos上传 2=AI 3=社区分享 4=衣柜分享
|
||||
Total int64 `json:"total"` // 总额度(Cos 永久 / 每日额度)
|
||||
Used int64 `json:"used"` // 已用(Cos 永久累计 / 当日已用)
|
||||
Remain int64 `json:"remain"` // 剩余
|
||||
}
|
||||
|
||||
// QuotaFlowListRequest 额度流水查询
|
||||
type QuotaFlowListRequest struct {
|
||||
PageRequest
|
||||
QuotaType int8 `form:"quota_type" binding:"omitempty,oneof=1 2 3 4"` // 不传=全部
|
||||
}
|
||||
|
||||
// QuotaFlowItemResponse 额度流水明细
|
||||
type QuotaFlowItemResponse struct {
|
||||
QuotaFlowUUID string `json:"quota_flow_uuid"` // 配额流水UUID
|
||||
QuotaType int8 `json:"quota_type"` // 1=cos上传 2=AI 3=社区分享 4=衣柜分享
|
||||
ChangeTarget int8 `json:"change_target"` // 1=配额调整 2=使用
|
||||
ChangeType int8 `json:"change_type"` // 1=购买获赠 2=活动赠送 3=上传消耗 4=删除回退 5=人工调整
|
||||
ChangeNum int64 `json:"change_num"` // 变更数量(正增负减)
|
||||
NewValue int64 `json:"new_value"` // 变更后数值
|
||||
Reason string `json:"reason"` // 变更原因/备注
|
||||
OrderNo string `json:"order_no"` // 关联订单号(购买类)
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
// QuotaFlowListResponse 额度流水列表
|
||||
type QuotaFlowListResponse struct {
|
||||
PageResponse
|
||||
List []QuotaFlowItemResponse `json:"list"`
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package dto
|
||||
|
||||
// UserWxLoginRequest 微信登录请求
|
||||
type UserWxLoginRequest struct {
|
||||
Code string `json:"code" binding:"required"` // 微信登录code
|
||||
}
|
||||
|
||||
// UserWxLoginResponse 登录响应
|
||||
type UserWxLoginResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpireTime int64 `json:"expire_time"` // access_token过期时间(秒)
|
||||
UserInfo UserInfoGetBySelfResponse `json:"user_info"`
|
||||
}
|
||||
|
||||
type UserInfoGetBySelfRequest struct{}
|
||||
|
||||
// UserInfoGetBySelfResponse 用户信息
|
||||
type UserInfoGetBySelfResponse struct {
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
Sex int8 `json:"sex"` // 1=男 2=女
|
||||
BirthDay int64 `json:"birth_day"` // 生日
|
||||
Phone string `json:"phone"` // 手机号
|
||||
IsNewUser bool `json:"is_new_user"` // 是否本次新注册用户
|
||||
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"` // VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员
|
||||
VIPExpire int64 `json:"vip_expire"` // VIP到期时间
|
||||
}
|
||||
|
||||
// UserProfileUpdateBySelfRequest 修改用户资料
|
||||
type UserProfileUpdateBySelfRequest struct {
|
||||
Username string `json:"username" binding:"max=32"` // 昵称
|
||||
Avatar string `json:"avatar" binding:"max=500"` // 头像地址
|
||||
Sex int8 `json:"sex" binding:"omitempty,oneof=0 1 2"` // 0=未知 1=男 2=女
|
||||
BirthDay int64 `json:"birth_day"` // 生日
|
||||
}
|
||||
|
||||
// UserProfileUpdateBySelfResponse 修改用户资料响应
|
||||
type UserProfileUpdateBySelfResponse struct{}
|
||||
|
||||
// UserAvatarUploadRequest 上传头像(multipart,字段名 file)
|
||||
type UserAvatarUploadRequest struct{}
|
||||
|
||||
// UserAvatarUploadResponse 上传头像响应
|
||||
type UserAvatarUploadResponse struct {
|
||||
Avatar string `json:"avatar"` // 头像可访问 URL
|
||||
}
|
||||
|
||||
// UserRefreshTokenRequest 刷新用户token请求
|
||||
type UserRefreshTokenRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
// AccessToken 必填:旧 access token,用于刷新时立即吊销旧 access + 旧 refresh。
|
||||
// 会话以 token 字符串为 key 存在 Redis 中,缺少旧 access token 无法精确删除其会话 key,
|
||||
// 旧 token 会在 TTL 内继续可用;为空时返回 10000 + details:["缺少旧 access token"]。
|
||||
// 注意:允许传入已过期的旧 access token(此时会话已随 TTL 消失,仅吊销旧 refresh 与集合条目)。
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
|
||||
// UserRefreshTokenResponse 刷新用户token响应
|
||||
type UserRefreshTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpireTime int64 `json:"expire_time"` // access_token过期时间(秒)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CORS 跨域中间件。
|
||||
// 允许来源通过 Server.Domain 配置(如 test.com),自动放行其 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
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/lzcwyjwt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type ctxKey string
|
||||
|
||||
const (
|
||||
userIDKey ctxKey = "userID"
|
||||
)
|
||||
|
||||
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 是否仍在会话中(登出/失效后立即拒绝)。
|
||||
sessionUID, serr := redisdao.AuthUserIDByAccess(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):
|
||||
// 已确认的安全权衡决策——优先保证可用性,避免 Redis 抖动导致全员不可用。
|
||||
// 代价是 Redis 故障窗口内,已登出/被吊销但未过期的 token 仍可访问;
|
||||
// 封禁用户仍会被下方 DB 回查(Status != 1)拒绝,不会因本降级放行而绕过封禁。
|
||||
slog.WarnContext(c.Request.Context(), "check access token session failed, allow by fallback", "error", serr)
|
||||
} else if sessionUID != claims.UserID {
|
||||
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("token user mismatch"))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 从缓存获取用户信息,未命中或 Redis 出错都回落 DB 并回填缓存,检查封禁状态
|
||||
userInfo, err := redisdao.UserInfoGet(c.Request.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, redis.Nil) {
|
||||
slog.WarnContext(c.Request.Context(), "get user info from cache failed, fallback to db", "error", err)
|
||||
}
|
||||
// 缓存未命中或 Redis 抖动:回落数据库。
|
||||
dbUser, dbErr := mysqldao.NewUserDao().UserGetByID(c.Request.Context(), claims.UserID)
|
||||
if dbErr != nil {
|
||||
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("用户不存在"))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
userInfo = dbUser
|
||||
_ = redisdao.UserInfoSet(c.Request.Context(), dbUser, 0)
|
||||
}
|
||||
|
||||
// 状态非正常(1=正常)一律拒绝
|
||||
if userInfo.Status != 1 {
|
||||
response.ToErrorResponse(errcode.UserDisabled)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 把 userID注入 context
|
||||
ctx := context.WithValue(c.Request.Context(), userIDKey, claims.UserID)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserID 从 context 取登录用户 ID(在 handler 里用)
|
||||
func GetUserID(ctx context.Context) (int64, bool) {
|
||||
uid, ok := ctx.Value(userIDKey).(int64)
|
||||
return uid, ok
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-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-backend"
|
||||
if global.OtelSetting != nil && global.OtelSetting.ServiceName != "" {
|
||||
name = global.OtelSetting.ServiceName
|
||||
}
|
||||
return otelgin.Middleware(name)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package mysqldao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ClosetAuditLogDao 衣柜分享审核日志数据访问
|
||||
type ClosetAuditLogDao struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewClosetAuditLogDao 创建衣柜分享审核日志 DAO
|
||||
func NewClosetAuditLogDao() *ClosetAuditLogDao {
|
||||
return &ClosetAuditLogDao{db: global.MysqlEngine}
|
||||
}
|
||||
|
||||
// ClosetAuditLogCreate 创建衣柜分享审核日志
|
||||
func (d *ClosetAuditLogDao) ClosetAuditLogCreate(ctx context.Context, log *mysqlmodel.ClosetAuditLog) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetAuditLogCreate")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Create(log).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClosetAuditLogListByTargetID 查询目标衣柜分享的审核日志
|
||||
func (d *ClosetAuditLogDao) ClosetAuditLogListByTargetID(ctx context.Context, targetID int64) ([]mysqlmodel.ClosetAuditLog, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetAuditLogListByTargetID")
|
||||
defer span.End()
|
||||
|
||||
var list []mysqlmodel.ClosetAuditLog
|
||||
if err := d.db.WithContext(ctx).Where("target_id = ?", targetID).Find(&list).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// OutfitAuditLogDao 穿搭审核日志数据访问
|
||||
type OutfitAuditLogDao struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewOutfitAuditLogDao 创建穿搭审核日志 DAO
|
||||
func NewOutfitAuditLogDao() *OutfitAuditLogDao {
|
||||
return &OutfitAuditLogDao{db: global.MysqlEngine}
|
||||
}
|
||||
|
||||
// OutfitAuditLogCreate 创建穿搭审核日志
|
||||
func (d *OutfitAuditLogDao) OutfitAuditLogCreate(ctx context.Context, log *mysqlmodel.OutfitAuditLog) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitAuditLogCreate")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Create(log).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OutfitAuditLogListByTargetID 查询目标穿搭的审核日志
|
||||
func (d *OutfitAuditLogDao) OutfitAuditLogListByTargetID(ctx context.Context, targetID int64) ([]mysqlmodel.OutfitAuditLog, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitAuditLogListByTargetID")
|
||||
defer span.End()
|
||||
|
||||
var list []mysqlmodel.OutfitAuditLog
|
||||
if err := d.db.WithContext(ctx).Where("target_id = ?", targetID).Find(&list).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package mysqldao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// BenefitGrantTaskDao 权益发放补偿任务数据访问
|
||||
type BenefitGrantTaskDao struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewBenefitGrantTaskDao 创建补偿任务 DAO
|
||||
func NewBenefitGrantTaskDao() *BenefitGrantTaskDao {
|
||||
return &BenefitGrantTaskDao{db: global.MysqlEngine}
|
||||
}
|
||||
|
||||
// BenefitGrantTaskCreate 创建补偿任务,依赖唯一索引 (order_no, benefit_type) 防重复发放。
|
||||
func (d *BenefitGrantTaskDao) BenefitGrantTaskCreate(ctx context.Context, task *mysqlmodel.BenefitGrantTask) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.BenefitGrantTaskCreate")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Create(task).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BenefitGrantTaskGetByOrderNoAndType 按订单号+权益类型查询补偿任务。
|
||||
func (d *BenefitGrantTaskDao) BenefitGrantTaskGetByOrderNoAndType(ctx context.Context, orderNo string, benefitType int8) (*mysqlmodel.BenefitGrantTask, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.BenefitGrantTaskGetByOrderNoAndType")
|
||||
defer span.End()
|
||||
|
||||
var task mysqlmodel.BenefitGrantTask
|
||||
if err := d.db.WithContext(ctx).
|
||||
Where("order_no = ? AND benefit_type = ?", orderNo, benefitType).
|
||||
First(&task).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
// BenefitGrantTaskUpdateByID 按主键更新补偿任务字段。
|
||||
func (d *BenefitGrantTaskDao) BenefitGrantTaskUpdateByID(ctx context.Context, id int64, updates map[string]any) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.BenefitGrantTaskUpdateByID")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.BenefitGrantTask{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BenefitGrantTaskListPending 查询待发放的补偿任务(status=0),按主键升序。
|
||||
func (d *BenefitGrantTaskDao) BenefitGrantTaskListPending(ctx context.Context, limit int) ([]mysqlmodel.BenefitGrantTask, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.BenefitGrantTaskListPending")
|
||||
defer span.End()
|
||||
|
||||
var list []mysqlmodel.BenefitGrantTask
|
||||
if err := d.db.WithContext(ctx).
|
||||
Where("status = ?", 0).
|
||||
Order("id ASC").
|
||||
Limit(limit).
|
||||
Find(&list).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// BenefitGrantTaskReset 将失败任务重置为待发放(首次发放遇到 failed 记录时复用)。
|
||||
// 保留 retry_count:不归零,保证重试上限与人工介入机制不被重置绕过;
|
||||
// 调用方应配合冷却期使用,避免高频回调无限重置。
|
||||
func (d *BenefitGrantTaskDao) BenefitGrantTaskReset(ctx context.Context, id int64) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.BenefitGrantTaskReset")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.BenefitGrantTask{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{"status": 0, "last_error": ""}).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BenefitGrantTaskClaim 条件认领任务(待发放/失败 -> 发放中),返回是否认领成功,防并发重复发放。
|
||||
func (d *BenefitGrantTaskDao) BenefitGrantTaskClaim(ctx context.Context, id int64) (bool, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.BenefitGrantTaskClaim")
|
||||
defer span.End()
|
||||
|
||||
res := d.db.WithContext(ctx).Model(&mysqlmodel.BenefitGrantTask{}).
|
||||
Where("id = ? AND status IN (?, ?)", id, 0, 2).
|
||||
Update("status", 3)
|
||||
if res.Error != nil {
|
||||
span.RecordError(res.Error)
|
||||
return false, res.Error
|
||||
}
|
||||
return res.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// BenefitGrantTaskRecoverStale 将「发放中」且超过一定时间未完成的任务重置为待发放(认领后崩溃的恢复)。
|
||||
func (d *BenefitGrantTaskDao) BenefitGrantTaskRecoverStale(ctx context.Context, beforeUpdatedAt int64) (int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.BenefitGrantTaskRecoverStale")
|
||||
defer span.End()
|
||||
|
||||
res := d.db.WithContext(ctx).Model(&mysqlmodel.BenefitGrantTask{}).
|
||||
Where("status = ? AND updated_at < ?", 3, beforeUpdatedAt).
|
||||
Update("status", 0)
|
||||
if res.Error != nil {
|
||||
span.RecordError(res.Error)
|
||||
return 0, res.Error
|
||||
}
|
||||
return res.RowsAffected, nil
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package mysqldao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// ClosetShareDao 衣柜分享数据访问
|
||||
type ClosetShareDao struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewClosetShareDao 创建衣柜分享 DAO
|
||||
func NewClosetShareDao() *ClosetShareDao {
|
||||
return &ClosetShareDao{db: global.MysqlEngine}
|
||||
}
|
||||
|
||||
// ClosetShareCreate 创建分享
|
||||
func (d *ClosetShareDao) ClosetShareCreate(ctx context.Context, share *mysqlmodel.ClosetShare) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareCreate")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Create(share).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// ClosetShareListByUserID 分页查询我的分享(status 为 0 不筛选)
|
||||
func (d *ClosetShareDao) ClosetShareListByUserID(ctx context.Context, userID int64, status int8, page, pageSize int) ([]mysqlmodel.ClosetShare, int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareListByUserID")
|
||||
defer span.End()
|
||||
|
||||
var list []mysqlmodel.ClosetShare
|
||||
var total int64
|
||||
q := d.db.WithContext(ctx).Model(&mysqlmodel.ClosetShare{}).Where("user_id = ?", userID)
|
||||
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
|
||||
}
|
||||
|
||||
// ClosetShareItemDao 分享明细数据访问
|
||||
type ClosetShareItemDao struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewClosetShareItemDao 创建分享明细 DAO
|
||||
func NewClosetShareItemDao() *ClosetShareItemDao {
|
||||
return &ClosetShareItemDao{db: global.MysqlEngine}
|
||||
}
|
||||
|
||||
// ClosetShareItemCreateBatch 批量创建分享明细
|
||||
func (d *ClosetShareItemDao) ClosetShareItemCreateBatch(ctx context.Context, items []mysqlmodel.ClosetShareItem) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareItemCreateBatch")
|
||||
defer span.End()
|
||||
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := d.db.WithContext(ctx).Create(&items).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClosetShareItemDeleteByShareUUID 删除分享明细
|
||||
func (d *ClosetShareItemDao) ClosetShareItemDeleteByShareUUID(ctx context.Context, shareUUID string) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareItemDeleteByShareUUID")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Where("share_uuid = ?", shareUUID).Delete(&mysqlmodel.ClosetShareItem{}).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClosetShareItemListByShareUUID 查询分享明细
|
||||
func (d *ClosetShareItemDao) ClosetShareItemListByShareUUID(ctx context.Context, shareUUID string) ([]mysqlmodel.ClosetShareItem, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareItemListByShareUUID")
|
||||
defer span.End()
|
||||
|
||||
var list []mysqlmodel.ClosetShareItem
|
||||
if err := d.db.WithContext(ctx).Where("share_uuid = ?", shareUUID).Find(&list).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// ClosetShareItemListByShareUUIDs 批量查询分享明细
|
||||
func (d *ClosetShareItemDao) ClosetShareItemListByShareUUIDs(ctx context.Context, shareUUIDs []string) ([]mysqlmodel.ClosetShareItem, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareItemListByShareUUIDs")
|
||||
defer span.End()
|
||||
|
||||
var list []mysqlmodel.ClosetShareItem
|
||||
if len(shareUUIDs) == 0 {
|
||||
return list, nil
|
||||
}
|
||||
if err := d.db.WithContext(ctx).Where("share_uuid IN ?", shareUUIDs).Find(&list).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// ClosetShareReceiverDao 衣柜分享接收人数据访问
|
||||
type ClosetShareReceiverDao struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewClosetShareReceiverDao 创建衣柜分享接收人 DAO
|
||||
func NewClosetShareReceiverDao() *ClosetShareReceiverDao {
|
||||
return &ClosetShareReceiverDao{db: global.MysqlEngine}
|
||||
}
|
||||
|
||||
// ClosetShareReceiverUpsert 幂等记录接收关系:唯一索引冲突时不做任何事,避免重复记录。
|
||||
func (d *ClosetShareReceiverDao) ClosetShareReceiverUpsert(ctx context.Context, shareUUID string, receiverUserID int64) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareReceiverUpsert")
|
||||
defer span.End()
|
||||
|
||||
receiver := &mysqlmodel.ClosetShareReceiver{
|
||||
ShareUUID: shareUUID,
|
||||
ReceiverUserID: receiverUserID,
|
||||
}
|
||||
if err := d.db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(receiver).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClosetShareReceivedList 联表分页查询某接收人收到的分享(status 为 0 不筛选)。
|
||||
func (d *ClosetShareReceiverDao) ClosetShareReceivedList(ctx context.Context, receiverUserID int64, status int8, page, pageSize int) ([]mysqlmodel.ClosetShare, int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareReceivedList")
|
||||
defer span.End()
|
||||
|
||||
var list []mysqlmodel.ClosetShare
|
||||
var total int64
|
||||
|
||||
q := d.db.WithContext(ctx).
|
||||
Model(&mysqlmodel.ClosetShare{}).
|
||||
Joins("JOIN closet_share_receivers r ON r.share_uuid = closet_shares.share_uuid").
|
||||
Where("r.receiver_user_id = ?", receiverUserID)
|
||||
if status != 0 {
|
||||
q = q.Where("closet_shares.status = ?", status)
|
||||
}
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := q.Select("closet_shares.*").
|
||||
Order("r.id DESC").
|
||||
Offset((page - 1) * pageSize).
|
||||
Limit(pageSize).
|
||||
Find(&list).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, 0, err
|
||||
}
|
||||
return list, total, nil
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package mysqldao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ClothesDao 衣物数据访问
|
||||
type ClothesDao struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewClothesDao 创建衣物 DAO
|
||||
func NewClothesDao() *ClothesDao {
|
||||
return &ClothesDao{db: global.MysqlEngine}
|
||||
}
|
||||
|
||||
// ClothesCreate 创建衣物
|
||||
func (d *ClothesDao) ClothesCreate(ctx context.Context, clothes *mysqlmodel.Clothes) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesCreate")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Create(clothes).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClothesDeleteByUUID 按 UUID 软删除衣物
|
||||
func (d *ClothesDao) ClothesDeleteByUUID(ctx context.Context, clothesUUID string) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesDeleteByUUID")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Where("clothes_uuid = ?", clothesUUID).Delete(&mysqlmodel.Clothes{}).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClothesUpdateByUUID 按 UUID 更新衣物字段
|
||||
func (d *ClothesDao) ClothesUpdateByUUID(ctx context.Context, clothesUUID string, updates map[string]any) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesUpdateByUUID")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Clothes{}).Where("clothes_uuid = ?", clothesUUID).Updates(updates).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return 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
|
||||
}
|
||||
|
||||
// ClothesGetByID 按主键 ID 查询衣物
|
||||
func (d *ClothesDao) ClothesGetByID(ctx context.Context, id int64) (*mysqlmodel.Clothes, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesGetByID")
|
||||
defer span.End()
|
||||
|
||||
var clothes mysqlmodel.Clothes
|
||||
if err := d.db.WithContext(ctx).First(&clothes, id).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return &clothes, nil
|
||||
}
|
||||
|
||||
// ClothesGetByUUIDs 按 UUID 批量查询衣物
|
||||
func (d *ClothesDao) ClothesGetByUUIDs(ctx context.Context, uuids []string) ([]mysqlmodel.Clothes, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesGetByUUIDs")
|
||||
defer span.End()
|
||||
|
||||
var list []mysqlmodel.Clothes
|
||||
if len(uuids) == 0 {
|
||||
return list, nil
|
||||
}
|
||||
if err := d.db.WithContext(ctx).Where("clothes_uuid IN ?", uuids).Find(&list).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// ClothesGetByIDs 按主键 ID 批量查询衣物
|
||||
func (d *ClothesDao) ClothesGetByIDs(ctx context.Context, ids []int64) ([]mysqlmodel.Clothes, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesGetByIDs")
|
||||
defer span.End()
|
||||
|
||||
var list []mysqlmodel.Clothes
|
||||
if len(ids) == 0 {
|
||||
return list, nil
|
||||
}
|
||||
if err := d.db.WithContext(ctx).Where("id IN ?", ids).Find(&list).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// ClothesListByUserID 分页查询用户衣物(categoryMain/categorySub 为空不筛选)
|
||||
func (d *ClothesDao) ClothesListByUserID(ctx context.Context, userID int64, categoryMain, categorySub string, page, pageSize int) ([]mysqlmodel.Clothes, int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesListByUserID")
|
||||
defer span.End()
|
||||
|
||||
var list []mysqlmodel.Clothes
|
||||
var total int64
|
||||
q := d.db.WithContext(ctx).Model(&mysqlmodel.Clothes{}).Where("user_id = ?", userID)
|
||||
if categoryMain != "" {
|
||||
q = q.Where("category_main = ?", categoryMain)
|
||||
}
|
||||
if categorySub != "" {
|
||||
q = q.Where("category_sub = ?", categorySub)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package mysqldao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// CollectDao 收藏数据访问
|
||||
type CollectDao struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewCollectDao 创建收藏 DAO
|
||||
func NewCollectDao() *CollectDao {
|
||||
return &CollectDao{db: global.MysqlEngine}
|
||||
}
|
||||
|
||||
// CollectCreate 创建收藏记录
|
||||
func (d *CollectDao) CollectCreate(ctx context.Context, collect *mysqlmodel.Collect) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.CollectCreate")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Create(collect).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CollectUpsertOn 收藏幂等写入:唯一索引冲突时仅置 status=1,防并发重复插入。
|
||||
func (d *CollectDao) CollectUpsertOn(ctx context.Context, collect *mysqlmodel.Collect) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.CollectUpsertOn")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "collect_type"}, {Name: "target_id"}, {Name: "user_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"status"}),
|
||||
}).Create(collect).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CollectUpdateStatus 更新收藏状态(1=已收藏 0=已取消)
|
||||
func (d *CollectDao) CollectUpdateStatus(ctx context.Context, id int64, status int8) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.CollectUpdateStatus")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Collect{}).Where("id = ?", id).UpdateColumn("status", status).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CollectGetByTargetAndUser 查询用户在目标上的收藏记录
|
||||
func (d *CollectDao) CollectGetByTargetAndUser(ctx context.Context, collectType int8, targetID, userID int64) (*mysqlmodel.Collect, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.CollectGetByTargetAndUser")
|
||||
defer span.End()
|
||||
|
||||
var collect mysqlmodel.Collect
|
||||
if err := d.db.WithContext(ctx).Where("collect_type = ? AND target_id = ? AND user_id = ?", collectType, targetID, userID).First(&collect).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return &collect, nil
|
||||
}
|
||||
|
||||
// CollectCountByTarget 统计目标收藏数(仅 status=1)
|
||||
func (d *CollectDao) CollectCountByTarget(ctx context.Context, collectType int8, targetID int64) (int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.CollectCountByTarget")
|
||||
defer span.End()
|
||||
|
||||
var count int64
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Collect{}).
|
||||
Where("collect_type = ? AND target_id = ? AND status = ?", collectType, targetID, int8(1)).
|
||||
Count(&count).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// CollectListTargetIDsByUserPage 分页查询用户收藏的目标ID列表(仅 status=1),返回 ids 与总数。
|
||||
func (d *CollectDao) CollectListTargetIDsByUserPage(ctx context.Context, collectType int8, userID int64, page, pageSize int) ([]int64, int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.CollectListTargetIDsByUserPage")
|
||||
defer span.End()
|
||||
|
||||
q := d.db.WithContext(ctx).Model(&mysqlmodel.Collect{}).
|
||||
Where("collect_type = ? AND user_id = ? AND status = ?", collectType, userID, int8(1))
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var ids []int64
|
||||
if err := q.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Pluck("target_id", &ids).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, 0, err
|
||||
}
|
||||
return ids, total, nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package mysqldao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GoodsDao 商品数据访问
|
||||
type GoodsDao struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewGoodsDao 创建商品 DAO
|
||||
func NewGoodsDao() *GoodsDao {
|
||||
return &GoodsDao{db: global.MysqlEngine}
|
||||
}
|
||||
|
||||
// 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).Where("goods_code = ?", code).First(&goods).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return &goods, 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
|
||||
}
|
||||
|
||||
// GoodsList 分页查询上架商品(goodsType 为 0 不筛选)
|
||||
func (d *GoodsDao) GoodsList(ctx context.Context, goodsType int8, page, pageSize int) ([]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{}).Where("status = ?", int8(1))
|
||||
if goodsType != 0 {
|
||||
q = q.Where("goods_type = ?", goodsType)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package mysqldao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// HoroscopeDao 运势数据访问
|
||||
type HoroscopeDao struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewHoroscopeDao 创建运势 DAO
|
||||
func NewHoroscopeDao() *HoroscopeDao {
|
||||
return &HoroscopeDao{db: global.MysqlEngine}
|
||||
}
|
||||
|
||||
// HoroscopeCreate 创建运势记录
|
||||
func (d *HoroscopeDao) HoroscopeCreate(ctx context.Context, horoscope *mysqlmodel.Horoscope) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.HoroscopeCreate")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Create(horoscope).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HoroscopeGetByZodiacAndDate 按星座和日期查询运势(幂等)
|
||||
func (d *HoroscopeDao) HoroscopeGetByZodiacAndDate(ctx context.Context, zodiac string, statDate int64) (*mysqlmodel.Horoscope, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.HoroscopeGetByZodiacAndDate")
|
||||
defer span.End()
|
||||
|
||||
var horoscope mysqlmodel.Horoscope
|
||||
if err := d.db.WithContext(ctx).Where("zodiac = ? AND stat_date = ?", zodiac, statDate).First(&horoscope).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return &horoscope, nil
|
||||
}
|
||||
|
||||
// HoroscopeGetByDate 按日期查询当日全部星座运势。
|
||||
func (d *HoroscopeDao) HoroscopeGetByDate(ctx context.Context, statDate int64) ([]mysqlmodel.Horoscope, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.HoroscopeGetByDate")
|
||||
defer span.End()
|
||||
|
||||
var list []mysqlmodel.Horoscope
|
||||
if err := d.db.WithContext(ctx).Where("stat_date = ?", statDate).Find(&list).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// AIHoroscopeFlowLogDao 星座运势 AI 流水数据访问
|
||||
type AIHoroscopeFlowLogDao struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAIHoroscopeFlowLogDao 创建星座运势 AI 流水 DAO
|
||||
func NewAIHoroscopeFlowLogDao() *AIHoroscopeFlowLogDao {
|
||||
return &AIHoroscopeFlowLogDao{db: global.MysqlEngine}
|
||||
}
|
||||
|
||||
// AIHoroscopeFlowLogCreate 创建星座运势 AI 流水
|
||||
func (d *AIHoroscopeFlowLogDao) AIHoroscopeFlowLogCreate(ctx context.Context, log *mysqlmodel.AIHoroscopeFlowLog) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.AIHoroscopeFlowLogCreate")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Create(log).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AIHoroscopeFlowLogGetByRequestID 按请求ID查询流水(幂等)
|
||||
func (d *AIHoroscopeFlowLogDao) AIHoroscopeFlowLogGetByRequestID(ctx context.Context, requestID string) (*mysqlmodel.AIHoroscopeFlowLog, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.AIHoroscopeFlowLogGetByRequestID")
|
||||
defer span.End()
|
||||
|
||||
var log mysqlmodel.AIHoroscopeFlowLog
|
||||
if err := d.db.WithContext(ctx).Where("request_id = ?", requestID).First(&log).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return &log, nil
|
||||
}
|
||||
|
||||
// AIRecommendFlowLogDao AI 推荐流水数据访问
|
||||
type AIRecommendFlowLogDao struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAIRecommendFlowLogDao 创建 AI 推荐流水 DAO
|
||||
func NewAIRecommendFlowLogDao() *AIRecommendFlowLogDao {
|
||||
return &AIRecommendFlowLogDao{db: global.MysqlEngine}
|
||||
}
|
||||
|
||||
// AIRecommendFlowLogCreate 创建 AI 推荐流水
|
||||
func (d *AIRecommendFlowLogDao) AIRecommendFlowLogCreate(ctx context.Context, log *mysqlmodel.AIRecommendFlowLog) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.AIRecommendFlowLogCreate")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Create(log).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AIRecommendFlowLogGetByRequestID 按请求ID查询流水(幂等)
|
||||
func (d *AIRecommendFlowLogDao) AIRecommendFlowLogGetByRequestID(ctx context.Context, requestID string) (*mysqlmodel.AIRecommendFlowLog, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.AIRecommendFlowLogGetByRequestID")
|
||||
defer span.End()
|
||||
|
||||
var log mysqlmodel.AIRecommendFlowLog
|
||||
if err := d.db.WithContext(ctx).Where("request_id = ?", requestID).First(&log).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return &log, nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package mysqldao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// LikeDao 点赞数据访问
|
||||
type LikeDao struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewLikeDao 创建点赞 DAO
|
||||
func NewLikeDao() *LikeDao {
|
||||
return &LikeDao{db: global.MysqlEngine}
|
||||
}
|
||||
|
||||
// LikeCreate 创建点赞记录
|
||||
func (d *LikeDao) LikeCreate(ctx context.Context, like *mysqlmodel.Like) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.LikeCreate")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Create(like).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LikeUpsertOn 点赞幂等写入:唯一索引冲突时仅置 status=1,防并发重复插入。
|
||||
func (d *LikeDao) LikeUpsertOn(ctx context.Context, like *mysqlmodel.Like) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.LikeUpsertOn")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "like_type"}, {Name: "target_id"}, {Name: "user_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"status"}),
|
||||
}).Create(like).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LikeUpdateStatus 更新点赞状态(1=已点赞 0=已取消)
|
||||
func (d *LikeDao) LikeUpdateStatus(ctx context.Context, id int64, status int8) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.LikeUpdateStatus")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Like{}).Where("id = ?", id).UpdateColumn("status", status).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LikeGetByTargetAndUser 查询用户在目标上的点赞记录
|
||||
func (d *LikeDao) LikeGetByTargetAndUser(ctx context.Context, likeType int8, targetID, userID int64) (*mysqlmodel.Like, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.LikeGetByTargetAndUser")
|
||||
defer span.End()
|
||||
|
||||
var like mysqlmodel.Like
|
||||
if err := d.db.WithContext(ctx).Where("like_type = ? AND target_id = ? AND user_id = ?", likeType, targetID, userID).First(&like).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return &like, nil
|
||||
}
|
||||
|
||||
// LikeCountByTarget 统计目标点赞数(仅 status=1)
|
||||
func (d *LikeDao) LikeCountByTarget(ctx context.Context, likeType int8, targetID int64) (int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.LikeCountByTarget")
|
||||
defer span.End()
|
||||
|
||||
var count int64
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Like{}).
|
||||
Where("like_type = ? AND target_id = ? AND status = ?", likeType, targetID, int8(1)).
|
||||
Count(&count).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// LikeListUserIDsByTarget 查询点赞用户ID列表(仅 status=1)
|
||||
func (d *LikeDao) LikeListUserIDsByTarget(ctx context.Context, likeType int8, targetID int64) ([]int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.LikeListUserIDsByTarget")
|
||||
defer span.End()
|
||||
|
||||
var ids []int64
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Like{}).
|
||||
Where("like_type = ? AND target_id = ? AND status = ?", likeType, targetID, int8(1)).
|
||||
Pluck("user_id", &ids).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package mysqldao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// NotificationDao 用户通知数据访问
|
||||
type NotificationDao struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewNotificationDao 创建通知 DAO
|
||||
func NewNotificationDao() *NotificationDao {
|
||||
return &NotificationDao{db: global.MysqlEngine}
|
||||
}
|
||||
|
||||
// NotificationCreate 创建通知
|
||||
func (d *NotificationDao) NotificationCreate(ctx context.Context, notification *mysqlmodel.UserNotification) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.NotificationCreate")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Create(notification).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotificationUpdateReadByUUID 标记通知已读
|
||||
func (d *NotificationDao) NotificationUpdateReadByUUID(ctx context.Context, notificationUUID string) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.NotificationUpdateReadByUUID")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.UserNotification{}).
|
||||
Where("notification_uuid = ?", notificationUUID).
|
||||
Update("is_read", int8(2)).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotificationUpdateReadByUUIDIfUnread 条件标记已读(仅未读 -> 已读),返回是否更新成功,
|
||||
// 用于防并发重复扣减未读数。
|
||||
func (d *NotificationDao) NotificationUpdateReadByUUIDIfUnread(ctx context.Context, notificationUUID string) (bool, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.NotificationUpdateReadByUUIDIfUnread")
|
||||
defer span.End()
|
||||
|
||||
res := d.db.WithContext(ctx).Model(&mysqlmodel.UserNotification{}).
|
||||
Where("notification_uuid = ? AND is_read = ?", notificationUUID, int8(1)).
|
||||
Update("is_read", int8(2))
|
||||
if res.Error != nil {
|
||||
span.RecordError(res.Error)
|
||||
return false, res.Error
|
||||
}
|
||||
return res.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// NotificationGetByUUID 按 UUID 查询通知
|
||||
func (d *NotificationDao) NotificationGetByUUID(ctx context.Context, notificationUUID string) (*mysqlmodel.UserNotification, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.NotificationGetByUUID")
|
||||
defer span.End()
|
||||
|
||||
var notification mysqlmodel.UserNotification
|
||||
if err := d.db.WithContext(ctx).Where("notification_uuid = ?", notificationUUID).First(¬ification).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return ¬ification, nil
|
||||
}
|
||||
|
||||
// NotificationCountUnread 统计用户未读数
|
||||
func (d *NotificationDao) NotificationCountUnread(ctx context.Context, userID int64) (int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.NotificationCountUnread")
|
||||
defer span.End()
|
||||
|
||||
var count int64
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.UserNotification{}).
|
||||
Where("user_id = ? AND is_read = ?", userID, int8(1)).
|
||||
Count(&count).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// NotificationListByUserID 分页查询用户通知(isRead/msgType 为 0 不筛选)
|
||||
func (d *NotificationDao) NotificationListByUserID(ctx context.Context, userID int64, isRead, msgType int8, page, pageSize int) ([]mysqlmodel.UserNotification, int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.NotificationListByUserID")
|
||||
defer span.End()
|
||||
|
||||
var list []mysqlmodel.UserNotification
|
||||
var total int64
|
||||
q := d.db.WithContext(ctx).Model(&mysqlmodel.UserNotification{}).Where("user_id = ?", userID)
|
||||
if isRead != 0 {
|
||||
q = q.Where("is_read = ?", isRead)
|
||||
}
|
||||
if msgType != 0 {
|
||||
q = q.Where("msg_type = ?", msgType)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package mysqldao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OrderDao 订单数据访问
|
||||
type OrderDao struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewOrderDao 创建订单 DAO
|
||||
func NewOrderDao() *OrderDao {
|
||||
return &OrderDao{db: global.MysqlEngine}
|
||||
}
|
||||
|
||||
// OrderCreate 创建订单
|
||||
func (d *OrderDao) OrderCreate(ctx context.Context, order *mysqlmodel.Order) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderCreate")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Create(order).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OrderUpdateByOrderNo 按订单号更新订单字段
|
||||
func (d *OrderDao) OrderUpdateByOrderNo(ctx context.Context, orderNo string, updates map[string]any) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderUpdateByOrderNo")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}).Where("order_no = ?", orderNo).Updates(updates).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OrderUpdateStatusByOrderNo 条件流转订单状态(防重复回调),返回是否更新成功
|
||||
func (d *OrderDao) OrderUpdateStatusByOrderNo(ctx context.Context, orderNo string, fromStatus, toStatus int8) (bool, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderUpdateStatusByOrderNo")
|
||||
defer span.End()
|
||||
|
||||
res := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}).
|
||||
Where("order_no = ? AND order_status = ?", orderNo, fromStatus).
|
||||
Update("order_status", toStatus)
|
||||
if res.Error != nil {
|
||||
span.RecordError(res.Error)
|
||||
return false, res.Error
|
||||
}
|
||||
return res.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// OrderExpireByTime 将超过支付有效期的待支付订单批量置为已过期,返回更新条数。
|
||||
// 状态字面量:1=待支付 4=已过期(与 service 层 OrderStatusPending/OrderStatusExpired 一致)。
|
||||
func (d *OrderDao) OrderExpireByTime(ctx context.Context, now int64) (int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderExpireByTime")
|
||||
defer span.End()
|
||||
|
||||
res := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}).
|
||||
Where("order_status = ? AND expire_time < ?", 1, now).
|
||||
Update("order_status", 4)
|
||||
if res.Error != nil {
|
||||
span.RecordError(res.Error)
|
||||
return 0, res.Error
|
||||
}
|
||||
return res.RowsAffected, 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// OrderListByUserID 分页查询用户订单(status 为 0 不筛选)
|
||||
func (d *OrderDao) OrderListByUserID(ctx context.Context, userID int64, status int8, page, pageSize int) ([]mysqlmodel.Order, int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderListByUserID")
|
||||
defer span.End()
|
||||
|
||||
var list []mysqlmodel.Order
|
||||
var total int64
|
||||
q := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}).Where("user_id = ?", userID)
|
||||
if status != 0 {
|
||||
q = q.Where("order_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
|
||||
}
|
||||
|
||||
// RefundFlowLogDao 退款流水数据访问
|
||||
type RefundFlowLogDao struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewRefundFlowLogDao 创建退款流水 DAO
|
||||
func NewRefundFlowLogDao() *RefundFlowLogDao {
|
||||
return &RefundFlowLogDao{db: global.MysqlEngine}
|
||||
}
|
||||
|
||||
// RefundFlowLogCreate 创建退款流水
|
||||
func (d *RefundFlowLogDao) RefundFlowLogCreate(ctx context.Context, log *mysqlmodel.RefundFlowLog) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.RefundFlowLogCreate")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Create(log).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefundFlowLogUpdateStatus 按退款单号更新退款流水字段
|
||||
func (d *RefundFlowLogDao) RefundFlowLogUpdateStatus(ctx context.Context, refundNo string, updates map[string]any) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.RefundFlowLogUpdateStatus")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.RefundFlowLog{}).Where("refund_no = ?", refundNo).Updates(updates).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefundFlowLogUpdateStatusByFrom 条件流转退款流水状态(防并发/乱序回调覆盖),返回是否更新成功。
|
||||
func (d *RefundFlowLogDao) RefundFlowLogUpdateStatusByFrom(ctx context.Context, refundNo string, fromStatus int8, updates map[string]any) (bool, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.RefundFlowLogUpdateStatusByFrom")
|
||||
defer span.End()
|
||||
|
||||
res := d.db.WithContext(ctx).Model(&mysqlmodel.RefundFlowLog{}).
|
||||
Where("refund_no = ? AND status = ?", refundNo, fromStatus).
|
||||
Updates(updates)
|
||||
if res.Error != nil {
|
||||
span.RecordError(res.Error)
|
||||
return false, res.Error
|
||||
}
|
||||
return res.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// RefundFlowLogGetByRefundNo 按退款单号查询退款流水
|
||||
func (d *RefundFlowLogDao) RefundFlowLogGetByRefundNo(ctx context.Context, refundNo string) (*mysqlmodel.RefundFlowLog, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.RefundFlowLogGetByRefundNo")
|
||||
defer span.End()
|
||||
|
||||
var log mysqlmodel.RefundFlowLog
|
||||
if err := d.db.WithContext(ctx).Where("refund_no = ?", refundNo).First(&log).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return &log, nil
|
||||
}
|
||||
|
||||
// RefundFlowLogGetByOrderNo 按商户订单号查询退款流水
|
||||
func (d *RefundFlowLogDao) RefundFlowLogGetByOrderNo(ctx context.Context, orderNo string) (*mysqlmodel.RefundFlowLog, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.RefundFlowLogGetByOrderNo")
|
||||
defer span.End()
|
||||
|
||||
var log mysqlmodel.RefundFlowLog
|
||||
if err := d.db.WithContext(ctx).Where("order_no = ?", orderNo).First(&log).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return &log, nil
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package mysqldao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OutfitDao 穿搭数据访问
|
||||
type OutfitDao struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewOutfitDao 创建穿搭 DAO
|
||||
func NewOutfitDao() *OutfitDao {
|
||||
return &OutfitDao{db: global.MysqlEngine}
|
||||
}
|
||||
|
||||
// OutfitCreate 创建穿搭
|
||||
func (d *OutfitDao) OutfitCreate(ctx context.Context, outfit *mysqlmodel.Outfit) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitCreate")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Create(outfit).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OutfitDeleteByUUID 按 UUID 软删除穿搭
|
||||
func (d *OutfitDao) OutfitDeleteByUUID(ctx context.Context, outfitUUID string) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitDeleteByUUID")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Where("outfit_uuid = ?", outfitUUID).Delete(&mysqlmodel.Outfit{}).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OutfitUpdateByUUID 按 UUID 更新穿搭字段
|
||||
func (d *OutfitDao) OutfitUpdateByUUID(ctx context.Context, outfitUUID string, updates map[string]any) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitUpdateByUUID")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{}).Where("outfit_uuid = ?", outfitUUID).Updates(updates).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OutfitUpdateIsPublic 设置穿搭公开状态
|
||||
func (d *OutfitDao) OutfitUpdateIsPublic(ctx context.Context, outfitUUID string, isPublic int8) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitUpdateIsPublic")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{}).Where("outfit_uuid = ?", outfitUUID).UpdateColumn("is_public", isPublic).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OutfitPublish 条件公开穿搭(仅私密 -> 公开 + 审核通过),返回是否更新成功,防并发重复消耗额度。
|
||||
// 状态字面量:is_public 1=私密 2=公开;audit_status 2=审核通过。
|
||||
func (d *OutfitDao) OutfitPublish(ctx context.Context, outfitUUID string) (bool, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitPublish")
|
||||
defer span.End()
|
||||
|
||||
res := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{}).
|
||||
Where("outfit_uuid = ? AND is_public = ?", outfitUUID, int8(1)).
|
||||
Updates(map[string]any{"is_public": int8(2), "audit_status": int8(2)})
|
||||
if res.Error != nil {
|
||||
span.RecordError(res.Error)
|
||||
return false, res.Error
|
||||
}
|
||||
return res.RowsAffected > 0, 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
|
||||
}
|
||||
|
||||
// OutfitGetByID 按主键 ID 查询穿搭
|
||||
func (d *OutfitDao) OutfitGetByID(ctx context.Context, id int64) (*mysqlmodel.Outfit, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitGetByID")
|
||||
defer span.End()
|
||||
|
||||
var outfit mysqlmodel.Outfit
|
||||
if err := d.db.WithContext(ctx).First(&outfit, id).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return &outfit, nil
|
||||
}
|
||||
|
||||
// OutfitGetByUUIDs 按 UUID 批量查询穿搭
|
||||
func (d *OutfitDao) OutfitGetByUUIDs(ctx context.Context, uuids []string) ([]mysqlmodel.Outfit, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitGetByUUIDs")
|
||||
defer span.End()
|
||||
|
||||
var list []mysqlmodel.Outfit
|
||||
if len(uuids) == 0 {
|
||||
return list, nil
|
||||
}
|
||||
if err := d.db.WithContext(ctx).Where("outfit_uuid IN ?", uuids).Find(&list).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// OutfitListByUserID 分页查询用户穿搭(isPublic/source 为 0 不筛选)
|
||||
func (d *OutfitDao) OutfitListByUserID(ctx context.Context, userID int64, isPublic, source int8, page, pageSize int) ([]mysqlmodel.Outfit, int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitListByUserID")
|
||||
defer span.End()
|
||||
|
||||
var list []mysqlmodel.Outfit
|
||||
var total int64
|
||||
q := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{}).Where("user_id = ?", userID)
|
||||
if isPublic != 0 {
|
||||
q = q.Where("is_public = ?", isPublic)
|
||||
}
|
||||
if source != 0 {
|
||||
q = q.Where("source = ?", source)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// OutfitItemDao 穿搭明细数据访问
|
||||
type OutfitItemDao struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewOutfitItemDao 创建穿搭明细 DAO
|
||||
func NewOutfitItemDao() *OutfitItemDao {
|
||||
return &OutfitItemDao{db: global.MysqlEngine}
|
||||
}
|
||||
|
||||
// OutfitItemCreateBatch 批量创建穿搭明细
|
||||
func (d *OutfitItemDao) OutfitItemCreateBatch(ctx context.Context, items []mysqlmodel.OutfitItem) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitItemCreateBatch")
|
||||
defer span.End()
|
||||
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := d.db.WithContext(ctx).Create(&items).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OutfitItemDeleteByOutfitID 删除穿搭明细
|
||||
func (d *OutfitItemDao) OutfitItemDeleteByOutfitID(ctx context.Context, outfitID int64) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitItemDeleteByOutfitID")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Where("outfit_id = ?", outfitID).Delete(&mysqlmodel.OutfitItem{}).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OutfitItemListByOutfitID 查询穿搭明细
|
||||
func (d *OutfitItemDao) OutfitItemListByOutfitID(ctx context.Context, outfitID int64) ([]mysqlmodel.OutfitItem, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitItemListByOutfitID")
|
||||
defer span.End()
|
||||
|
||||
var list []mysqlmodel.OutfitItem
|
||||
if err := d.db.WithContext(ctx).Where("outfit_id = ?", outfitID).Find(&list).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// OutfitItemListByOutfitIDs 批量查询穿搭明细
|
||||
func (d *OutfitItemDao) OutfitItemListByOutfitIDs(ctx context.Context, outfitIDs []int64) ([]mysqlmodel.OutfitItem, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitItemListByOutfitIDs")
|
||||
defer span.End()
|
||||
|
||||
var list []mysqlmodel.OutfitItem
|
||||
if len(outfitIDs) == 0 {
|
||||
return list, nil
|
||||
}
|
||||
if err := d.db.WithContext(ctx).Where("outfit_id IN ?", outfitIDs).Find(&list).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
package mysqldao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// QuotaDao 用户配额数据访问
|
||||
type QuotaDao struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewQuotaDao 创建配额 DAO
|
||||
func NewQuotaDao() *QuotaDao {
|
||||
return &QuotaDao{db: global.MysqlEngine}
|
||||
}
|
||||
|
||||
// QuotaCreate 创建配额记录
|
||||
func (d *QuotaDao) QuotaCreate(ctx context.Context, quota *mysqlmodel.Quota) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaCreate")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Create(quota).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// QuotaIncreaseUsed 原子增加已用量(扣减配额),剩余不足时返回 false。
|
||||
// 已废弃:仅改数值不写流水,存在账实不符风险;新代码请使用 QuotaConsumeWithFlow。
|
||||
func (d *QuotaDao) QuotaIncreaseUsed(ctx context.Context, userID int64, quotaType int8, n int64) (bool, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaIncreaseUsed")
|
||||
defer span.End()
|
||||
|
||||
res := d.db.WithContext(ctx).Model(&mysqlmodel.Quota{}).
|
||||
Where("user_id = ? AND quota_type = ? AND used + ? <= total", userID, quotaType, n).
|
||||
UpdateColumn("used", gorm.Expr("used + ?", n))
|
||||
if res.Error != nil {
|
||||
span.RecordError(res.Error)
|
||||
return false, res.Error
|
||||
}
|
||||
return res.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// QuotaDecreaseUsed 原子减少已用量(回退配额),已用量不足时返回 false。
|
||||
// 已废弃:仅改数值不写流水,存在账实不符风险;新代码请使用 QuotaRefundWithFlow。
|
||||
func (d *QuotaDao) QuotaDecreaseUsed(ctx context.Context, userID int64, quotaType int8, n int64) (bool, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaDecreaseUsed")
|
||||
defer span.End()
|
||||
|
||||
res := d.db.WithContext(ctx).Model(&mysqlmodel.Quota{}).
|
||||
Where("user_id = ? AND quota_type = ? AND used >= ?", userID, quotaType, n).
|
||||
UpdateColumn("used", gorm.Expr("used - ?", n))
|
||||
if res.Error != nil {
|
||||
span.RecordError(res.Error)
|
||||
return false, res.Error
|
||||
}
|
||||
return res.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// QuotaConsumeWithFlow 事务内扣减配额并写流水,保证数值变更与流水强一致。
|
||||
// 条件更新不满足(额度不足)返回 (false, nil);其余错误(含流水写入失败整体回滚)返回 error。
|
||||
func (d *QuotaDao) QuotaConsumeWithFlow(ctx context.Context, userID int64, quotaType int8, n int64, flow *mysqlmodel.QuotaFlowLog) (bool, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaConsumeWithFlow")
|
||||
defer span.End()
|
||||
|
||||
var ok bool
|
||||
err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
res := tx.Model(&mysqlmodel.Quota{}).
|
||||
Where("user_id = ? AND quota_type = ? AND used + ? <= total", userID, quotaType, n).
|
||||
UpdateColumn("used", gorm.Expr("used + ?", n))
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return nil // 额度不足,无变更。
|
||||
}
|
||||
ok = true
|
||||
|
||||
// 回读真实值填充流水 old/new。
|
||||
var cur mysqlmodel.Quota
|
||||
if err := tx.Where("user_id = ? AND quota_type = ?", userID, quotaType).First(&cur).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
remain := cur.Total - cur.Used
|
||||
flow.OldValue = remain + n
|
||||
flow.NewValue = remain
|
||||
|
||||
// 写流水(失败则整个事务回滚,扣减一并撤销)。
|
||||
ensureFlowOrderNo(flow)
|
||||
return tx.Create(flow).Error
|
||||
})
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return false, err
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// QuotaRefundWithFlow 事务内回退配额并写流水,保证数值变更与流水强一致。
|
||||
// 条件更新不满足(已用量不足/重复回退)返回 (false, nil);其余错误(含流水失败回滚)返回 error。
|
||||
func (d *QuotaDao) QuotaRefundWithFlow(ctx context.Context, userID int64, quotaType int8, n int64, flow *mysqlmodel.QuotaFlowLog) (bool, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaRefundWithFlow")
|
||||
defer span.End()
|
||||
|
||||
var ok bool
|
||||
err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
res := tx.Model(&mysqlmodel.Quota{}).
|
||||
Where("user_id = ? AND quota_type = ? AND used >= ?", userID, quotaType, n).
|
||||
UpdateColumn("used", gorm.Expr("used - ?", n))
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return nil // 已用量不足,无变更。
|
||||
}
|
||||
ok = true
|
||||
|
||||
// 回读真实值填充流水 old/new。
|
||||
var cur mysqlmodel.Quota
|
||||
if err := tx.Where("user_id = ? AND quota_type = ?", userID, quotaType).First(&cur).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
remain := cur.Total - cur.Used
|
||||
flow.OldValue = remain - n
|
||||
flow.NewValue = remain
|
||||
|
||||
// 写流水(失败则整个事务回滚,回退一并撤销)。
|
||||
ensureFlowOrderNo(flow)
|
||||
return tx.Create(flow).Error
|
||||
})
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return false, err
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// QuotaInitWithFlow 事务内初始化配额并写流水。
|
||||
// 依赖 uk_user_quota 唯一索引(OnConflict DoNothing)实现并发安全幂等:已存在则跳过且不写流水。
|
||||
func (d *QuotaDao) QuotaInitWithFlow(ctx context.Context, userID int64, quotaType int8, total int64, flow *mysqlmodel.QuotaFlowLog) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaInitWithFlow")
|
||||
defer span.End()
|
||||
|
||||
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
q := &mysqlmodel.Quota{
|
||||
UserID: userID,
|
||||
QuotaType: quotaType,
|
||||
Total: total,
|
||||
Used: 0,
|
||||
}
|
||||
res := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}, {Name: "quota_type"}},
|
||||
DoNothing: true,
|
||||
}).Create(q)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return nil // 已存在,幂等跳过。
|
||||
}
|
||||
|
||||
// 回读真实值填充流水 old/new。
|
||||
var cur mysqlmodel.Quota
|
||||
if err := tx.Where("user_id = ? AND quota_type = ?", userID, quotaType).First(&cur).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
remain := cur.Total - cur.Used
|
||||
flow.OldValue = remain - total
|
||||
flow.NewValue = remain
|
||||
|
||||
// 写流水(失败则整个事务回滚,初始化一并撤销)。
|
||||
ensureFlowOrderNo(flow)
|
||||
return tx.Create(flow).Error
|
||||
})
|
||||
}
|
||||
|
||||
// ensureFlowOrderNo 流水联合唯一索引 (order_no, quota_type, change_type) 的兜底:
|
||||
// 消费/回退/初始化流水无业务订单号时(order_no 为空串),MySQL 唯一索引将空串视为相同值,
|
||||
// 第二条同类流水会 1062 冲突。此处用流水自身 UUID 填充 order_no 保证唯一,不影响
|
||||
// 购买/退款类幂等流水(其 order_no 非空,直接沿用业务订单号)。
|
||||
func ensureFlowOrderNo(flow *mysqlmodel.QuotaFlowLog) {
|
||||
if flow != nil && flow.OrderNo == "" {
|
||||
flow.OrderNo = flow.QuotaFlowUUID
|
||||
}
|
||||
}
|
||||
|
||||
// QuotaGrantIdempotent 幂等发放额度:事务内先累加 total(不存在则创建,存在则累加),
|
||||
// 回读真实值后写流水;流水唯一索引(order_no+quota_type+change_type)冲突时事务回滚,
|
||||
// 调用方据此识别为「已发放」从而幂等。
|
||||
func (d *QuotaDao) QuotaGrantIdempotent(ctx context.Context, userID int64, quotaType int8, n int64, flow *mysqlmodel.QuotaFlowLog) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantIdempotent")
|
||||
defer span.End()
|
||||
|
||||
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 1. 累加 total(记录不存在则创建,存在则原子累加)。
|
||||
q := &mysqlmodel.Quota{
|
||||
UserID: userID,
|
||||
QuotaType: quotaType,
|
||||
Total: n,
|
||||
Used: 0,
|
||||
}
|
||||
if err := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}, {Name: "quota_type"}},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{"total": gorm.Expr("total + ?", n)}),
|
||||
}).Create(q).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 回读真实值填充流水 old/new。
|
||||
var cur mysqlmodel.Quota
|
||||
if err := tx.Where("user_id = ? AND quota_type = ?", userID, quotaType).First(&cur).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
remain := cur.Total - cur.Used
|
||||
flow.OldValue = remain - n
|
||||
flow.NewValue = remain
|
||||
|
||||
// 3. 写流水(唯一索引冲突会回滚整个事务,保证不重复发放)。
|
||||
return tx.Create(flow).Error
|
||||
})
|
||||
}
|
||||
|
||||
// QuotaRevokeIdempotent 幂等回退额度(退款回收):事务内先扣减 total(GREATEST 保证 total 不低于 used,
|
||||
// 即已用部分不回退),回读真实值后写回退流水;流水唯一索引(order_no+quota_type+change_type)冲突时
|
||||
// 事务回滚,调用方据此识别为「已回退」从而幂等。
|
||||
func (d *QuotaDao) QuotaRevokeIdempotent(ctx context.Context, userID int64, quotaType int8, n int64, flow *mysqlmodel.QuotaFlowLog) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaRevokeIdempotent")
|
||||
defer span.End()
|
||||
|
||||
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 1. 扣减 total(GREATEST(used, total - n) 保证 total >= used)。
|
||||
res := tx.Model(&mysqlmodel.Quota{}).
|
||||
Where("user_id = ? AND quota_type = ?", userID, quotaType).
|
||||
UpdateColumn("total", gorm.Expr("GREATEST(used, total - ?)", n))
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
|
||||
// 2. 回读真实值填充流水 old/new。
|
||||
var cur mysqlmodel.Quota
|
||||
if err := tx.Where("user_id = ? AND quota_type = ?", userID, quotaType).First(&cur).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
remain := cur.Total - cur.Used
|
||||
flow.OldValue = remain + n
|
||||
flow.NewValue = remain
|
||||
|
||||
// 3. 写回退流水(唯一索引冲突回滚整个事务,保证不重复回退)。
|
||||
return tx.Create(flow).Error
|
||||
})
|
||||
}
|
||||
|
||||
// QuotaGetByUserIDAndType 查询用户某类配额
|
||||
func (d *QuotaDao) 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("a).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return "a, nil
|
||||
}
|
||||
|
||||
// QuotaListByUserID 查询用户的所有配额
|
||||
func (d *QuotaDao) 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("as).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return quotas, nil
|
||||
}
|
||||
|
||||
// UserDailyUsageDao 每日使用计数数据访问
|
||||
type UserDailyUsageDao struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewUserDailyUsageDao 创建每日使用计数 DAO
|
||||
func NewUserDailyUsageDao() *UserDailyUsageDao {
|
||||
return &UserDailyUsageDao{db: global.MysqlEngine}
|
||||
}
|
||||
|
||||
// UserDailyUsageCreate 创建每日使用计数
|
||||
func (d *UserDailyUsageDao) UserDailyUsageCreate(ctx context.Context, usage *mysqlmodel.UserDailyUsage) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserDailyUsageCreate")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Create(usage).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserDailyUsageIncrementAI 当日AI推荐次数 +1
|
||||
func (d *UserDailyUsageDao) UserDailyUsageIncrementAI(ctx context.Context, userID int64, statDate int64) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserDailyUsageIncrementAI")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.UserDailyUsage{}).
|
||||
Where("user_id = ? AND stat_date = ?", userID, statDate).
|
||||
UpdateColumn("ai_recommend_count", gorm.Expr("ai_recommend_count + ?", 1)).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserDailyUsageIncrementOutfitShare 当日社区分享次数 +1
|
||||
func (d *UserDailyUsageDao) UserDailyUsageIncrementOutfitShare(ctx context.Context, userID int64, statDate int64) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserDailyUsageIncrementOutfitShare")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.UserDailyUsage{}).
|
||||
Where("user_id = ? AND stat_date = ?", userID, statDate).
|
||||
UpdateColumn("outfit_share_count", gorm.Expr("outfit_share_count + ?", 1)).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserDailyUsageIncrementClosetShare 当日衣柜分享次数 +1
|
||||
func (d *UserDailyUsageDao) UserDailyUsageIncrementClosetShare(ctx context.Context, userID int64, statDate int64) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserDailyUsageIncrementClosetShare")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.UserDailyUsage{}).
|
||||
Where("user_id = ? AND stat_date = ?", userID, statDate).
|
||||
UpdateColumn("closet_share_count", gorm.Expr("closet_share_count + ?", 1)).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserDailyUsageGetByUserIDAndDate 查询用户某日使用计数
|
||||
func (d *UserDailyUsageDao) UserDailyUsageGetByUserIDAndDate(ctx context.Context, userID int64, 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
|
||||
}
|
||||
|
||||
// QuotaFlowLogDao 配额流水数据访问
|
||||
type QuotaFlowLogDao struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewQuotaFlowLogDao 创建配额流水 DAO
|
||||
func NewQuotaFlowLogDao() *QuotaFlowLogDao {
|
||||
return &QuotaFlowLogDao{db: global.MysqlEngine}
|
||||
}
|
||||
|
||||
// QuotaFlowLogCreate 创建配额流水
|
||||
func (d *QuotaFlowLogDao) QuotaFlowLogCreate(ctx context.Context, log *mysqlmodel.QuotaFlowLog) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaFlowLogCreate")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Create(log).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// QuotaFlowLogExistsByOrderNoAndType 判断指定订单+额度类型是否已有发放流水(补偿重试幂等判断)。
|
||||
func (d *QuotaFlowLogDao) QuotaFlowLogExistsByOrderNoAndType(ctx context.Context, orderNo string, quotaType int8) (bool, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaFlowLogExistsByOrderNoAndType")
|
||||
defer span.End()
|
||||
|
||||
var count int64
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.QuotaFlowLog{}).
|
||||
Where("order_no = ? AND quota_type = ?", orderNo, quotaType).
|
||||
Count(&count).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// QuotaFlowLogListByUserID 分页查询用户配额流水(quotaType 为 0 不筛选)
|
||||
func (d *QuotaFlowLogDao) QuotaFlowLogListByUserID(ctx context.Context, userID int64, quotaType int8, page, pageSize int) ([]mysqlmodel.QuotaFlowLog, int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaFlowLogListByUserID")
|
||||
defer span.End()
|
||||
|
||||
var list []mysqlmodel.QuotaFlowLog
|
||||
var total int64
|
||||
q := d.db.WithContext(ctx).Model(&mysqlmodel.QuotaFlowLog{}).Where("user_id = ?", userID)
|
||||
if quotaType != 0 {
|
||||
q = q.Where("quota_type = ?", quotaType)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// QuotaGrantListByUserIDAndType 查询用户某类型全部凭证(未删除)。
|
||||
// 按到期时间升序(expire_time=0 表示跟随 VIP,排最后),配合「先到期先用」扣减策略。
|
||||
func (d *QuotaGrantDao) QuotaGrantListByUserIDAndType(ctx context.Context, userID int64, quotaType int8) ([]mysqlmodel.QuotaGrant, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantListByUserIDAndType")
|
||||
defer span.End()
|
||||
|
||||
var list []mysqlmodel.QuotaGrant
|
||||
if err := d.db.WithContext(ctx).
|
||||
Where("user_id = ? AND quota_type = ?", userID, quotaType).
|
||||
Order("CASE WHEN expire_time = 0 THEN 1 ELSE 0 END ASC").
|
||||
Order("expire_time ASC").
|
||||
Find(&list).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// QuotaGrantConsume 条件扣减单条凭证已用量(used + n <= amount),返回是否扣减成功。
|
||||
// 用于 Cos 永久额度凭证维度的扣减,避免并发超扣。
|
||||
func (d *QuotaGrantDao) QuotaGrantConsume(ctx context.Context, grantID int64, n int64) (bool, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantConsume")
|
||||
defer span.End()
|
||||
|
||||
res := d.db.WithContext(ctx).Model(&mysqlmodel.QuotaGrant{}).
|
||||
Where("id = ? AND used + ? <= amount", grantID, n).
|
||||
UpdateColumn("used", gorm.Expr("used + ?", n))
|
||||
if res.Error != nil {
|
||||
span.RecordError(res.Error)
|
||||
return false, res.Error
|
||||
}
|
||||
return res.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// QuotaGrantInvalidateByRefID 按关联ID软删除凭证(退款回收时使赠送额度失效)。
|
||||
func (d *QuotaGrantDao) QuotaGrantInvalidateByRefID(ctx context.Context, refID string) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantInvalidateByRefID")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Where("ref_id = ?", refID).Delete(&mysqlmodel.QuotaGrant{}).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// QuotaGrantExistsByRefIDAndType 判断凭证是否已存在(广告激励防重复领取)。
|
||||
func (d *QuotaGrantDao) QuotaGrantExistsByRefIDAndType(ctx context.Context, refID string, quotaType int8) (bool, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantExistsByRefIDAndType")
|
||||
defer span.End()
|
||||
|
||||
var count int64
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.QuotaGrant{}).
|
||||
Where("ref_id = ? AND quota_type = ?", refID, quotaType).
|
||||
Count(&count).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package mysqldao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UserDao 用户数据访问
|
||||
type UserDao struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewUserDao 创建用户 DAO
|
||||
func NewUserDao() *UserDao {
|
||||
return &UserDao{db: global.MysqlEngine}
|
||||
}
|
||||
|
||||
// UserCreate 创建用户
|
||||
func (d *UserDao) UserCreate(ctx context.Context, user *mysqlmodel.User) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserCreate")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Create(user).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return 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
|
||||
}
|
||||
|
||||
// VIPGrantIdempotent 幂等续费会员:事务内先原子累加 vip_expire(CASE 表达式避免并发丢失天数),
|
||||
// 回读真实到期时间后推导会员类型并写 VIP 流水;流水唯一索引(order_no+change_type)冲突时事务回滚,
|
||||
// 调用方据此识别为「已发放」从而幂等。
|
||||
//
|
||||
// 注意:续费 SQL 使用原生语句显式控制 SET 顺序(vip_start 先于 vip_expire 求值),
|
||||
// 使 vip_start 读到更新前的旧 vip_expire,保证「已过期续费时重置 vip_start」语义正确。
|
||||
//
|
||||
// 会员类型按「续费后最终到期时长」推导:>=365 天为年度会员,否则月度会员,
|
||||
// 避免「年度会员续月包被错误降级为月度」;体验会员等其它类型保持传入值不参与推导。
|
||||
func (d *UserDao) VIPGrantIdempotent(ctx context.Context, userID int64, now int64, memberDays int64, vipType int8, flow *mysqlmodel.VIPFlowLog) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.VIPGrantIdempotent")
|
||||
defer span.End()
|
||||
|
||||
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
sql := `UPDATE users SET
|
||||
vip_start = CASE WHEN vip_type = 1 OR vip_expire <= ? THEN ? ELSE vip_start END,
|
||||
vip_expire = (CASE WHEN vip_expire > ? THEN vip_expire ELSE ? END) + ?
|
||||
WHERE id = ? AND deleted_at = 0`
|
||||
if res := tx.Exec(sql, now, now, now, now, memberDays*86400, userID); res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
|
||||
// 回读续费后的真实到期时间,用于推导会员类型。
|
||||
var renewed mysqlmodel.User
|
||||
if err := tx.Where("id = ?", userID).First(&renewed).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 字面量 2=月度会员 3=年度会员(与 service 层 VIPTypeMonthly/VIPTypeYearly 一致)。
|
||||
// 月度/年度续费按最终到期时长推导;体验会员(4)等其它类型保持传入值。
|
||||
finalType := vipType
|
||||
if vipType == 2 || vipType == 3 {
|
||||
if renewed.VIPExpire-now >= 365*86400 {
|
||||
finalType = 3
|
||||
} else {
|
||||
finalType = 2
|
||||
}
|
||||
}
|
||||
if err := tx.Model(&mysqlmodel.User{}).Where("id = ?", userID).
|
||||
UpdateColumn("vip_type", finalType).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
flow.NewVIPExpire = renewed.VIPExpire
|
||||
flow.NewVIPType = finalType
|
||||
|
||||
return tx.Create(flow).Error
|
||||
})
|
||||
}
|
||||
|
||||
// VIPRevokeIdempotent 幂等回退会员(退款回收):事务内先扣减 vip_expire(GREATEST 保证不早于 now),
|
||||
// 回读真实值后写回退流水;流水唯一索引(order_no+change_type)冲突时事务回滚,调用方据此识别为「已回退」。
|
||||
func (d *UserDao) VIPRevokeIdempotent(ctx context.Context, userID int64, now int64, memberDays int64, flow *mysqlmodel.VIPFlowLog) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.VIPRevokeIdempotent")
|
||||
defer span.End()
|
||||
|
||||
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 1. 扣减 vip_expire(GREATEST(now, vip_expire - days) 保证不倒退到过去)。
|
||||
res := tx.Model(&mysqlmodel.User{}).
|
||||
Where("id = ? AND vip_expire > ?", userID, now).
|
||||
UpdateColumn("vip_expire", gorm.Expr("GREATEST(?, vip_expire - ?)", now, memberDays*86400))
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
|
||||
// 2. 回读真实值填充流水。
|
||||
var renewed mysqlmodel.User
|
||||
if err := tx.Where("id = ?", userID).First(&renewed).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
flow.NewVIPExpire = renewed.VIPExpire
|
||||
flow.NewVIPType = renewed.VIPType
|
||||
|
||||
// 3. 写回退流水(唯一索引冲突回滚整个事务,保证不重复回退)。
|
||||
return tx.Create(flow).Error
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// UserGetByIDs 按主键 ID 批量查询用户(空 ids 直接返回空切片)
|
||||
func (d *UserDao) UserGetByIDs(ctx context.Context, ids []int64) ([]mysqlmodel.User, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserGetByIDs")
|
||||
defer span.End()
|
||||
|
||||
var users []mysqlmodel.User
|
||||
if len(ids) == 0 {
|
||||
return users, nil
|
||||
}
|
||||
if err := d.db.WithContext(ctx).Where("id IN ?", ids).Find(&users).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return users, 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
|
||||
}
|
||||
|
||||
// UserGetByOpenID 按微信 openid 查询用户
|
||||
func (d *UserDao) UserGetByOpenID(ctx context.Context, openID string) (*mysqlmodel.User, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserGetByOpenID")
|
||||
defer span.End()
|
||||
|
||||
var user mysqlmodel.User
|
||||
if err := d.db.WithContext(ctx).Where("wx_open_id = ?", openID).First(&user).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package mysqldao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// VIPFlowLogDao VIP 流水数据访问
|
||||
type VIPFlowLogDao struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewVIPFlowLogDao 创建 VIP 流水 DAO
|
||||
func NewVIPFlowLogDao() *VIPFlowLogDao {
|
||||
return &VIPFlowLogDao{db: global.MysqlEngine}
|
||||
}
|
||||
|
||||
// VIPFlowLogCreate 创建 VIP 流水
|
||||
func (d *VIPFlowLogDao) VIPFlowLogCreate(ctx context.Context, log *mysqlmodel.VIPFlowLog) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.VIPFlowLogCreate")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Create(log).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VIPFlowLogExistsByOrderNo 判断指定订单是否已有 VIP 发放流水(补偿重试幂等判断)。
|
||||
func (d *VIPFlowLogDao) VIPFlowLogExistsByOrderNo(ctx context.Context, orderNo string) (bool, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.VIPFlowLogExistsByOrderNo")
|
||||
defer span.End()
|
||||
|
||||
var count int64
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.VIPFlowLog{}).
|
||||
Where("order_no = ?", orderNo).
|
||||
Count(&count).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// VIPFlowLogListByUserID 分页查询用户 VIP 流水
|
||||
func (d *VIPFlowLogDao) VIPFlowLogListByUserID(ctx context.Context, userID int64, page, pageSize int) ([]mysqlmodel.VIPFlowLog, int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.VIPFlowLogListByUserID")
|
||||
defer span.End()
|
||||
|
||||
var list []mysqlmodel.VIPFlowLog
|
||||
var total int64
|
||||
q := d.db.WithContext(ctx).Model(&mysqlmodel.VIPFlowLog{}).Where("user_id = ?", userID)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package mysqlmodel
|
||||
|
||||
type AIRecommendFlowLog struct {
|
||||
Model
|
||||
RequestID string `gorm:"column:request_id"` // 请求唯一ID
|
||||
ServerRequestID string `gorm:"column:server_request_id"` // 服务请求唯一ID
|
||||
UserID int64 `gorm:"column:user_id"` // 用户ID
|
||||
ModelVersion string `gorm:"column:model_version"` // 大模型版本
|
||||
PromptTokens int64 `gorm:"column:prompt_tokens"` // 输入token数
|
||||
CompletionTokens int64 `gorm:"column:completion_tokens"` // 输出token数
|
||||
ResultContent string `gorm:"column:result_content"` // 推荐结果结构化数据
|
||||
Status int8 `gorm:"column:status"` // 状态 1=成功 2=失败
|
||||
FailReason string `gorm:"column:fail_reason"` // 失败原因
|
||||
RequestTime int64 `gorm:"column:request_time"` // 请求发起时间
|
||||
ResponseTime int64 `gorm:"column:response_time"` // 响应返回时间
|
||||
}
|
||||
|
||||
func (AIRecommendFlowLog) TableName() string {
|
||||
return "ai_recommend_flow_logs"
|
||||
}
|
||||
|
||||
type AIHoroscopeFlowLog struct {
|
||||
Model
|
||||
RequestID string `gorm:"column:request_id"` // 请求唯一ID(幂等依据)
|
||||
Zodiac string `gorm:"column:zodiac"` // 星座
|
||||
ServerRequestID string `gorm:"column:server_request_id"` // 服务请求唯一ID
|
||||
StatDate int64 `gorm:"column:stat_date"` // 运势日期(YYYYMMDD)
|
||||
ModelVersion string `gorm:"column:model_version"` // 大模型版本
|
||||
PromptTokens int64 `gorm:"column:prompt_tokens"` // 输入token数
|
||||
CompletionTokens int64 `gorm:"column:completion_tokens"` // 输出token数
|
||||
ResultContent string `gorm:"column:result_content"` // 生成结果结构化数据
|
||||
HoroscopeID int64 `gorm:"column:horoscope_id"` // 生成的运势记录ID
|
||||
Status int8 `gorm:"column:status"` // 状态 1=成功 2=失败
|
||||
FailReason string `gorm:"column:fail_reason"` // 失败原因
|
||||
RequestTime int64 `gorm:"column:request_time"` // 请求发起时间
|
||||
ResponseTime int64 `gorm:"column:response_time"` // 响应返回时间
|
||||
}
|
||||
|
||||
func (AIHoroscopeFlowLog) TableName() string {
|
||||
return "ai_horoscope_flow_logs"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package mysqlmodel
|
||||
|
||||
type ClosetAuditLog 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 (ClosetAuditLog) TableName() string {
|
||||
return "closet_audit_logs"
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package mysqlmodel
|
||||
|
||||
// BenefitGrantTask 权益发放补偿任务,用于支付成功后权益发放失败时的幂等补偿。
|
||||
// 唯一索引 (order_no, benefit_type) 作为幂等凭证,防止重复发放。
|
||||
type BenefitGrantTask struct {
|
||||
Model
|
||||
UserID int64 `gorm:"column:user_id"` // 用户ID
|
||||
// OrderNo 商户订单号;与 BenefitType 组成联合唯一索引作为幂等凭证。
|
||||
OrderNo string `gorm:"column:order_no;uniqueIndex:uk_benefit_task_idem,priority:1"`
|
||||
GoodsID int64 `gorm:"column:goods_id"` // 关联商品ID
|
||||
// BenefitType 权益类型 1=vip 2=cos 3=ai 4=closet_share 5=outfit_share
|
||||
BenefitType int8 `gorm:"column:benefit_type;uniqueIndex:uk_benefit_task_idem,priority:2"`
|
||||
Amount int64 `gorm:"column:amount"` // 发放数量
|
||||
Status int8 `gorm:"column:status"` // 状态 0=待发放 1=已发放 2=发放失败
|
||||
RetryCount int `gorm:"column:retry_count"` // 已重试次数
|
||||
MaxRetry int `gorm:"column:max_retry"` // 最大重试次数
|
||||
LastError string `gorm:"column:last_error"` // 最近一次失败原因
|
||||
}
|
||||
|
||||
func (BenefitGrantTask) TableName() string {
|
||||
return "benefit_grant_tasks"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package mysqlmodel
|
||||
|
||||
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 ClosetShareItem struct {
|
||||
Model
|
||||
ShareUUID string `gorm:"column:share_uuid"` // 分享UUID
|
||||
ClothesID int64 `gorm:"column:clothes_id"` // 衣物ID
|
||||
}
|
||||
|
||||
func (ClosetShareItem) TableName() string {
|
||||
return "closet_share_items"
|
||||
}
|
||||
|
||||
// ClosetShareReceiver 衣柜分享接收人关联。
|
||||
type ClosetShareReceiver struct {
|
||||
Model
|
||||
ShareUUID string `gorm:"column:share_uuid;uniqueIndex:uk_closet_share_receiver,priority:1"` // 分享UUID
|
||||
ReceiverUserID int64 `gorm:"column:receiver_user_id;uniqueIndex:uk_closet_share_receiver,priority:2"` // 接收人用户ID
|
||||
}
|
||||
|
||||
func (ClosetShareReceiver) TableName() string {
|
||||
return "closet_share_receivers"
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package mysqlmodel
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package mysqlmodel
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package mysqlmodel
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package mysqlmodel
|
||||
|
||||
type Horoscope struct {
|
||||
Model
|
||||
// StatDate 日期(YYYYMMDD);与 Zodiac 组成联合唯一索引,保证每日每星座仅一条运势。
|
||||
StatDate int64 `gorm:"column:stat_date;uniqueIndex:uk_horoscope_date_zodiac,priority:1"`
|
||||
Zodiac string `gorm:"column:zodiac;uniqueIndex:uk_horoscope_date_zodiac,priority:2"` // 星座
|
||||
OverallScore int8 `gorm:"column:overall_score"` // 综合运势 1-5分
|
||||
LoveScore int8 `gorm:"column:love_score"` // 爱情运势
|
||||
CareerScore int8 `gorm:"column:career_score"` // 工作运势
|
||||
WealthScore int8 `gorm:"column:wealth_score"` // 财富运势
|
||||
DailyText string `gorm:"column:daily_text"` // 今日运势详情
|
||||
LuckyColor string `gorm:"column:lucky_color"` // 幸运色
|
||||
LuckyNumber string `gorm:"column:lucky_number"` // 幸运数字
|
||||
}
|
||||
|
||||
func (Horoscope) TableName() string {
|
||||
return "horoscopes"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package mysqlmodel
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package mysqlmodel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-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
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package mysqlmodel
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package mysqlmodel
|
||||
|
||||
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=微信支付
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package mysqlmodel
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package mysqlmodel
|
||||
|
||||
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;uniqueIndex:uk_grant_ref_type,priority:1"` // 关联ID(订单号/广告记录ID)
|
||||
}
|
||||
|
||||
func (QuotaGrant) TableName() string {
|
||||
return "quota_grants"
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package mysqlmodel
|
||||
|
||||
// QuotaFlowLog 配额流水日志,统计用户配额变更情况
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package mysqlmodel
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package 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"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package mysqlmodel
|
||||
|
||||
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 关联订单号(购买类变更);唯一索引 (order_no, change_type) 为 VIP 发放/回收幂等兜底。
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package redisdao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-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 -> user_id
|
||||
// - refresh_token -> user_id
|
||||
// - user_id 的 token 集合加入 refresh_token(用于多端管理 / 登出)
|
||||
func AuthTokenSet(ctx context.Context, userID 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)
|
||||
userTokensKey := fmt.Sprintf(redismodel.KeyAuthUserTokens, userID)
|
||||
|
||||
accessTTL := time.Duration(global.JWTSetting.AccessTokenExpire) * time.Second
|
||||
refreshTTL := time.Duration(global.JWTSetting.RefreshTokenExpire) * time.Second
|
||||
|
||||
pipe := global.RedisEngine.TxPipeline()
|
||||
pipe.Set(ctx, accessKey, userID, accessTTL)
|
||||
pipe.Set(ctx, refreshKey, userID, refreshTTL)
|
||||
pipe.Eval(ctx, authTokenSAddScript, []string{userTokensKey}, 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, userID 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)
|
||||
userTokensKey := fmt.Sprintf(redismodel.KeyAuthUserTokens, userID)
|
||||
|
||||
pipe := global.RedisEngine.TxPipeline()
|
||||
pipe.Del(ctx, accessKey)
|
||||
pipe.Del(ctx, refreshKey)
|
||||
pipe.SRem(ctx, userTokensKey, refreshToken)
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthUserIDByAccess 通过 access token 查询 user_id,token 不存在或已过期返回 redis.Nil
|
||||
func AuthUserIDByAccess(ctx context.Context, accessToken string) (int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.AuthUserIDByAccess")
|
||||
defer span.End()
|
||||
|
||||
key := fmt.Sprintf(redismodel.KeyAuthAccess, accessToken)
|
||||
userID, err := global.RedisEngine.Get(ctx, key).Int64()
|
||||
if err != nil && err != redis.Nil {
|
||||
span.RecordError(err)
|
||||
}
|
||||
return userID, err
|
||||
}
|
||||
|
||||
// AuthUserIDByRefresh 通过 refresh token 查询 user_id,token 不存在或已过期返回 redis.Nil
|
||||
func AuthUserIDByRefresh(ctx context.Context, refreshToken string) (int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.AuthUserIDByRefresh")
|
||||
defer span.End()
|
||||
|
||||
key := fmt.Sprintf(redismodel.KeyAuthRefresh, refreshToken)
|
||||
userID, err := global.RedisEngine.Get(ctx, key).Int64()
|
||||
if err != nil && err != redis.Nil {
|
||||
span.RecordError(err)
|
||||
}
|
||||
return userID, err
|
||||
}
|
||||
|
||||
// sessionKeyTTL session_key 缓存时长(30 天,与微信 session_key 有效期对齐)。
|
||||
const sessionKeyTTL = 30 * 24 * time.Hour
|
||||
|
||||
// AuthSessionKeySet 写入用户的 session_key(登录时 code2session 返回,用于虚拟支付 signature 计算)。
|
||||
func AuthSessionKeySet(ctx context.Context, userID int64, sessionKey string) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.AuthSessionKeySet")
|
||||
defer span.End()
|
||||
|
||||
key := fmt.Sprintf(redismodel.KeyAuthSessionKey, userID)
|
||||
if err := global.RedisEngine.Set(ctx, key, sessionKey, sessionKeyTTL).Err(); err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthSessionKeyGet 读取用户的 session_key,不存在返回 redis.Nil。
|
||||
func AuthSessionKeyGet(ctx context.Context, userID int64) (string, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.AuthSessionKeyGet")
|
||||
defer span.End()
|
||||
|
||||
key := fmt.Sprintf(redismodel.KeyAuthSessionKey, userID)
|
||||
sessionKey, err := global.RedisEngine.Get(ctx, key).Result()
|
||||
if err != nil && err != redis.Nil {
|
||||
span.RecordError(err)
|
||||
}
|
||||
return sessionKey, err
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package redisdao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
|
||||
)
|
||||
|
||||
// viewCountTTL 访问计数 key 的过期时间,避免长期驻留导致内存无界增长。
|
||||
const viewCountTTL = 7 * 24 * time.Hour
|
||||
|
||||
// ClosetShareViewIncr 衣柜分享访问计数 +1,返回最新值
|
||||
func ClosetShareViewIncr(ctx context.Context, shareUUID string) (int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.ClosetShareViewIncr")
|
||||
defer span.End()
|
||||
|
||||
key := fmt.Sprintf(redismodel.KeyClosetShareView, shareUUID)
|
||||
val, err := quotaDailyIncrScript.Run(ctx, global.RedisEngine, []string{key}, int(viewCountTTL.Seconds())).Int64()
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return 0, err
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package redisdao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// collectSetTTL 收藏集合 key 的过期时间,避免长期驻留导致与 MySQL 弱一致漂移。
|
||||
const collectSetTTL = 7 * 24 * time.Hour
|
||||
|
||||
// CollectAdd 用户收藏(collect_type + target_id 分桶,成员为 user_id)
|
||||
func CollectAdd(ctx context.Context, collectType int8, targetID, userID int64) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.CollectAdd")
|
||||
defer span.End()
|
||||
|
||||
key := fmt.Sprintf(redismodel.KeyCollectSet, collectType, targetID)
|
||||
if err := global.RedisEngine.SAdd(ctx, key, userID).Err(); err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
// 设置 TTL(best-effort)。
|
||||
_ = global.RedisEngine.Expire(ctx, key, collectSetTTL).Err()
|
||||
return nil
|
||||
}
|
||||
|
||||
// CollectRemove 用户取消收藏
|
||||
func CollectRemove(ctx context.Context, collectType int8, targetID, userID int64) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.CollectRemove")
|
||||
defer span.End()
|
||||
|
||||
key := fmt.Sprintf(redismodel.KeyCollectSet, collectType, targetID)
|
||||
if err := global.RedisEngine.SRem(ctx, key, userID).Err(); err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CollectIsMember 判断用户是否已收藏;集合 key 不存在(缓存过期/冷启动)时返回 redis.Nil,
|
||||
// 由调用方回落 MySQL 以区分「未命中」与「确实未收藏」。
|
||||
func CollectIsMember(ctx context.Context, collectType int8, targetID, userID int64) (bool, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.CollectIsMember")
|
||||
defer span.End()
|
||||
|
||||
key := fmt.Sprintf(redismodel.KeyCollectSet, collectType, targetID)
|
||||
exists, err := global.RedisEngine.Exists(ctx, key).Result()
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return false, err
|
||||
}
|
||||
if exists == 0 {
|
||||
return false, redis.Nil
|
||||
}
|
||||
ok, err := global.RedisEngine.SIsMember(ctx, key, userID).Result()
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return false, err
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// CollectCount 获取收藏总数
|
||||
func CollectCount(ctx context.Context, collectType int8, targetID int64) (int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.CollectCount")
|
||||
defer span.End()
|
||||
|
||||
key := fmt.Sprintf(redismodel.KeyCollectSet, collectType, targetID)
|
||||
cnt, err := global.RedisEngine.SCard(ctx, key).Result()
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return 0, err
|
||||
}
|
||||
return cnt, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package redisdao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// HoroscopeSet 缓存星座运势内容(zodiac + 日期维度,data 由上层负责序列化)
|
||||
func HoroscopeSet(ctx context.Context, zodiac string, statDate int64, data []byte, ttl time.Duration) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.HoroscopeSet")
|
||||
defer span.End()
|
||||
|
||||
key := fmt.Sprintf(redismodel.KeyHoroscope, zodiac, statDate)
|
||||
if err := global.RedisEngine.Set(ctx, key, data, ttl).Err(); err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HoroscopeGet 读取星座运势缓存,未命中返回 redis.Nil
|
||||
func HoroscopeGet(ctx context.Context, zodiac string, statDate int64) ([]byte, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.HoroscopeGet")
|
||||
defer span.End()
|
||||
|
||||
key := fmt.Sprintf(redismodel.KeyHoroscope, zodiac, statDate)
|
||||
data, err := global.RedisEngine.Get(ctx, key).Bytes()
|
||||
if err != nil && err != redis.Nil {
|
||||
span.RecordError(err)
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package redisdao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// likeSetTTL 点赞集合 key 的过期时间,避免长期驻留导致与 MySQL 弱一致漂移。
|
||||
const likeSetTTL = 7 * 24 * time.Hour
|
||||
|
||||
// LikeAdd 用户点赞(like_type + target_id 分桶,成员为 user_id)
|
||||
func LikeAdd(ctx context.Context, likeType int8, targetID, userID int64) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.LikeAdd")
|
||||
defer span.End()
|
||||
|
||||
key := fmt.Sprintf(redismodel.KeyLikeSet, likeType, targetID)
|
||||
if err := global.RedisEngine.SAdd(ctx, key, userID).Err(); err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
// 设置 TTL(best-effort)。
|
||||
_ = global.RedisEngine.Expire(ctx, key, likeSetTTL).Err()
|
||||
return nil
|
||||
}
|
||||
|
||||
// LikeRemove 用户取消点赞
|
||||
func LikeRemove(ctx context.Context, likeType int8, targetID, userID int64) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.LikeRemove")
|
||||
defer span.End()
|
||||
|
||||
key := fmt.Sprintf(redismodel.KeyLikeSet, likeType, targetID)
|
||||
if err := global.RedisEngine.SRem(ctx, key, userID).Err(); err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LikeIsMember 判断用户是否已点赞;集合 key 不存在(缓存过期/冷启动)时返回 redis.Nil,
|
||||
// 由调用方回落 MySQL 以区分「未命中」与「确实未点赞」。
|
||||
func LikeIsMember(ctx context.Context, likeType int8, targetID, userID int64) (bool, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.LikeIsMember")
|
||||
defer span.End()
|
||||
|
||||
key := fmt.Sprintf(redismodel.KeyLikeSet, likeType, targetID)
|
||||
exists, err := global.RedisEngine.Exists(ctx, key).Result()
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return false, err
|
||||
}
|
||||
if exists == 0 {
|
||||
return false, redis.Nil
|
||||
}
|
||||
ok, err := global.RedisEngine.SIsMember(ctx, key, userID).Result()
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return false, err
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// LikeCount 获取点赞总数
|
||||
func LikeCount(ctx context.Context, likeType int8, targetID int64) (int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.LikeCount")
|
||||
defer span.End()
|
||||
|
||||
key := fmt.Sprintf(redismodel.KeyLikeSet, likeType, targetID)
|
||||
cnt, err := global.RedisEngine.SCard(ctx, key).Result()
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return 0, err
|
||||
}
|
||||
return cnt, nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package redisdao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// unlockScript 仅当锁值匹配时才删除,防止误删其他持有者的锁
|
||||
var unlockScript = redis.NewScript(`
|
||||
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('DEL', KEYS[1])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
`)
|
||||
|
||||
// TryLock 尝试获取分布式锁(SET NX EX),获取成功返回 true。
|
||||
// value 建议传唯一标识(如 request_id / uuid),用于释放时校验持有者。
|
||||
func TryLock(ctx context.Context, key, value string, ttl time.Duration) (bool, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.TryLock")
|
||||
defer span.End()
|
||||
|
||||
ok, err := global.RedisEngine.SetNX(ctx, key, value, ttl).Result()
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return false, err
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// Unlock 释放分布式锁,仅当 value 匹配时才删除;锁不存在或不属于自己时静默返回。
|
||||
func Unlock(ctx context.Context, key, value string) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.Unlock")
|
||||
defer span.End()
|
||||
|
||||
if err := unlockScript.Run(ctx, global.RedisEngine, []string{key}, value).Err(); err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package redisdao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// notificationUnreadTTL 未读数 key 的过期时间,避免长期驻留与 MySQL 弱一致漂移。
|
||||
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
|
||||
`)
|
||||
|
||||
// notificationUnreadDecrScript 未读数 -1,保证结果不低于 0
|
||||
var notificationUnreadDecrScript = redis.NewScript(`
|
||||
local v = redis.call('GET', KEYS[1])
|
||||
if v and tonumber(v) > 0 then
|
||||
return redis.call('DECRBY', KEYS[1], 1)
|
||||
end
|
||||
return 0
|
||||
`)
|
||||
|
||||
// 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(redismodel.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
|
||||
}
|
||||
|
||||
// NotificationUnreadDecr 用户未读消息数 -1,返回扣减后的值(不低于 0)
|
||||
func NotificationUnreadDecr(ctx context.Context, userID int64) (int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.NotificationUnreadDecr")
|
||||
defer span.End()
|
||||
|
||||
key := fmt.Sprintf(redismodel.KeyNotificationUnread, userID)
|
||||
val, err := notificationUnreadDecrScript.Run(ctx, global.RedisEngine, []string{key}).Int64()
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return 0, err
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// NotificationUnreadGet 获取用户未读消息数,不存在时返回 0
|
||||
func NotificationUnreadGet(ctx context.Context, userID int64) (int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.NotificationUnreadGet")
|
||||
defer span.End()
|
||||
|
||||
key := fmt.Sprintf(redismodel.KeyNotificationUnread, userID)
|
||||
v, err := global.RedisEngine.Get(ctx, key).Int64()
|
||||
if err == redis.Nil {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
|
||||
// NotificationUnreadSet 回填未读数缓存(DB 对账回源后使用),避免 key 过期导致后续扣减被吞。
|
||||
func NotificationUnreadSet(ctx context.Context, userID int64, cnt int64) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.NotificationUnreadSet")
|
||||
defer span.End()
|
||||
|
||||
key := fmt.Sprintf(redismodel.KeyNotificationUnread, userID)
|
||||
if err := global.RedisEngine.Set(ctx, key, cnt, notificationUnreadTTL).Err(); err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package redisdao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
|
||||
)
|
||||
|
||||
// OutfitViewIncr 公开穿搭浏览计数 +1,返回最新值
|
||||
func OutfitViewIncr(ctx context.Context, outfitID int64) (int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.OutfitViewIncr")
|
||||
defer span.End()
|
||||
|
||||
key := fmt.Sprintf(redismodel.KeyOutfitView, outfitID)
|
||||
val, err := quotaDailyIncrScript.Run(ctx, global.RedisEngine, []string{key}, int(viewCountTTL.Seconds())).Int64()
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return 0, err
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package redisdao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// quotaDailyTTL 每日计数 key 的过期时间(跨天,保证到期自然过期)
|
||||
const quotaDailyTTL = 48 * time.Hour
|
||||
|
||||
// quotaDailyIncrScript 首次自增时设置过期时间,保证 INCR 与 EXPIRE 原子执行
|
||||
var quotaDailyIncrScript = redis.NewScript(`
|
||||
local v = redis.call('INCR', KEYS[1])
|
||||
if v == 1 then
|
||||
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
||||
end
|
||||
return v
|
||||
`)
|
||||
|
||||
// quotaDailyIncr 每日计数 +1
|
||||
func quotaDailyIncr(ctx context.Context, key string) (int64, error) {
|
||||
return quotaDailyIncrScript.Run(ctx, global.RedisEngine, []string{key}, int(quotaDailyTTL.Seconds())).Int64()
|
||||
}
|
||||
|
||||
// quotaDailyValue 读取每日计数,key 不存在时返回 0
|
||||
func quotaDailyValue(ctx context.Context, key string) (int64, error) {
|
||||
v, err := global.RedisEngine.Get(ctx, key).Int64()
|
||||
if err == redis.Nil {
|
||||
return 0, nil
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
|
||||
// QuotaDailyAIIncr 当日 AI 推荐次数 +1
|
||||
func QuotaDailyAIIncr(ctx context.Context, userID int64, date int64) (int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.QuotaDailyAIIncr")
|
||||
defer span.End()
|
||||
|
||||
val, err := quotaDailyIncr(ctx, fmt.Sprintf(redismodel.KeyQuotaDailyAI, userID, date))
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return 0, err
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// QuotaDailyOutfitShareIncr 当日社区分享(搭配分享)次数 +1
|
||||
func QuotaDailyOutfitShareIncr(ctx context.Context, userID int64, date int64) (int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.QuotaDailyOutfitShareIncr")
|
||||
defer span.End()
|
||||
|
||||
val, err := quotaDailyIncr(ctx, fmt.Sprintf(redismodel.KeyQuotaDailyOutfitShare, userID, date))
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return 0, err
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// QuotaDailyClosetShareIncr 当日衣柜分享次数 +1
|
||||
func QuotaDailyClosetShareIncr(ctx context.Context, userID int64, date int64) (int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.QuotaDailyClosetShareIncr")
|
||||
defer span.End()
|
||||
|
||||
val, err := quotaDailyIncr(ctx, fmt.Sprintf(redismodel.KeyQuotaDailyClosetShare, userID, date))
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return 0, err
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// QuotaDailyAIValue 当日 AI 推荐次数
|
||||
func QuotaDailyAIValue(ctx context.Context, userID int64, date int64) (int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.QuotaDailyAIValue")
|
||||
defer span.End()
|
||||
|
||||
val, err := quotaDailyValue(ctx, fmt.Sprintf(redismodel.KeyQuotaDailyAI, userID, date))
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return 0, err
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// QuotaDailyOutfitShareValue 当日社区分享(搭配分享)次数
|
||||
func QuotaDailyOutfitShareValue(ctx context.Context, userID int64, date int64) (int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.QuotaDailyOutfitShareValue")
|
||||
defer span.End()
|
||||
|
||||
val, err := quotaDailyValue(ctx, fmt.Sprintf(redismodel.KeyQuotaDailyOutfitShare, userID, date))
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return 0, err
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// QuotaDailyClosetShareValue 当日衣柜分享次数
|
||||
func QuotaDailyClosetShareValue(ctx context.Context, userID int64, date int64) (int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.QuotaDailyClosetShareValue")
|
||||
defer span.End()
|
||||
|
||||
val, err := quotaDailyValue(ctx, fmt.Sprintf(redismodel.KeyQuotaDailyClosetShare, userID, date))
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return 0, err
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// ---- 通用每日额度原子限流(带限额) ----
|
||||
|
||||
// dailyQuotaConsumeScript 带限额的原子自增:
|
||||
// KEYS[1]=当日计数 key;ARGV[1]=每日上限;ARGV[2]=消费数量;ARGV[3]=TTL 秒。
|
||||
// 当前计数 + n 超过上限返回 -1;否则 INCRBY 并在首次写入时设置 TTL,返回消费后的当日计数。
|
||||
var dailyQuotaConsumeScript = redis.NewScript(`
|
||||
local v = tonumber(redis.call('GET', KEYS[1]) or '0')
|
||||
local limit = tonumber(ARGV[1])
|
||||
local n = tonumber(ARGV[2])
|
||||
if v + n > limit then
|
||||
return -1
|
||||
end
|
||||
local r = redis.call('INCRBY', KEYS[1], n)
|
||||
if v == 0 then
|
||||
redis.call('EXPIRE', KEYS[1], ARGV[3])
|
||||
end
|
||||
return r
|
||||
`)
|
||||
|
||||
// dailyQuotaRefundScript 回退当日计数(DECRBY 且不小于 0)。
|
||||
// ARGV[1]=回退数量。
|
||||
var dailyQuotaRefundScript = redis.NewScript(`
|
||||
local v = tonumber(redis.call('GET', KEYS[1]) or '0')
|
||||
local n = tonumber(ARGV[1])
|
||||
if v <= 0 then
|
||||
return 0
|
||||
end
|
||||
if n > v then
|
||||
n = v
|
||||
end
|
||||
return redis.call('DECRBY', KEYS[1], n)
|
||||
`)
|
||||
|
||||
// DailyQuotaConsume 原子消费当日额度。
|
||||
// 返回 (消费后计数, nil);额度不足返回 (-1, nil);Redis 异常返回 (0, err)。
|
||||
func DailyQuotaConsume(ctx context.Context, key string, limit int64, n int64) (int64, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.DailyQuotaConsume")
|
||||
defer span.End()
|
||||
|
||||
if limit <= 0 || n <= 0 {
|
||||
return -1, nil
|
||||
}
|
||||
val, err := dailyQuotaConsumeScript.Run(ctx, global.RedisEngine, []string{key}, limit, n, int(quotaDailyTTL.Seconds())).Int64()
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return 0, err
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// DailyQuotaRefund 回退当日计数(保证不小于 0)。失败仅记录,调用方按 best-effort 处理。
|
||||
func DailyQuotaRefund(ctx context.Context, key string, n int64) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.DailyQuotaRefund")
|
||||
defer span.End()
|
||||
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := dailyQuotaRefundScript.Run(ctx, global.RedisEngine, []string{key}, n).Int64(); err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DailyQuotaValue 读取当日计数,key 不存在时返回 0。
|
||||
func DailyQuotaValue(ctx context.Context, key string) (int64, error) {
|
||||
return quotaDailyValue(ctx, key)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package redisdao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// userInfoTTL 用户鉴权信息缓存默认过期时间
|
||||
const userInfoTTL = 24 * time.Hour
|
||||
|
||||
// UserInfoSet 缓存用户鉴权核心信息(含 Status,供中间件鉴权时快速读取)
|
||||
func UserInfoSet(ctx context.Context, user *mysqlmodel.User, ttl time.Duration) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.UserInfoSet")
|
||||
defer span.End()
|
||||
|
||||
if ttl <= 0 {
|
||||
ttl = userInfoTTL
|
||||
}
|
||||
data, err := json.Marshal(user)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
key := fmt.Sprintf(redismodel.KeyUserInfo, user.ID)
|
||||
if err := global.RedisEngine.Set(ctx, key, data, ttl).Err(); err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserInfoDel 删除用户缓存(用户资料变更/注销时失效缓存)
|
||||
func UserInfoDel(ctx context.Context, userID int64) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.UserInfoDel")
|
||||
defer span.End()
|
||||
|
||||
key := fmt.Sprintf(redismodel.KeyUserInfo, userID)
|
||||
if err := global.RedisEngine.Del(ctx, key).Err(); err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserInfoGet 读取缓存中的用户鉴权核心信息,未命中返回 redis.Nil
|
||||
func UserInfoGet(ctx context.Context, userID int64) (*mysqlmodel.User, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "redisdao.UserInfoGet")
|
||||
defer span.End()
|
||||
|
||||
key := fmt.Sprintf(redismodel.KeyUserInfo, userID)
|
||||
data, err := global.RedisEngine.Get(ctx, key).Bytes()
|
||||
if err != nil {
|
||||
if err != redis.Nil {
|
||||
span.RecordError(err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var user mysqlmodel.User
|
||||
if err := json.Unmarshal(data, &user); err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package redismodel
|
||||
|
||||
// 认证模块:token 与用户会话的 Redis key。
|
||||
const (
|
||||
KeyAuthAccess = RedisPrefix + "auth:access:%s" // access_token -> user_id(String)
|
||||
KeyAuthRefresh = RedisPrefix + "auth:refresh:%s" // refresh_token -> user_id(String)
|
||||
KeyAuthUserTokens = RedisPrefix + "auth:user:tokens:%d" // user_id -> token 集合(Set,支持多端登录)
|
||||
KeyAuthSessionKey = RedisPrefix + "auth:session_key:%d" // user_id -> session_key(String,虚拟支付 signature 用)
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
package redismodel
|
||||
|
||||
// 衣柜分享模块:分享访问计数。
|
||||
const KeyClosetShareView = RedisPrefix + "closet:share:view:%s" // share_uuid,衣柜分享访问计数(INCR)
|
||||
@@ -0,0 +1,4 @@
|
||||
package redismodel
|
||||
|
||||
// 收藏模块:按业务类型 + 目标 ID 分桶的收藏集合。
|
||||
const KeyCollectSet = RedisPrefix + "collect:set:%d:%d" // collect_type:target_id,成员为 user_id(Set,1=社区穿搭)
|
||||
@@ -0,0 +1,4 @@
|
||||
package redismodel
|
||||
|
||||
// 星座模块:按星座 + 日期缓存的运势内容。
|
||||
const KeyHoroscope = RedisPrefix + "horoscope:%s:%d" // zodiac:stat_date(YYYYMMDD),星座运势缓存(String/JSON)
|
||||
@@ -0,0 +1,41 @@
|
||||
package redismodel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/setting"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// RedisPrefix 是所有 Redis key 的全局命名空间前缀,避免多服务共享 Redis 实例时发生 key 冲突。
|
||||
const RedisPrefix = "ztcloset:"
|
||||
|
||||
// NewRedisClient 根据配置创建 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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package redismodel
|
||||
|
||||
// 点赞模块:按业务类型 + 目标 ID 分桶的点赞集合。
|
||||
const KeyLikeSet = RedisPrefix + "like:set:%d:%d" // like_type:target_id,成员为 user_id(Set,1=社区穿搭)
|
||||
@@ -0,0 +1,13 @@
|
||||
package redismodel
|
||||
|
||||
// 分布式锁与幂等模块:对齐 mysqlmodel 的 Order、AIRecommend、AIHoroscope、审核等业务。
|
||||
const (
|
||||
KeyLockOrderCreate = RedisPrefix + "lock:order:create:%d" // order_id,下单防重
|
||||
KeyLockPayCallback = RedisPrefix + "lock:pay:callback:%s" // order_no,支付回调幂等
|
||||
KeyLockRefundCallback = RedisPrefix + "lock:refund:callback:%s" // refund_no,退款回调幂等
|
||||
KeyLockAIRecommend = RedisPrefix + "lock:ai:recommend:%d" // user_id,AI推荐防并发
|
||||
KeyLockAIHoroscope = RedisPrefix + "lock:ai:horoscope:%d:%s" // user_id:zodiac,AI星座防并发
|
||||
KeyLockClosetAudit = RedisPrefix + "lock:audit:closet:%d" // target_id,衣柜分享审核防重
|
||||
KeyLockOutfitAudit = RedisPrefix + "lock:audit:outfit:%d" // target_id,穿搭审核防重
|
||||
KeyLockOutfitPublic = RedisPrefix + "lock:outfit:public:%s" // outfit_uuid,穿搭公开/取消公开串行化
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
package redismodel
|
||||
|
||||
// 通知模块:用户未读消息计数。
|
||||
const KeyNotificationUnread = RedisPrefix + "notification:unread:%d" // user_id,未读消息数(INCR/DECR)
|
||||
@@ -0,0 +1,4 @@
|
||||
package redismodel
|
||||
|
||||
// 穿搭模块:公开穿搭浏览计数。
|
||||
const KeyOutfitView = RedisPrefix + "outfit:view:%d" // outfit_id,公开穿搭浏览计数(INCR)
|
||||
@@ -0,0 +1,9 @@
|
||||
package redismodel
|
||||
|
||||
// 配额模块:对齐 mysqlmodel 的 Quota(总配额)与 UserDailyUsage(每日使用计数)。
|
||||
// 说明:每日使用计数使用 Redis INCR 做当日限流统计,权威值仍以 MySQL 为准。
|
||||
const (
|
||||
KeyQuotaDailyAI = RedisPrefix + "quota:daily:ai:%d:%d" // user_id:date(YYYYMMDD),当日AI推荐次数(INCR)
|
||||
KeyQuotaDailyOutfitShare = RedisPrefix + "quota:daily:outfit_share:%d:%d" // user_id:date(YYYYMMDD),当日社区分享次数(搭配分享,INCR)
|
||||
KeyQuotaDailyClosetShare = RedisPrefix + "quota:daily:closet_share:%d:%d" // user_id:date(YYYYMMDD),当日衣柜分享次数(INCR)
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
package redismodel
|
||||
|
||||
// 用户模块:用户鉴权核心信息缓存。
|
||||
const KeyUserInfo = RedisPrefix + "user:info:%d" // user_id -> 用户鉴权核心信息(String/JSON,含 Status)
|
||||
@@ -0,0 +1,41 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdRewardHandler 广告激励额度接口。
|
||||
type AdRewardHandler struct {
|
||||
svc *service.AdRewardService
|
||||
}
|
||||
|
||||
// NewAdRewardHandler 创建广告激励 handler。
|
||||
func NewAdRewardHandler() *AdRewardHandler {
|
||||
return &AdRewardHandler{svc: service.NewAdRewardService()}
|
||||
}
|
||||
|
||||
// Grant 发放广告激励额度(当天有效,幂等防重复领取)。
|
||||
func (h *AdRewardHandler) Grant(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
var request dto.AdRewardGrantRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
out, e := h.svc.Grant(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AIRecommendHandler AI 穿搭推荐相关接口。
|
||||
type AIRecommendHandler struct {
|
||||
svc *service.AIRecommendService
|
||||
}
|
||||
|
||||
// NewAIRecommendHandler 创建 AI 推荐 handler。
|
||||
func NewAIRecommendHandler() *AIRecommendHandler {
|
||||
return &AIRecommendHandler{svc: service.NewAIRecommendService()}
|
||||
}
|
||||
|
||||
// Create 发起 AI 穿搭推荐。
|
||||
func (h *AIRecommendHandler) Create(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
var request dto.AIRecommendCreateRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
out, e := h.svc.Create(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// Save 保存 AI 推荐穿搭到我的搭配。
|
||||
func (h *AIRecommendHandler) Save(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
var request dto.AIRecommendSaveRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
if e := h.svc.Save(c.Request.Context(), userID, &request); e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(nil)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ClosetShareHandler 衣柜分享相关接口。
|
||||
type ClosetShareHandler struct {
|
||||
svc *service.ClosetShareService
|
||||
}
|
||||
|
||||
// NewClosetShareHandler 创建衣柜分享 handler。
|
||||
func NewClosetShareHandler() *ClosetShareHandler {
|
||||
return &ClosetShareHandler{svc: service.NewClosetShareService()}
|
||||
}
|
||||
|
||||
// Create 创建衣柜分享。
|
||||
func (h *ClosetShareHandler) Create(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
var request dto.ClosetShareCreateRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
out, e := h.svc.Create(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// List 分页查询我的分享。
|
||||
func (h *ClosetShareHandler) List(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
var request dto.ClosetShareListRequest
|
||||
if err := c.ShouldBindQuery(&request); err != nil {
|
||||
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
out, e := h.svc.List(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// ReceivedList 分页查询分享给我的分享。
|
||||
func (h *ClosetShareHandler) ReceivedList(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
var request dto.ClosetShareListRequest
|
||||
if err := c.ShouldBindQuery(&request); err != nil {
|
||||
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
out, e := h.svc.ListReceived(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// Detail 查看分享详情(uuid 来自路径参数)。
|
||||
func (h *ClosetShareHandler) Detail(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
shareUUID := c.Param("uuid")
|
||||
if shareUUID == "" {
|
||||
response.ToErrorResponse(errcode.InvalidParams)
|
||||
return
|
||||
}
|
||||
out, e := h.svc.Detail(c.Request.Context(), userID, shareUUID)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// Cancel 取消分享(uuid 来自路径参数)。
|
||||
func (h *ClosetShareHandler) Cancel(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
request := dto.ClosetShareCancelRequest{ShareUUID: c.Param("uuid")}
|
||||
if request.ShareUUID == "" {
|
||||
response.ToErrorResponse(errcode.InvalidParams)
|
||||
return
|
||||
}
|
||||
out, e := h.svc.Cancel(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ClothesHandler 衣物相关接口。
|
||||
type ClothesHandler struct {
|
||||
svc *service.ClothesService
|
||||
}
|
||||
|
||||
// NewClothesHandler 创建衣物 handler。
|
||||
func NewClothesHandler() *ClothesHandler {
|
||||
return &ClothesHandler{svc: service.NewClothesService()}
|
||||
}
|
||||
|
||||
// Upload 上传衣物图片(multipart,字段名 file)。
|
||||
func (h *ClothesHandler) Upload(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
// 解析 multipart 前限制请求体大小(MaxSizeMB + 1MB 的 multipart 边界开销),
|
||||
// 防止超大 body 在 FormFile 解析时耗尽内存/磁盘。
|
||||
maxBytes := int64(global.UploadSetting.MaxSizeMB)*1024*1024 + 1024*1024
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
response.ToErrorResponse(errcode.UploadFileEmpty.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
out, e := h.svc.Upload(c.Request.Context(), userID, fileHeader)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// List 分页查询当前用户衣物。
|
||||
func (h *ClothesHandler) List(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
var request dto.ClothesListRequest
|
||||
if err := c.ShouldBindQuery(&request); err != nil {
|
||||
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
out, e := h.svc.List(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// Get 获取衣物详情(uuid 来自路径参数)。
|
||||
func (h *ClothesHandler) Get(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
request := dto.ClothesGetByUUIDRequest{ClothesUUID: c.Param("uuid")}
|
||||
if request.ClothesUUID == "" {
|
||||
response.ToErrorResponse(errcode.InvalidParams)
|
||||
return
|
||||
}
|
||||
out, e := h.svc.GetByUUID(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// Status 查询衣物图片处理状态(uuid 来自路径参数)。
|
||||
func (h *ClothesHandler) Status(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
request := dto.ClothesProcessStatusGetByUUIDRequest{ClothesUUID: c.Param("uuid")}
|
||||
if request.ClothesUUID == "" {
|
||||
response.ToErrorResponse(errcode.InvalidParams)
|
||||
return
|
||||
}
|
||||
out, e := h.svc.ProcessStatusGetByUUID(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// Update 修改衣物属性(uuid 来自路径参数)。
|
||||
func (h *ClothesHandler) Update(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
var request dto.ClothesUpdateByUUIDRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
request.ClothesUUID = c.Param("uuid")
|
||||
if request.ClothesUUID == "" {
|
||||
response.ToErrorResponse(errcode.InvalidParams)
|
||||
return
|
||||
}
|
||||
out, e := h.svc.UpdateByUUID(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// Delete 删除衣物(uuid 来自路径参数)。
|
||||
func (h *ClothesHandler) Delete(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
request := dto.ClothesDeleteByUUIDRequest{ClothesUUID: c.Param("uuid")}
|
||||
if request.ClothesUUID == "" {
|
||||
response.ToErrorResponse(errcode.InvalidParams)
|
||||
return
|
||||
}
|
||||
out, e := h.svc.DeleteByUUID(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CollectHandler 收藏相关接口。
|
||||
type CollectHandler struct {
|
||||
svc *service.CollectService
|
||||
}
|
||||
|
||||
// NewCollectHandler 创建收藏 handler。
|
||||
func NewCollectHandler() *CollectHandler {
|
||||
return &CollectHandler{svc: service.NewCollectService()}
|
||||
}
|
||||
|
||||
// Collect 收藏/取消收藏社区穿搭。
|
||||
func (h *CollectHandler) Collect(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
var request dto.CollectRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
out, e := h.svc.Collect(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// List 分页查询我收藏的穿搭。
|
||||
func (h *CollectHandler) List(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
var request dto.CollectListRequest
|
||||
if err := c.ShouldBindQuery(&request); err != nil {
|
||||
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
out, e := h.svc.List(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// GetByTarget 查询穿搭收藏状态与数量(targetUUID 来自路径参数)。
|
||||
func (h *CollectHandler) GetByTarget(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
request := dto.CollectGetByTargetRequest{TargetUUID: c.Param("targetUUID")}
|
||||
if request.TargetUUID == "" {
|
||||
response.ToErrorResponse(errcode.InvalidParams)
|
||||
return
|
||||
}
|
||||
out, e := h.svc.GetByTarget(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-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()}
|
||||
}
|
||||
|
||||
// GetByCode 按商品编码查询商品(编码来自路径参数)。
|
||||
func (h *GoodsHandler) GetByCode(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
request := dto.GoodsGetByCodeRequest{GoodsCode: c.Param("code")}
|
||||
if request.GoodsCode == "" {
|
||||
response.ToErrorResponse(errcode.InvalidParams)
|
||||
return
|
||||
}
|
||||
out, e := h.svc.GetByCode(c.Request.Context(), &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// HoroscopeHandler 今日运势接口。
|
||||
type HoroscopeHandler struct {
|
||||
svc *service.HoroscopeService
|
||||
}
|
||||
|
||||
// NewHoroscopeHandler 创建运势 handler。
|
||||
func NewHoroscopeHandler() *HoroscopeHandler {
|
||||
return &HoroscopeHandler{svc: service.NewHoroscopeService()}
|
||||
}
|
||||
|
||||
// Today 获取当前用户今日运势。
|
||||
func (h *HoroscopeHandler) Today(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
out, e := h.svc.GetToday(c.Request.Context(), userID)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// LikeHandler 点赞相关接口。
|
||||
type LikeHandler struct {
|
||||
svc *service.LikeService
|
||||
}
|
||||
|
||||
// NewLikeHandler 创建点赞 handler。
|
||||
func NewLikeHandler() *LikeHandler {
|
||||
return &LikeHandler{svc: service.NewLikeService()}
|
||||
}
|
||||
|
||||
// Like 点赞/取消点赞社区穿搭。
|
||||
func (h *LikeHandler) Like(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
var request dto.LikeCreateRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
out, e := h.svc.LikeCreate(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// GetByTarget 查询穿搭点赞状态与数量(targetUUID 来自路径参数)。
|
||||
func (h *LikeHandler) GetByTarget(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
request := dto.LikeGetByTargetIDRequest{TargetUUID: c.Param("targetUUID")}
|
||||
if request.TargetUUID == "" {
|
||||
response.ToErrorResponse(errcode.InvalidParams)
|
||||
return
|
||||
}
|
||||
out, e := h.svc.GetByTargetID(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-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()}
|
||||
}
|
||||
|
||||
// List 分页查询当前用户通知。
|
||||
func (h *NotificationHandler) List(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
var request dto.NotificationListRequest
|
||||
if err := c.ShouldBindQuery(&request); err != nil {
|
||||
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
out, e := h.svc.List(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// Unread 获取当前用户未读数。
|
||||
func (h *NotificationHandler) Unread(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
out, e := h.svc.Unread(c.Request.Context(), userID)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// Get 获取通知详情(uuid 来自路径参数)。
|
||||
func (h *NotificationHandler) Get(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
request := dto.NotificationGetByUUIDRequest{NotificationUUID: c.Param("uuid")}
|
||||
if request.NotificationUUID == "" {
|
||||
response.ToErrorResponse(errcode.InvalidParams)
|
||||
return
|
||||
}
|
||||
out, e := h.svc.GetByUUID(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// Read 标记通知已读(uuid 来自路径参数)。
|
||||
func (h *NotificationHandler) Read(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
request := dto.NotificationReadRequest{NotificationUUID: c.Param("uuid")}
|
||||
if request.NotificationUUID == "" {
|
||||
response.ToErrorResponse(errcode.InvalidParams)
|
||||
return
|
||||
}
|
||||
out, e := h.svc.Read(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/wechat"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// OrderHandler 订单与退款相关接口。
|
||||
type OrderHandler struct {
|
||||
svc *service.OrderService
|
||||
}
|
||||
|
||||
// NewOrderHandler 创建订单 handler。
|
||||
func NewOrderHandler() *OrderHandler {
|
||||
return &OrderHandler{svc: service.NewOrderService()}
|
||||
}
|
||||
|
||||
// Create 创建订单并发起微信下单。
|
||||
func (h *OrderHandler) Create(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
var request dto.OrderCreateRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
out, e := h.svc.Create(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// VirtualCreate 创建虚拟支付订单(小程序虚拟支付,覆盖 iOS/Android 全终端)。
|
||||
func (h *OrderHandler) VirtualCreate(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
var request dto.VirtualOrderCreateRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
out, e := h.svc.VirtualOrderCreate(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// List 分页查询我的订单。
|
||||
func (h *OrderHandler) List(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
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(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// Cancel 取消订单(orderNo 来自路径参数)。
|
||||
func (h *OrderHandler) Cancel(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
request := dto.OrderCancelRequest{OrderNo: c.Param("orderNo")}
|
||||
if request.OrderNo == "" {
|
||||
response.ToErrorResponse(errcode.InvalidParams)
|
||||
return
|
||||
}
|
||||
out, e := h.svc.Cancel(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// RefundApply 申请退款。
|
||||
func (h *OrderHandler) RefundApply(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
var request dto.RefundApplyRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
out, e := h.svc.RefundApply(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// PayNotify 微信支付结果回调(公开,按微信规范返回受理结果)。
|
||||
func (h *OrderHandler) PayNotify(c *gin.Context) {
|
||||
if e := h.svc.PayNotify(c.Request.Context(), c.Request); e != nil {
|
||||
wechatCallbackFail(c, e.Msg())
|
||||
return
|
||||
}
|
||||
wechatCallbackSuccess(c)
|
||||
}
|
||||
|
||||
// RefundNotify 微信退款结果回调(公开,按微信规范返回受理结果)。
|
||||
func (h *OrderHandler) RefundNotify(c *gin.Context) {
|
||||
if e := h.svc.RefundNotify(c.Request.Context(), c.Request); e != nil {
|
||||
wechatCallbackFail(c, e.Msg())
|
||||
return
|
||||
}
|
||||
wechatCallbackSuccess(c)
|
||||
}
|
||||
|
||||
// XPayVerify 虚拟支付消息推送 URL 配置验证(GET):校验签名并原样回显 echostr。
|
||||
func (h *OrderHandler) XPayVerify(c *gin.Context) {
|
||||
timestamp := c.Query("timestamp")
|
||||
nonce := c.Query("nonce")
|
||||
signature := c.Query("signature")
|
||||
echostr := c.Query("echostr")
|
||||
if wechat.VerifyNotifyURL(timestamp, nonce, signature) {
|
||||
c.String(http.StatusOK, echostr)
|
||||
return
|
||||
}
|
||||
c.String(http.StatusUnauthorized, "invalid signature")
|
||||
}
|
||||
|
||||
// XPayNotify 虚拟支付消息推送回调(POST,公开,按虚拟支付规范返回 ErrCode 应答)。
|
||||
func (h *OrderHandler) XPayNotify(c *gin.Context) {
|
||||
raw, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", wechat.BuildNotifyResponse(false))
|
||||
return
|
||||
}
|
||||
resp := h.svc.XPayNotify(c.Request.Context(), raw)
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", resp)
|
||||
}
|
||||
|
||||
// wechatCallbackSuccess 按微信规范返回受理成功,避免微信重复回调。
|
||||
func wechatCallbackSuccess(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS", "message": "成功"})
|
||||
}
|
||||
|
||||
// wechatCallbackFail 按微信规范返回受理失败,微信将按策略重试。
|
||||
func wechatCallbackFail(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": message})
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// OutfitHandler 穿搭相关接口。
|
||||
type OutfitHandler struct {
|
||||
svc *service.OutfitService
|
||||
}
|
||||
|
||||
// NewOutfitHandler 创建穿搭 handler。
|
||||
func NewOutfitHandler() *OutfitHandler {
|
||||
return &OutfitHandler{svc: service.NewOutfitService()}
|
||||
}
|
||||
|
||||
// Create 创建穿搭。
|
||||
func (h *OutfitHandler) Create(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
var request dto.OutfitCreateRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
out, e := h.svc.Create(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// List 分页查询我的穿搭。
|
||||
func (h *OutfitHandler) List(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
var request dto.OutfitListRequest
|
||||
if err := c.ShouldBindQuery(&request); err != nil {
|
||||
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
out, e := h.svc.List(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// Get 获取穿搭详情(uuid 来自路径参数)。
|
||||
func (h *OutfitHandler) Get(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
request := dto.OutfitGetByUUIDRequest{OutfitUUID: c.Param("uuid")}
|
||||
if request.OutfitUUID == "" {
|
||||
response.ToErrorResponse(errcode.InvalidParams)
|
||||
return
|
||||
}
|
||||
out, e := h.svc.GetByUUID(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// Update 修改穿搭(uuid 来自路径参数)。
|
||||
func (h *OutfitHandler) Update(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
var request dto.OutfitUpdateByUUIDRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
request.OutfitUUID = c.Param("uuid")
|
||||
if request.OutfitUUID == "" {
|
||||
response.ToErrorResponse(errcode.InvalidParams)
|
||||
return
|
||||
}
|
||||
out, e := h.svc.UpdateByUUID(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// Delete 删除穿搭(uuid 来自路径参数)。
|
||||
func (h *OutfitHandler) Delete(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
request := dto.OutfitDeleteRequest{OutfitUUID: c.Param("uuid")}
|
||||
if request.OutfitUUID == "" {
|
||||
response.ToErrorResponse(errcode.InvalidParams)
|
||||
return
|
||||
}
|
||||
out, e := h.svc.Delete(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// Public 设置穿搭公开状态(uuid 来自路径参数)。
|
||||
func (h *OutfitHandler) Public(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
var request dto.OutfitPublicRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
request.OutfitUUID = c.Param("uuid")
|
||||
if request.OutfitUUID == "" {
|
||||
response.ToErrorResponse(errcode.InvalidParams)
|
||||
return
|
||||
}
|
||||
out, e := h.svc.Public(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// QuotaHandler 额度相关接口。
|
||||
type QuotaHandler struct {
|
||||
svc *service.QuotaService
|
||||
}
|
||||
|
||||
// NewQuotaHandler 创建额度 handler。
|
||||
func NewQuotaHandler() *QuotaHandler {
|
||||
return &QuotaHandler{svc: service.NewQuotaService()}
|
||||
}
|
||||
|
||||
// Info 获取当前用户额度概览。
|
||||
func (h *QuotaHandler) Info(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
out, e := h.svc.InfoGetBySelf(c.Request.Context(), userID)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// FlowList 分页查询当前用户额度流水。
|
||||
func (h *QuotaHandler) FlowList(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
var request dto.QuotaFlowListRequest
|
||||
if err := c.ShouldBindQuery(&request); err != nil {
|
||||
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
out, e := h.svc.FlowList(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Package v1 提供 /api/v1 版本的 HTTP handler,负责请求绑定、鉴权透传与统一响应,
|
||||
// 业务逻辑与归属校验均在 internal/service 层完成。
|
||||
package v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-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()}
|
||||
}
|
||||
|
||||
// WxLogin 微信登录(公开)。
|
||||
func (h *UserHandler) WxLogin(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
var request dto.UserWxLoginRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
out, e := h.svc.WxLogin(c.Request.Context(), &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// RefreshToken 刷新访问令牌(公开)。
|
||||
func (h *UserHandler) RefreshToken(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
var request dto.UserRefreshTokenRequest
|
||||
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)
|
||||
}
|
||||
|
||||
// InfoGetBySelf 获取当前用户信息。
|
||||
func (h *UserHandler) InfoGetBySelf(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
out, e := h.svc.InfoGetBySelf(c.Request.Context(), userID)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// ProfileUpdate 修改当前用户资料。
|
||||
func (h *UserHandler) ProfileUpdate(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
var request dto.UserProfileUpdateBySelfRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
out, e := h.svc.ProfileUpdate(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// AvatarUpload 上传用户头像(multipart,字段名 file)。
|
||||
func (h *UserHandler) AvatarUpload(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
// 解析 multipart 前限制请求体大小(MaxSizeMB + 1MB 的 multipart 边界开销),
|
||||
// 防止超大 body 在 FormFile 解析时耗尽内存/磁盘。
|
||||
maxBytes := int64(global.UploadSetting.MaxSizeMB)*1024*1024 + 1024*1024
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
response.ToErrorResponse(errcode.UploadFileEmpty.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
out, e := h.svc.AvatarUpload(c.Request.Context(), userID, fileHeader)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package routers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware"
|
||||
v1 "git.lzcwy.cn/zhengchiliu/zt-closet-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 实例
|
||||
userHandler := v1.NewUserHandler()
|
||||
quotaHandler := v1.NewQuotaHandler()
|
||||
horoscopeHandler := v1.NewHoroscopeHandler()
|
||||
clothesHandler := v1.NewClothesHandler()
|
||||
outfitHandler := v1.NewOutfitHandler()
|
||||
closetHandler := v1.NewClosetShareHandler()
|
||||
collectHandler := v1.NewCollectHandler()
|
||||
likeHandler := v1.NewLikeHandler()
|
||||
notificationHandler := v1.NewNotificationHandler()
|
||||
goodsHandler := v1.NewGoodsHandler()
|
||||
orderHandler := v1.NewOrderHandler()
|
||||
aiHandler := v1.NewAIRecommendHandler()
|
||||
adRewardHandler := v1.NewAdRewardHandler()
|
||||
|
||||
apiV1 := r.Group("/api/v1")
|
||||
|
||||
// ===== 公开路由(无需登录)=====
|
||||
{
|
||||
apiV1.POST("/user/wxlogin", userHandler.WxLogin)
|
||||
apiV1.POST("/user/refresh-token", userHandler.RefreshToken)
|
||||
|
||||
apiV1.GET("/goods", goodsHandler.List)
|
||||
apiV1.GET("/goods/:code", goodsHandler.GetByCode)
|
||||
|
||||
// 微信支付/退款回调
|
||||
apiV1.POST("/order/pay/notify", orderHandler.PayNotify)
|
||||
apiV1.POST("/order/refund/notify", orderHandler.RefundNotify)
|
||||
|
||||
// 虚拟支付消息推送(GET 为 URL 配置验证,POST 为业务回调)
|
||||
apiV1.GET("/xpay/notify", orderHandler.XPayVerify)
|
||||
apiV1.POST("/xpay/notify", orderHandler.XPayNotify)
|
||||
}
|
||||
|
||||
// ===== 鉴权路由(需登录)=====
|
||||
auth := apiV1.Group("")
|
||||
auth.Use(middleware.JWTAuth())
|
||||
{
|
||||
// 用户
|
||||
auth.GET("/user/info", userHandler.InfoGetBySelf)
|
||||
auth.PUT("/user/profile", userHandler.ProfileUpdate)
|
||||
auth.POST("/user/avatar/upload", userHandler.AvatarUpload)
|
||||
|
||||
// 额度
|
||||
auth.GET("/quota/info", quotaHandler.Info)
|
||||
auth.GET("/quota/flow", quotaHandler.FlowList)
|
||||
|
||||
// 运势
|
||||
auth.GET("/horoscope/today", horoscopeHandler.Today)
|
||||
|
||||
// 衣物
|
||||
auth.POST("/clothes/upload", clothesHandler.Upload)
|
||||
auth.GET("/clothes", clothesHandler.List)
|
||||
auth.GET("/clothes/:uuid", clothesHandler.Get)
|
||||
auth.GET("/clothes/:uuid/status", clothesHandler.Status)
|
||||
auth.PUT("/clothes/:uuid", clothesHandler.Update)
|
||||
auth.DELETE("/clothes/:uuid", clothesHandler.Delete)
|
||||
|
||||
// 穿搭
|
||||
auth.POST("/outfit", outfitHandler.Create)
|
||||
auth.GET("/outfit", outfitHandler.List)
|
||||
auth.GET("/outfit/:uuid", outfitHandler.Get)
|
||||
auth.PUT("/outfit/:uuid", outfitHandler.Update)
|
||||
auth.DELETE("/outfit/:uuid", outfitHandler.Delete)
|
||||
auth.PUT("/outfit/:uuid/public", outfitHandler.Public)
|
||||
|
||||
// 衣柜分享
|
||||
auth.POST("/closet-share", closetHandler.Create)
|
||||
auth.GET("/closet-share", closetHandler.List)
|
||||
auth.GET("/closet-share/received", closetHandler.ReceivedList)
|
||||
auth.GET("/closet-share/:uuid", closetHandler.Detail)
|
||||
auth.POST("/closet-share/:uuid/cancel", closetHandler.Cancel)
|
||||
|
||||
// 收藏
|
||||
auth.POST("/collect", collectHandler.Collect)
|
||||
auth.GET("/collect", collectHandler.List)
|
||||
auth.GET("/collect/:targetUUID", collectHandler.GetByTarget)
|
||||
|
||||
// 点赞
|
||||
auth.POST("/like", likeHandler.Like)
|
||||
auth.GET("/like/:targetUUID", likeHandler.GetByTarget)
|
||||
|
||||
// 通知
|
||||
auth.GET("/notification", notificationHandler.List)
|
||||
auth.GET("/notification/unread", notificationHandler.Unread)
|
||||
auth.GET("/notification/:uuid", notificationHandler.Get)
|
||||
auth.POST("/notification/:uuid/read", notificationHandler.Read)
|
||||
|
||||
// 订单
|
||||
auth.POST("/order", orderHandler.Create)
|
||||
auth.POST("/order/virtual", orderHandler.VirtualCreate)
|
||||
auth.GET("/order", orderHandler.List)
|
||||
auth.POST("/order/:orderNo/cancel", orderHandler.Cancel)
|
||||
auth.POST("/order/refund", orderHandler.RefundApply)
|
||||
|
||||
// 广告激励额度
|
||||
auth.POST("/ad-reward/grant", adRewardHandler.Grant)
|
||||
|
||||
// AI 推荐
|
||||
auth.POST("/ai-recommend", aiHandler.Create)
|
||||
auth.POST("/ai-recommend/save", aiHandler.Save)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||
)
|
||||
|
||||
// AdRewardService 广告激励额度服务。
|
||||
type AdRewardService struct{}
|
||||
|
||||
// NewAdRewardService 创建广告激励额度服务。
|
||||
func NewAdRewardService() *AdRewardService {
|
||||
return &AdRewardService{}
|
||||
}
|
||||
|
||||
// Grant 发放广告激励额度:校验开关后按服务端配置的固定金额创建当天有效凭证。
|
||||
// ref_id+quota_type 唯一索引兜底幂等,重复领取返回 AdRewardClaimed。
|
||||
func (s *AdRewardService) Grant(ctx context.Context, userID int64, request *dto.AdRewardGrantRequest) (*dto.AdRewardGrantResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AdRewardGrant")
|
||||
defer span.End()
|
||||
|
||||
quotaType, ok := parseAdRewardType(request.RewardType)
|
||||
if !ok {
|
||||
return nil, errcode.AdRewardInvalid
|
||||
}
|
||||
|
||||
// 开关校验:广告激励未开启时拒绝发放。
|
||||
if global.AdRewardSetting == nil || !global.AdRewardSetting.Enable {
|
||||
return nil, errcode.AdRewardDisabled
|
||||
}
|
||||
|
||||
// 服务端定价:发放金额由配置固定,前端仅传类型与广告记录 ID,杜绝刷额度。
|
||||
amount := adRewardAmount(quotaType)
|
||||
if amount <= 0 {
|
||||
return nil, errcode.AdRewardInvalid.WithDetails("该激励类型未配置发放数量")
|
||||
}
|
||||
|
||||
grant := &mysqlmodel.QuotaGrant{
|
||||
UserID: userID,
|
||||
QuotaType: quotaType,
|
||||
Amount: amount,
|
||||
Used: 0,
|
||||
Source: QuotaGrantSourceAdReward,
|
||||
ExpireTime: todayEndUnix(), // 当天 23:59:59 到期
|
||||
RefID: request.RefID,
|
||||
}
|
||||
if err := mysqldao.NewQuotaGrantDao().QuotaGrantCreate(ctx, grant); err != nil {
|
||||
if isDuplicateKey(err) {
|
||||
return nil, errcode.AdRewardClaimed
|
||||
}
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
return &dto.AdRewardGrantResponse{
|
||||
RefID: request.RefID,
|
||||
QuotaType: quotaType,
|
||||
Amount: amount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// adRewardAmount 返回指定额度类型的广告激励固定金额(服务端配置)。
|
||||
// 未配置(指针为 nil)或配置为非正数时返回 0,表示不发放该类型激励。
|
||||
func adRewardAmount(quotaType int8) int64 {
|
||||
cfg := global.AdRewardSetting
|
||||
if cfg == nil {
|
||||
return 0
|
||||
}
|
||||
var p *int64
|
||||
switch quotaType {
|
||||
case QuotaTypeCos:
|
||||
p = cfg.Cos
|
||||
case QuotaTypeAI:
|
||||
p = cfg.AI
|
||||
case QuotaTypeOutfitShare:
|
||||
p = cfg.OutfitShare
|
||||
case QuotaTypeClosetShare:
|
||||
p = cfg.ClosetShare
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
if p == nil || *p <= 0 {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// parseAdRewardType 将 reward_type 字符串映射到额度类型。
|
||||
func parseAdRewardType(rewardType string) (int8, bool) {
|
||||
switch rewardType {
|
||||
case "cos":
|
||||
return QuotaTypeCos, true
|
||||
case "ai":
|
||||
return QuotaTypeAI, true
|
||||
case "outfit_share":
|
||||
return QuotaTypeOutfitShare, true
|
||||
case "closet_share":
|
||||
return QuotaTypeClosetShare, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// todayEndUnix 返回当天 23:59:59 的秒级时间戳(广告激励当天有效)。
|
||||
func todayEndUnix() int64 {
|
||||
now := time.Now()
|
||||
return time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).Unix()
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/tokenhub"
|
||||
)
|
||||
|
||||
// AIRecommendService AI 穿搭推荐业务服务。
|
||||
type AIRecommendService struct {
|
||||
outfitDao *mysqldao.OutfitDao
|
||||
outfitItemDao *mysqldao.OutfitItemDao
|
||||
clothesDao *mysqldao.ClothesDao
|
||||
userDao *mysqldao.UserDao
|
||||
horoscopeDao *mysqldao.HoroscopeDao
|
||||
flowLogDao *mysqldao.AIRecommendFlowLogDao
|
||||
}
|
||||
|
||||
// NewAIRecommendService 创建 AI 推荐服务。
|
||||
func NewAIRecommendService() *AIRecommendService {
|
||||
return &AIRecommendService{
|
||||
outfitDao: mysqldao.NewOutfitDao(),
|
||||
outfitItemDao: mysqldao.NewOutfitItemDao(),
|
||||
clothesDao: mysqldao.NewClothesDao(),
|
||||
userDao: mysqldao.NewUserDao(),
|
||||
horoscopeDao: mysqldao.NewHoroscopeDao(),
|
||||
flowLogDao: mysqldao.NewAIRecommendFlowLogDao(),
|
||||
}
|
||||
}
|
||||
|
||||
// Create 发起 AI 穿搭推荐:防并发、扣 AI 额度、基于衣橱与运势生成推荐,落库为待保存穿搭。
|
||||
func (s *AIRecommendService) Create(ctx context.Context, userID int64, request *dto.AIRecommendCreateRequest) (*dto.AIRecommendCreateResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AIRecommendCreate")
|
||||
defer span.End()
|
||||
|
||||
// 1. 防并发锁。
|
||||
lockKey := fmt.Sprintf(redismodel.KeyLockAIRecommend, userID)
|
||||
lockVal := genUUID()
|
||||
ok, err := redisdao.TryLock(ctx, lockKey, lockVal, 180*time.Second)
|
||||
if err != nil {
|
||||
// Redis 异常:返回服务错误而非误报「请求过于频繁」。
|
||||
return nil, errcode.CacheError
|
||||
}
|
||||
if !ok {
|
||||
return nil, errcode.TooManyRequests
|
||||
}
|
||||
defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }()
|
||||
|
||||
// 2. 扣减 AI 每日额度(权威扣减)。
|
||||
if qerr := consumeDailyQuota(ctx, userID, QuotaTypeAI, 1, "AI 推荐消耗"); qerr != nil {
|
||||
if qerr == errcode.QuotaInsufficient {
|
||||
return nil, errcode.AIRecommendQuotaLimit
|
||||
}
|
||||
return nil, qerr
|
||||
}
|
||||
|
||||
// 失败时统一回退额度的兜底闭包。
|
||||
fail := func(bizErr *errcode.Error, reason string) *errcode.Error {
|
||||
refundDailyQuota(ctx, userID, QuotaTypeAI, 1)
|
||||
s.writeFlowLog(ctx, userID, "", AIFlowStatusFailed, reason)
|
||||
return bizErr
|
||||
}
|
||||
|
||||
// 3. 加载用户可用衣物(已成功处理)。
|
||||
clothes, cerr := s.processedClothes(ctx, userID)
|
||||
if cerr != nil {
|
||||
return nil, fail(errcode.DatabaseError, "查询衣橱失败")
|
||||
}
|
||||
if len(clothes) == 0 {
|
||||
return nil, fail(errcode.AIRecommendFailed.WithDetails("衣橱暂无可用衣物"), "衣橱为空")
|
||||
}
|
||||
|
||||
// 4. 计算星座与今日运势(best-effort)。
|
||||
zodiac, fortune := s.userFortune(ctx, userID)
|
||||
|
||||
// 5. 调用大模型生成推荐。
|
||||
requestTime := nowUnix()
|
||||
client := tokenhub.NewClient()
|
||||
result, gerr := client.GenerateOutfitRecommendation(ctx, zodiac, fortune, clothes)
|
||||
if gerr != nil {
|
||||
return nil, fail(errcode.AIRecommendFailed, "大模型调用失败")
|
||||
}
|
||||
result.ValidateClothesIDs(clothes)
|
||||
if len(result.Items) == 0 {
|
||||
return nil, fail(errcode.AIRecommendFailed.WithDetails("未生成有效搭配"), "无有效单品")
|
||||
}
|
||||
|
||||
// 6. 落库为待用户保存的 AI 穿搭。
|
||||
scene := result.Scene
|
||||
if scene == "" {
|
||||
scene = request.Scene
|
||||
}
|
||||
outfit := &mysqlmodel.Outfit{
|
||||
OutfitUUID: genUUID(),
|
||||
UserID: userID,
|
||||
OutfitName: result.OutfitName,
|
||||
OutfitDesc: result.Description,
|
||||
Scene: scene,
|
||||
Source: OutfitSourceAI,
|
||||
IsPublic: OutfitPrivate,
|
||||
AuditStatus: OutfitAuditPending,
|
||||
Status: OutfitStatusUnsaved,
|
||||
}
|
||||
if err := s.outfitDao.OutfitCreate(ctx, outfit); err != nil {
|
||||
return nil, fail(errcode.OutfitCreateFailed, "穿搭落库失败")
|
||||
}
|
||||
|
||||
clothesIDs := result.GetClothesIDs()
|
||||
items := make([]mysqlmodel.OutfitItem, 0, len(clothesIDs))
|
||||
for _, cid := range clothesIDs {
|
||||
items = append(items, mysqlmodel.OutfitItem{OutfitID: outfit.ID, ClothesID: cid})
|
||||
}
|
||||
if err := s.outfitItemDao.OutfitItemCreateBatch(ctx, items); err != nil {
|
||||
// 明细落库失败:删除已建穿搭并回退额度,避免产生无明细的空推荐。
|
||||
_ = s.outfitDao.OutfitDeleteByUUID(ctx, outfit.OutfitUUID)
|
||||
return nil, fail(errcode.OutfitCreateFailed, "穿搭明细落库失败")
|
||||
}
|
||||
|
||||
// 7. 写成功流水 + 当日计数。
|
||||
resultJSON, _ := json.Marshal(result)
|
||||
if err := s.flowLogDao.AIRecommendFlowLogCreate(ctx, &mysqlmodel.AIRecommendFlowLog{
|
||||
RequestID: genUUID(),
|
||||
UserID: userID,
|
||||
ModelVersion: global.TokenhubSetting.Model,
|
||||
ResultContent: string(resultJSON),
|
||||
Status: AIFlowStatusSuccess,
|
||||
RequestTime: requestTime,
|
||||
ResponseTime: nowUnix(),
|
||||
}); err != nil {
|
||||
slog.WarnContext(ctx, "write ai recommend success flow log failed", "user_id", userID, "error", err)
|
||||
}
|
||||
recordDailyUsage(ctx, userID, QuotaTypeAI)
|
||||
|
||||
// 8. 组装响应(衣物 UUID 来自校正后的 items)。
|
||||
outfitItems := make([]dto.OutfitItem, 0, len(result.Items))
|
||||
for _, it := range result.Items {
|
||||
outfitItems = append(outfitItems, dto.OutfitItem{ClothesUUID: it.ClothesUUID})
|
||||
}
|
||||
|
||||
return &dto.AIRecommendCreateResponse{
|
||||
OutfitUUID: outfit.OutfitUUID,
|
||||
OutfitName: outfit.OutfitName,
|
||||
OutfitDesc: outfit.OutfitDesc,
|
||||
Scene: outfit.Scene,
|
||||
Source: outfit.Source,
|
||||
Status: outfit.Status,
|
||||
OutfitItem: outfitItems,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Save 将 AI 推荐生成的待保存穿搭保存到「我的搭配」(置为正常状态)。
|
||||
func (s *AIRecommendService) Save(ctx context.Context, userID int64, request *dto.AIRecommendSaveRequest) *errcode.Error {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.AIRecommendSave")
|
||||
defer span.End()
|
||||
|
||||
outfit, err := s.outfitDao.OutfitGetByUUID(ctx, request.OutfitUUID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return errcode.OutfitNotFound
|
||||
}
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
if outfit.UserID != userID {
|
||||
return errcode.OutfitNoPermission
|
||||
}
|
||||
// 仅待保存状态可保存;已正常则幂等返回。
|
||||
if outfit.Status == OutfitStatusNormal {
|
||||
return nil
|
||||
}
|
||||
if outfit.Status != OutfitStatusUnsaved {
|
||||
return errcode.OutfitUpdateFailed
|
||||
}
|
||||
|
||||
if uerr := s.outfitDao.OutfitUpdateByUUID(ctx, request.OutfitUUID, map[string]any{
|
||||
"status": OutfitStatusNormal,
|
||||
}); uerr != nil {
|
||||
return errcode.OutfitUpdateFailed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- 内部辅助 ----
|
||||
|
||||
// processedClothes 获取用户已成功处理、可用于推荐的衣物。
|
||||
func (s *AIRecommendService) processedClothes(ctx context.Context, userID int64) ([]mysqlmodel.Clothes, error) {
|
||||
var result []mysqlmodel.Clothes
|
||||
page, pageSize := 1, 200
|
||||
for {
|
||||
list, total, err := s.clothesDao.ClothesListByUserID(ctx, userID, "", "", page, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, c := range list {
|
||||
if c.ImageProcessStatus == ImageProcessSuccess {
|
||||
result = append(result, c)
|
||||
}
|
||||
}
|
||||
if len(list) == 0 || int64(page*pageSize) >= total {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// userFortune 计算用户星座并读取今日运势(缺失时返回仅含星座的空运势)。
|
||||
func (s *AIRecommendService) userFortune(ctx context.Context, userID int64) (string, *tokenhub.FortuneItem) {
|
||||
zodiac := ""
|
||||
if user, err := s.userDao.UserGetByID(ctx, userID); err == nil && user.BirthDay != 0 {
|
||||
zodiac = zodiacByUnix(user.BirthDay)
|
||||
}
|
||||
|
||||
fortune := &tokenhub.FortuneItem{Zodiac: zodiac}
|
||||
if zodiac != "" {
|
||||
if h, err := s.horoscopeDao.HoroscopeGetByZodiacAndDate(ctx, zodiac, todayStatDate()); err == nil && h != nil {
|
||||
fortune = &tokenhub.FortuneItem{
|
||||
Zodiac: h.Zodiac,
|
||||
Overall: h.OverallScore,
|
||||
Love: h.LoveScore,
|
||||
Work: h.CareerScore,
|
||||
Money: h.WealthScore,
|
||||
LuckyColor: h.LuckyColor,
|
||||
LuckyNumber: h.LuckyNumber,
|
||||
Description: h.DailyText,
|
||||
}
|
||||
}
|
||||
}
|
||||
return zodiac, fortune
|
||||
}
|
||||
|
||||
// writeFlowLog 写 AI 推荐流水(best-effort,失败仅告警不阻断主流程)。
|
||||
func (s *AIRecommendService) writeFlowLog(ctx context.Context, userID int64, resultContent string, status int8, failReason string) {
|
||||
if err := s.flowLogDao.AIRecommendFlowLogCreate(ctx, &mysqlmodel.AIRecommendFlowLog{
|
||||
RequestID: genUUID(),
|
||||
UserID: userID,
|
||||
ModelVersion: global.TokenhubSetting.Model,
|
||||
ResultContent: resultContent,
|
||||
Status: status,
|
||||
FailReason: failReason,
|
||||
RequestTime: nowUnix(),
|
||||
ResponseTime: nowUnix(),
|
||||
}); err != nil {
|
||||
slog.WarnContext(ctx, "write ai recommend flow log failed", "user_id", userID, "status", status, "error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
|
||||
)
|
||||
|
||||
// StartBenefitCompensationScheduler 启动权益发放补偿定时任务:周期性重试发放失败的权益。
|
||||
func StartBenefitCompensationScheduler() {
|
||||
go func() {
|
||||
slog.Info("benefit compensation scheduler started")
|
||||
for {
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("benefit compensation scheduler panic", "recover", r)
|
||||
}
|
||||
}()
|
||||
runBenefitCompensation()
|
||||
}()
|
||||
time.Sleep(time.Minute)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// runBenefitCompensation 扫描待发放补偿任务并重试,单实例通过 Redis 分布式锁防重。
|
||||
func runBenefitCompensation() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
ctx, span := global.Tracer.Start(ctx, "scheduler.BenefitCompensation")
|
||||
defer span.End()
|
||||
|
||||
lockKey := "ztcloset:lock:benefit:compensation"
|
||||
lockVal := genUUID()
|
||||
ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, time.Minute)
|
||||
if lerr != nil || !ok {
|
||||
return
|
||||
}
|
||||
defer redisdao.Unlock(context.Background(), lockKey, lockVal)
|
||||
|
||||
taskDao := mysqldao.NewBenefitGrantTaskDao()
|
||||
|
||||
// 恢复超时的「发放中」任务(认领后崩溃的场景),使其回到待发放继续重试。
|
||||
_, _ = taskDao.BenefitGrantTaskRecoverStale(ctx, nowUnix()-int64((10*time.Minute).Seconds()))
|
||||
|
||||
tasks, err := taskDao.BenefitGrantTaskListPending(ctx, 100)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "list pending benefit tasks failed", "error", err)
|
||||
return
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
s := NewOrderService()
|
||||
for i := range tasks {
|
||||
s.retryBenefitTask(ctx, &tasks[i])
|
||||
}
|
||||
}
|
||||
|
||||
// retryBenefitTask 重试单个补偿任务(幂等:先查发放流水避免重复发放)。
|
||||
func (s *OrderService) retryBenefitTask(ctx context.Context, task *mysqlmodel.BenefitGrantTask) {
|
||||
taskDao := mysqldao.NewBenefitGrantTaskDao()
|
||||
|
||||
// 重试次数超限,标记失败并告警,等待人工介入。
|
||||
if task.RetryCount >= task.MaxRetry {
|
||||
_ = taskDao.BenefitGrantTaskUpdateByID(ctx, task.ID, map[string]any{"status": BenefitTaskFailed})
|
||||
slog.ErrorContext(ctx, "benefit grant task exhausted retries",
|
||||
"order_no", task.OrderNo, "benefit_type", task.BenefitType, "retry_count", task.RetryCount)
|
||||
return
|
||||
}
|
||||
|
||||
// 幂等判断:若发放流水已存在(上次发放成功但未置 success 的崩溃窗口),直接置成功。
|
||||
if granted, err := s.benefitAlreadyGranted(ctx, task); err == nil && granted {
|
||||
_ = taskDao.BenefitGrantTaskUpdateByID(ctx, task.ID, map[string]any{"status": BenefitTaskSuccess})
|
||||
return
|
||||
} else if err != nil {
|
||||
slog.ErrorContext(ctx, "check benefit granted failed",
|
||||
"order_no", task.OrderNo, "benefit_type", task.BenefitType, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 执行发放(claim 防并发,内部处理 retry_count 原子自增与状态流转)。
|
||||
s.executeGrant(ctx, task)
|
||||
}
|
||||
|
||||
// benefitAlreadyGranted 判断该权益是否已发放(用于崩溃恢复幂等)。
|
||||
// VIP 查 VIP 流水;额度类查额度凭证(quota_grants)。
|
||||
func (s *OrderService) benefitAlreadyGranted(ctx context.Context, task *mysqlmodel.BenefitGrantTask) (bool, error) {
|
||||
grantDao := mysqldao.NewQuotaGrantDao()
|
||||
switch task.BenefitType {
|
||||
case BenefitTypeVIP:
|
||||
return s.vipDao.VIPFlowLogExistsByOrderNo(ctx, task.OrderNo)
|
||||
case BenefitTypeCos:
|
||||
return grantDao.QuotaGrantExistsByRefIDAndType(ctx, task.OrderNo, QuotaTypeCos)
|
||||
case BenefitTypeAI:
|
||||
return grantDao.QuotaGrantExistsByRefIDAndType(ctx, task.OrderNo, QuotaTypeAI)
|
||||
case BenefitTypeClosetShare:
|
||||
return grantDao.QuotaGrantExistsByRefIDAndType(ctx, task.OrderNo, QuotaTypeClosetShare)
|
||||
case BenefitTypeOutfitShare:
|
||||
return grantDao.QuotaGrantExistsByRefIDAndType(ctx, task.OrderNo, QuotaTypeOutfitShare)
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/upload"
|
||||
)
|
||||
|
||||
// ClosetShareService 衣柜分享业务服务。
|
||||
type ClosetShareService struct {
|
||||
shareDao *mysqldao.ClosetShareDao
|
||||
shareItemDao *mysqldao.ClosetShareItemDao
|
||||
receiverDao *mysqldao.ClosetShareReceiverDao
|
||||
clothesDao *mysqldao.ClothesDao
|
||||
userDao *mysqldao.UserDao
|
||||
auditLogDao *mysqldao.ClosetAuditLogDao
|
||||
}
|
||||
|
||||
// NewClosetShareService 创建衣柜分享服务。
|
||||
func NewClosetShareService() *ClosetShareService {
|
||||
return &ClosetShareService{
|
||||
shareDao: mysqldao.NewClosetShareDao(),
|
||||
shareItemDao: mysqldao.NewClosetShareItemDao(),
|
||||
receiverDao: mysqldao.NewClosetShareReceiverDao(),
|
||||
clothesDao: mysqldao.NewClothesDao(),
|
||||
userDao: mysqldao.NewUserDao(),
|
||||
auditLogDao: mysqldao.NewClosetAuditLogDao(),
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建衣柜分享。部分分享需指定衣物;全量快照分享快照当前全部衣物。创建即自动审核通过。
|
||||
func (s *ClosetShareService) Create(ctx context.Context, userID int64, request *dto.ClosetShareCreateRequest) (*dto.ClosetShareCreateResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.ClosetShareCreate")
|
||||
defer span.End()
|
||||
|
||||
// 校验过期时间:必须晚于当前时间(0 表示不设置过期时间)。
|
||||
if request.ExpireTime > 0 && request.ExpireTime <= nowUnix() {
|
||||
return nil, errcode.InvalidParams.WithDetails("过期时间必须晚于当前时间")
|
||||
}
|
||||
|
||||
// 1. 确定分享的衣物 ID 集合。
|
||||
var clothesIDs []int64
|
||||
switch request.ShareType {
|
||||
case ShareTypePartial:
|
||||
if len(request.ClothesUUIDs) == 0 {
|
||||
return nil, errcode.InvalidParams.WithDetails("部分分享需选择衣物")
|
||||
}
|
||||
ids, cerr := s.resolveOwnedClothesIDs(ctx, userID, request.ClothesUUIDs)
|
||||
if cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
clothesIDs = ids
|
||||
case ShareTypeSnapshot:
|
||||
ids, err := s.allUserClothesIDs(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
clothesIDs = ids
|
||||
default:
|
||||
return nil, errcode.InvalidParams
|
||||
}
|
||||
|
||||
// 2. 消耗衣柜分享每日额度。
|
||||
if qerr := consumeDailyQuota(ctx, userID, QuotaTypeClosetShare, 1, "衣柜分享消耗"); qerr != nil {
|
||||
return nil, qerr
|
||||
}
|
||||
|
||||
// 3. 创建分享。
|
||||
shareUUID := genUUID()
|
||||
share := &mysqlmodel.ClosetShare{
|
||||
ShareUUID: shareUUID,
|
||||
UserID: userID,
|
||||
ShareType: request.ShareType,
|
||||
ExpireTime: request.ExpireTime,
|
||||
Status: ShareStatusNormal,
|
||||
}
|
||||
if err := s.shareDao.ClosetShareCreate(ctx, share); err != nil {
|
||||
refundDailyQuota(ctx, userID, QuotaTypeClosetShare, 1)
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
// 4. 快照衣物明细(明细是分享核心数据,失败则回退额度并报错,避免产生空分享)。
|
||||
items := make([]mysqlmodel.ClosetShareItem, 0, len(clothesIDs))
|
||||
for _, cid := range clothesIDs {
|
||||
items = append(items, mysqlmodel.ClosetShareItem{ShareUUID: shareUUID, ClothesID: cid})
|
||||
}
|
||||
if err := s.shareItemDao.ClosetShareItemCreateBatch(ctx, items); err != nil {
|
||||
refundDailyQuota(ctx, userID, QuotaTypeClosetShare, 1)
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
// 5. 自动审核通过,写审核日志(best-effort:失败仅告警不阻断创建)。
|
||||
if err := s.auditLogDao.ClosetAuditLogCreate(ctx, &mysqlmodel.ClosetAuditLog{
|
||||
TargetID: share.ID,
|
||||
AuditResult: AuditResultPass,
|
||||
AuditTime: nowUnix(),
|
||||
}); err != nil {
|
||||
slog.WarnContext(ctx, "write closet audit log failed", "share_id", share.ID, "error", err)
|
||||
}
|
||||
|
||||
recordDailyUsage(ctx, userID, QuotaTypeClosetShare)
|
||||
|
||||
return &dto.ClosetShareCreateResponse{ShareUUID: shareUUID}, nil
|
||||
}
|
||||
|
||||
// Detail 查看分享详情(访客/属主视角)。
|
||||
func (s *ClosetShareService) Detail(ctx context.Context, userID int64, shareUUID string) (*dto.ClosetShareDetailResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.ClosetShareDetail")
|
||||
defer span.End()
|
||||
|
||||
share, err := s.shareDao.ClosetShareGetByUUID(ctx, shareUUID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, errcode.ShareNotFound
|
||||
}
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
// 状态校验。
|
||||
switch share.Status {
|
||||
case ShareStatusCancelled:
|
||||
return nil, errcode.ShareCancelled
|
||||
case ShareStatusBanned:
|
||||
return nil, errcode.ShareNotFound
|
||||
case ShareStatusExpired:
|
||||
return nil, errcode.ShareExpired
|
||||
}
|
||||
// 时间过期校验。
|
||||
if share.ExpireTime > 0 && nowUnix() > share.ExpireTime {
|
||||
_ = s.shareDao.ClosetShareUpdateStatus(ctx, shareUUID, ShareStatusExpired)
|
||||
return nil, errcode.ShareExpired
|
||||
}
|
||||
|
||||
isOwner := share.UserID == userID
|
||||
|
||||
// 访问计数(访客计入)。
|
||||
if !isOwner {
|
||||
_, _ = redisdao.ClosetShareViewIncr(ctx, shareUUID)
|
||||
}
|
||||
|
||||
// 加载分享的衣物。
|
||||
shareItems, ierr := s.shareItemDao.ClosetShareItemListByShareUUID(ctx, shareUUID)
|
||||
if ierr != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
clothesIDs := make([]int64, 0, len(shareItems))
|
||||
for _, it := range shareItems {
|
||||
clothesIDs = append(clothesIDs, it.ClothesID)
|
||||
}
|
||||
clothesList, cerr := s.clothesDao.ClothesGetByIDs(ctx, clothesIDs)
|
||||
if cerr != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
clothesResp := make([]dto.ClothesGetByUUIDResponse, 0, len(clothesList))
|
||||
for i := range clothesList {
|
||||
clothesResp = append(clothesResp, buildClothesResponse(&clothesList[i]))
|
||||
}
|
||||
|
||||
// 记录接收关系:登录且非属主用户成功访问即视为收到,幂等,失败不阻断详情返回。
|
||||
if userID > 0 && !isOwner {
|
||||
if rerr := s.receiverDao.ClosetShareReceiverUpsert(ctx, shareUUID, userID); rerr != nil {
|
||||
slog.WarnContext(ctx, "record closet share receiver failed", "share_uuid", shareUUID, "user_id", userID, "error", rerr)
|
||||
}
|
||||
}
|
||||
|
||||
// 分享者信息。
|
||||
ownerName, ownerAvatar := "", ""
|
||||
if owner, oerr := s.userDao.UserGetByID(ctx, share.UserID); oerr == nil {
|
||||
ownerName = owner.Username
|
||||
ownerAvatar = upload.AvatarURL(owner.Avatar)
|
||||
}
|
||||
|
||||
return &dto.ClosetShareDetailResponse{
|
||||
ShareUUID: shareUUID,
|
||||
OwnerName: ownerName,
|
||||
OwnerAvatar: ownerAvatar,
|
||||
Clothes: clothesResp,
|
||||
ExpireTime: share.ExpireTime,
|
||||
Status: share.Status,
|
||||
IsOwner: isOwner,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// List 分页查询我的分享。
|
||||
func (s *ClosetShareService) List(ctx context.Context, userID int64, request *dto.ClosetShareListRequest) (*dto.ShareListResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.ClosetShareList")
|
||||
defer span.End()
|
||||
|
||||
page, pageSize := normalizePage(request.Page, request.PageSize)
|
||||
|
||||
list, total, err := s.shareDao.ClosetShareListByUserID(ctx, userID, request.Status, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
// 批量统计各分享的衣物数量。
|
||||
shareUUIDs := make([]string, 0, len(list))
|
||||
for _, sh := range list {
|
||||
shareUUIDs = append(shareUUIDs, sh.ShareUUID)
|
||||
}
|
||||
countByShare := make(map[string]int, len(list))
|
||||
if allItems, ierr := s.shareItemDao.ClosetShareItemListByShareUUIDs(ctx, shareUUIDs); ierr == nil {
|
||||
for _, it := range allItems {
|
||||
countByShare[it.ShareUUID]++
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]dto.ClosetShareItemResponse, 0, len(list))
|
||||
for _, sh := range list {
|
||||
items = append(items, dto.ClosetShareItemResponse{
|
||||
ShareUUID: sh.ShareUUID,
|
||||
ShareType: sh.ShareType,
|
||||
ClothesCount: countByShare[sh.ShareUUID],
|
||||
ExpireTime: sh.ExpireTime,
|
||||
Status: sh.Status,
|
||||
})
|
||||
}
|
||||
|
||||
return &dto.ShareListResponse{
|
||||
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
|
||||
List: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListReceived 分页查询"分享给我的"分享。
|
||||
func (s *ClosetShareService) ListReceived(ctx context.Context, userID int64, request *dto.ClosetShareListRequest) (*dto.ShareListResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.ClosetShareReceivedList")
|
||||
defer span.End()
|
||||
|
||||
page, pageSize := normalizePage(request.Page, request.PageSize)
|
||||
|
||||
list, total, err := s.receiverDao.ClosetShareReceivedList(ctx, userID, request.Status, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
// 批量统计各分享的衣物数量。
|
||||
shareUUIDs := make([]string, 0, len(list))
|
||||
ownerIDs := make([]int64, 0, len(list))
|
||||
for _, sh := range list {
|
||||
shareUUIDs = append(shareUUIDs, sh.ShareUUID)
|
||||
ownerIDs = append(ownerIDs, sh.UserID)
|
||||
}
|
||||
countByShare := make(map[string]int, len(list))
|
||||
if allItems, ierr := s.shareItemDao.ClosetShareItemListByShareUUIDs(ctx, shareUUIDs); ierr == nil {
|
||||
for _, it := range allItems {
|
||||
countByShare[it.ShareUUID]++
|
||||
}
|
||||
}
|
||||
|
||||
// 批量查询分享者信息。
|
||||
ownerByID := make(map[int64]mysqlmodel.User, len(ownerIDs))
|
||||
if owners, oerr := s.userDao.UserGetByIDs(ctx, ownerIDs); oerr == nil {
|
||||
for _, u := range owners {
|
||||
ownerByID[u.ID] = u
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]dto.ClosetShareItemResponse, 0, len(list))
|
||||
for _, sh := range list {
|
||||
ownerName, ownerAvatar := "", ""
|
||||
if owner, ok := ownerByID[sh.UserID]; ok {
|
||||
ownerName = owner.Username
|
||||
ownerAvatar = upload.AvatarURL(owner.Avatar)
|
||||
}
|
||||
items = append(items, dto.ClosetShareItemResponse{
|
||||
ShareUUID: sh.ShareUUID,
|
||||
ShareType: sh.ShareType,
|
||||
ClothesCount: countByShare[sh.ShareUUID],
|
||||
ExpireTime: sh.ExpireTime,
|
||||
Status: sh.Status,
|
||||
OwnerName: ownerName,
|
||||
OwnerAvatar: ownerAvatar,
|
||||
})
|
||||
}
|
||||
|
||||
return &dto.ShareListResponse{
|
||||
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
|
||||
List: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Cancel 取消分享(校验归属)。
|
||||
func (s *ClosetShareService) Cancel(ctx context.Context, userID int64, request *dto.ClosetShareCancelRequest) (*dto.CollectResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.ClosetShareCancel")
|
||||
defer span.End()
|
||||
|
||||
share, err := s.shareDao.ClosetShareGetByUUID(ctx, request.ShareUUID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, errcode.ShareNotFound
|
||||
}
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
if share.UserID != userID {
|
||||
return nil, errcode.Forbidden
|
||||
}
|
||||
if err := s.shareDao.ClosetShareUpdateStatus(ctx, request.ShareUUID, ShareStatusCancelled); err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
return &dto.CollectResponse{}, nil
|
||||
}
|
||||
|
||||
// ---- 内部辅助 ----
|
||||
|
||||
// resolveOwnedClothesIDs 校验衣物均属于用户,返回有序去重的 clothesID 列表。
|
||||
func (s *ClosetShareService) resolveOwnedClothesIDs(ctx context.Context, userID int64, uuids []string) ([]int64, *errcode.Error) {
|
||||
dedup := make([]string, 0, len(uuids))
|
||||
seen := make(map[string]struct{}, len(uuids))
|
||||
for _, u := range uuids {
|
||||
if _, ok := seen[u]; ok {
|
||||
continue
|
||||
}
|
||||
seen[u] = struct{}{}
|
||||
dedup = append(dedup, u)
|
||||
}
|
||||
|
||||
clothesList, err := s.clothesDao.ClothesGetByUUIDs(ctx, dedup)
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
byUUID := make(map[string]mysqlmodel.Clothes, len(clothesList))
|
||||
for _, c := range clothesList {
|
||||
byUUID[c.ClothesUUID] = c
|
||||
}
|
||||
|
||||
ids := make([]int64, 0, len(dedup))
|
||||
for _, u := range dedup {
|
||||
c, ok := byUUID[u]
|
||||
if !ok {
|
||||
return nil, errcode.ClothesNotFound
|
||||
}
|
||||
if c.UserID != userID {
|
||||
return nil, errcode.ClothesNoPermission
|
||||
}
|
||||
ids = append(ids, c.ID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// allUserClothesIDs 获取用户当前全部衣物 ID(分页遍历)。
|
||||
func (s *ClosetShareService) allUserClothesIDs(ctx context.Context, userID int64) ([]int64, error) {
|
||||
var ids []int64
|
||||
page, pageSize := 1, 200
|
||||
for {
|
||||
list, total, err := s.clothesDao.ClothesListByUserID(ctx, userID, "", "", page, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, c := range list {
|
||||
ids = append(ids, c.ID)
|
||||
}
|
||||
if len(list) == 0 || int64(page*pageSize) >= total {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/classification"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/matting"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/upload"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// imageProcessSem 限制异步抠图处理的并发数,避免瞬时大量上传打满下游抠图/分类服务。
|
||||
var imageProcessSem = make(chan struct{}, 10)
|
||||
|
||||
// ClothesService 衣物业务服务。
|
||||
type ClothesService struct {
|
||||
clothesDao *mysqldao.ClothesDao
|
||||
userDao *mysqldao.UserDao
|
||||
}
|
||||
|
||||
// NewClothesService 创建衣物服务。
|
||||
func NewClothesService() *ClothesService {
|
||||
return &ClothesService{
|
||||
clothesDao: mysqldao.NewClothesDao(),
|
||||
userDao: mysqldao.NewUserDao(),
|
||||
}
|
||||
}
|
||||
|
||||
// Upload 上传衣物图片:同步完成校验、扣额度、建记录后立即返回,
|
||||
// 抠图与分类在后台 goroutine 异步执行并回填状态。
|
||||
func (s *ClothesService) Upload(ctx context.Context, userID int64, fileHeader *multipart.FileHeader) (*dto.ClothesUploadResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.ClothesUpload")
|
||||
defer span.End()
|
||||
|
||||
if fileHeader == nil || fileHeader.Size == 0 {
|
||||
return nil, errcode.UploadFileEmpty
|
||||
}
|
||||
|
||||
// 1. 校验扩展名与大小。
|
||||
ext := upload.FileGetExt(fileHeader.Filename)
|
||||
if !upload.FileCheckAllowExt(ext, global.UploadSetting.AllowedExtensions) {
|
||||
return nil, errcode.UploadFileExtNotAllow
|
||||
}
|
||||
if !upload.FileCheckMaxSize(fileHeader.Size, int64(global.UploadSetting.MaxSizeMB)) {
|
||||
return nil, errcode.UploadFileTooLarge
|
||||
}
|
||||
|
||||
// 2. 读取原图字节(必须同步读取,请求返回后 multipart 临时文件会被清理)。
|
||||
src, oerr := fileHeader.Open()
|
||||
if oerr != nil {
|
||||
return nil, errcode.UploadFileSaveFailed
|
||||
}
|
||||
imageData, rerr := io.ReadAll(src)
|
||||
_ = src.Close()
|
||||
if rerr != nil {
|
||||
return nil, errcode.UploadFileSaveFailed
|
||||
}
|
||||
|
||||
// 3. 校验文件内容为真实图片(magic bytes),防止伪造扩展名上传恶意文件。
|
||||
if !upload.FileCheckImageMagic(imageData) {
|
||||
return nil, errcode.UploadFileExtNotAllow
|
||||
}
|
||||
|
||||
// 4. 扣减 COS 上传额度(权威扣减,先到期凭证先用,再扣免费额度)。
|
||||
if qerr := consumeCosQuota(ctx, userID, 1, 0, "上传衣物消耗"); qerr != nil {
|
||||
return nil, qerr
|
||||
}
|
||||
|
||||
// 5. 创建衣物记录,初始状态为排队中。
|
||||
clothesUUID := genUUID()
|
||||
clothes := &mysqlmodel.Clothes{
|
||||
ClothesUUID: clothesUUID,
|
||||
UserID: userID,
|
||||
ImageSize: fileHeader.Size / 1024,
|
||||
ImageProcessStatus: ImageProcessQueuing,
|
||||
}
|
||||
if err := s.clothesDao.ClothesCreate(ctx, clothes); err != nil {
|
||||
// 建记录失败,回退已扣额度。
|
||||
refundCosQuota(ctx, userID, 1, 0, "上传失败回退")
|
||||
return nil, errcode.ClothesCreateFailed
|
||||
}
|
||||
|
||||
// 6. 异步抠图 + 分类。
|
||||
go s.processImageAsync(ctx, userID, clothesUUID, imageData)
|
||||
|
||||
return &dto.ClothesUploadResponse{
|
||||
ClothesUUID: clothesUUID,
|
||||
ImageProcessStatus: clothes.ImageProcessStatus,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// processImageAsync 后台执行抠图、上传 COS、分类,并回填衣物记录。
|
||||
func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int64, clothesUUID string, imageData []byte) {
|
||||
// 限流:获取信号量,超出并发上限时阻塞等待。
|
||||
imageProcessSem <- struct{}{}
|
||||
defer func() { <-imageProcessSem }()
|
||||
|
||||
// 派生独立 ctx:继承父 trace 信息,但切断取消信号,避免复用已结束的请求 ctx。
|
||||
ctx, cancel := context.WithTimeout(trace.ContextWithSpanContext(context.Background(), trace.SpanContextFromContext(parentCtx)), 3*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.ClothesProcessImageAsync")
|
||||
defer span.End()
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.ErrorContext(ctx, "clothes image process panic", "clothes_uuid", clothesUUID, "recover", r)
|
||||
s.markProcessFailed(ctx, userID, clothesUUID, "内部错误")
|
||||
}
|
||||
}()
|
||||
|
||||
dao := s.clothesDao
|
||||
|
||||
// 1. 抠图。
|
||||
_ = dao.ClothesUpdateByUUID(ctx, clothesUUID, map[string]any{"image_process_status": ImageProcessMatting})
|
||||
mattingClient := matting.NewClient()
|
||||
processedData, err := retryCall(ctx, global.MattingModelSetting.MaxRetry, "matting", func() ([]byte, error) {
|
||||
return mattingClient.Matting(ctx, imageData)
|
||||
})
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "matting failed", "clothes_uuid", clothesUUID, "error", err)
|
||||
s.markProcessFailed(ctx, userID, clothesUUID, "图片抠图失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 处理结果落临时目录并上传 COS。对象键结构:用户UUID/用户ID/图片文件名。
|
||||
processedName := genUUID() + ".png"
|
||||
// 获取用户 UUID,作为 COS 对象键第一层(uuid/userid/图片)。
|
||||
user, gerr := s.userDao.UserGetByID(ctx, userID)
|
||||
if gerr != nil {
|
||||
slog.ErrorContext(ctx, "get user for cos key failed", "clothes_uuid", clothesUUID, "error", gerr)
|
||||
s.markProcessFailed(ctx, userID, clothesUUID, "获取用户信息失败")
|
||||
return
|
||||
}
|
||||
subDir := fmt.Sprintf("%s/%d", user.UserUUID, userID)
|
||||
if _, serr := upload.FileSaveTmpBytes(ctx, processedData, processedName, subDir); serr != nil {
|
||||
slog.ErrorContext(ctx, "save processed image failed", "clothes_uuid", clothesUUID, "error", serr)
|
||||
s.markProcessFailed(ctx, userID, clothesUUID, "图片保存失败")
|
||||
return
|
||||
}
|
||||
cosKey := fmt.Sprintf("%s/%s", subDir, processedName)
|
||||
if uerr := upload.FileUploadCos(ctx, cosKey); uerr != nil {
|
||||
slog.ErrorContext(ctx, "upload processed image to cos failed", "clothes_uuid", clothesUUID, "error", uerr)
|
||||
s.markProcessFailed(ctx, userID, clothesUUID, "图片上传失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 上传成功后回填前检查衣物是否仍存在:用户可能在处理期间删除衣物,
|
||||
// 若已删除则清理刚上传的对象,避免 COS 残留(额度已由删除事务回退,无需再处理)。
|
||||
if _, gerr := dao.ClothesGetByUUID(ctx, clothesUUID); isNotFound(gerr) {
|
||||
if derr := upload.FileDeleteCos(ctx, cosKey); derr != nil {
|
||||
slog.ErrorContext(ctx, "delete orphan cos image failed", "clothes_uuid", clothesUUID, "cos_key", cosKey, "error", derr)
|
||||
}
|
||||
slog.InfoContext(ctx, "clothes deleted during processing, cleaned uploaded image", "clothes_uuid", clothesUUID)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 分类。
|
||||
_ = dao.ClothesUpdateByUUID(ctx, clothesUUID, map[string]any{"image_process_status": ImageProcessClassifying})
|
||||
classifyClient := classification.NewClient()
|
||||
result, cerr := retryCall(ctx, global.ClassificationModelSetting.MaxRetry, "classify", func() (*classification.ClassifyResult, error) {
|
||||
return classifyClient.Classify(ctx, processedData)
|
||||
})
|
||||
if cerr != nil {
|
||||
slog.ErrorContext(ctx, "classify failed", "clothes_uuid", clothesUUID, "error", cerr)
|
||||
// 分类失败仍保留已抠图结果,但条件标记失败并回退以便用户手动补全。
|
||||
_ = dao.ClothesUpdateByUUID(ctx, clothesUUID, map[string]any{"processed_image": cosKey})
|
||||
s.markProcessFailed(ctx, userID, clothesUUID, "图片分类失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 回填分类结果与处理后图片,标记成功。注意主色列名为 color。
|
||||
updates := map[string]any{
|
||||
"category_main": result.CategoryMain,
|
||||
"category_sub": result.CategorySub,
|
||||
"style": result.Style,
|
||||
"name": buildDefaultClothesName(result),
|
||||
"color": result.ColorMain,
|
||||
"color_sub": result.ColorSub,
|
||||
"thickness": result.Thickness,
|
||||
"version": result.Version,
|
||||
"material": result.Material,
|
||||
"pattern": result.Pattern,
|
||||
"sleeve_type": result.SleeveType,
|
||||
"clothes_type": result.ClothesType,
|
||||
"neckline_type": result.NecklineType,
|
||||
"formality": result.Formality,
|
||||
"occasions": result.Occasions,
|
||||
"visual_impact": result.VisualImpact,
|
||||
"processed_image": cosKey,
|
||||
"image_process_status": ImageProcessSuccess,
|
||||
"fail_reason": "",
|
||||
}
|
||||
if uerr := dao.ClothesUpdateByUUID(ctx, clothesUUID, updates); uerr != nil {
|
||||
slog.ErrorContext(ctx, "update clothes after classify failed", "clothes_uuid", clothesUUID, "error", uerr)
|
||||
}
|
||||
}
|
||||
|
||||
// buildDefaultClothesName 根据分类结果生成衣服默认名称(风格+小类+大类,空字段跳过)。
|
||||
func buildDefaultClothesName(result *classification.ClassifyResult) string {
|
||||
name := ""
|
||||
if result.Style != "" {
|
||||
name += result.Style
|
||||
}
|
||||
if result.CategorySub != "" {
|
||||
name += result.CategorySub
|
||||
}
|
||||
if result.CategoryMain != "" {
|
||||
name += result.CategoryMain
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// retryCall 对 fn 进行最多 maxRetry 次额外重试(总尝试 maxRetry+1 次)。
|
||||
// 重试间隔 1s;ctx 到期则提前终止并返回 ctx.Err()。
|
||||
func retryCall[T any](ctx context.Context, maxRetry int, name string, fn func() (T, error)) (T, error) {
|
||||
var zero T
|
||||
var err error
|
||||
for attempt := 0; attempt <= maxRetry; attempt++ {
|
||||
result, e := fn()
|
||||
if e == nil {
|
||||
return result, nil
|
||||
}
|
||||
err = e
|
||||
if attempt >= maxRetry {
|
||||
break
|
||||
}
|
||||
slog.WarnContext(ctx, "model call failed, retrying", "name", name, "attempt", attempt+1, "error", e)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return zero, ctx.Err()
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
return zero, err
|
||||
}
|
||||
|
||||
// markProcessFailed 标记处理失败并回退上传额度。
|
||||
// 单事务(行锁 -> 置失败 -> 回退额度 + 写流水),与删除事务(FOR UPDATE)通过行锁互斥:
|
||||
// 两路径串行化保证「处理失败回退」与「删除回退」恰好执行一次,消除漏退/双退窗口。
|
||||
func (s *ClothesService) markProcessFailed(ctx context.Context, userID int64, clothesUUID, reason string) {
|
||||
vipExpire := getUserVIPExpire(ctx, userID)
|
||||
err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var c mysqlmodel.Clothes
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("clothes_uuid = ?", clothesUUID).
|
||||
First(&c).Error; err != nil {
|
||||
if isNotFound(err) {
|
||||
// 衣物已删除:删除事务已回退额度,跳过。
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
// 已是失败态:说明失败回退已处理过,跳过。
|
||||
if c.ImageProcessStatus == ImageProcessFailed {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 置失败。
|
||||
if err := tx.Model(&mysqlmodel.Clothes{}).
|
||||
Where("clothes_uuid = ?", clothesUUID).
|
||||
Updates(map[string]any{"image_process_status": ImageProcessFailed, "fail_reason": reason}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 同事务回退额度(优先免费额度,不足回退凭证)。
|
||||
return refundCosQuotaTx(tx, userID, vipExpire, 1, 0, "衣物处理失败回退")
|
||||
})
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "mark process failed transaction error", "clothes_uuid", clothesUUID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessStatusGetByUUID 查询衣物图片处理状态。
|
||||
func (s *ClothesService) ProcessStatusGetByUUID(ctx context.Context, userID int64, request *dto.ClothesProcessStatusGetByUUIDRequest) (*dto.ClothesProcessStatusGetByUUIDResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.ClothesProcessStatusGetByUUID")
|
||||
defer span.End()
|
||||
|
||||
clothes, cerr := s.getOwnedClothes(ctx, request.ClothesUUID, userID)
|
||||
if cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
return &dto.ClothesProcessStatusGetByUUIDResponse{
|
||||
ImageProcessStatus: clothes.ImageProcessStatus,
|
||||
FailReason: clothes.FailReason,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetByUUID 获取衣物详情。
|
||||
func (s *ClothesService) GetByUUID(ctx context.Context, userID int64, request *dto.ClothesGetByUUIDRequest) (*dto.ClothesGetByUUIDResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.ClothesGetByUUID")
|
||||
defer span.End()
|
||||
|
||||
clothes, cerr := s.getOwnedClothes(ctx, request.ClothesUUID, userID)
|
||||
if cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
response := buildClothesResponse(clothes)
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// UpdateByUUID 修改衣物属性(仅更新非空字段)。
|
||||
func (s *ClothesService) UpdateByUUID(ctx context.Context, userID int64, request *dto.ClothesUpdateByUUIDRequest) (*dto.ClothesUpdateByUUIDResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.ClothesUpdateByUUID")
|
||||
defer span.End()
|
||||
|
||||
if _, cerr := s.getOwnedClothes(ctx, request.ClothesUUID, userID); cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
|
||||
updates := map[string]any{}
|
||||
setIfNotEmpty(updates, "category_main", request.CategoryMain)
|
||||
setIfNotEmpty(updates, "category_sub", request.CategorySub)
|
||||
setIfNotEmpty(updates, "style", request.Style)
|
||||
setIfNotEmpty(updates, "color", request.ColorMain) // 主色列名为 color
|
||||
setIfNotEmpty(updates, "color_sub", request.ColorSub)
|
||||
setIfNotEmpty(updates, "thickness", request.Thickness)
|
||||
setIfNotEmpty(updates, "version", request.Version)
|
||||
setIfNotEmpty(updates, "material", request.Material)
|
||||
setIfNotEmpty(updates, "pattern", request.Pattern)
|
||||
setIfNotEmpty(updates, "sleeve_type", request.SleeveType)
|
||||
setIfNotEmpty(updates, "clothes_type", request.ClothesType)
|
||||
setIfNotEmpty(updates, "neckline_type", request.NecklineType)
|
||||
setIfNotEmpty(updates, "formality", request.Formality)
|
||||
setIfNotEmpty(updates, "occasions", request.Occasions)
|
||||
setIfNotEmpty(updates, "visual_impact", request.VisualImpact)
|
||||
setIfNotEmpty(updates, "name", request.Name)
|
||||
setIfNotEmpty(updates, "remark", request.Remark)
|
||||
|
||||
if len(updates) == 0 {
|
||||
return &dto.ClothesUpdateByUUIDResponse{}, nil
|
||||
}
|
||||
if err := s.clothesDao.ClothesUpdateByUUID(ctx, request.ClothesUUID, updates); err != nil {
|
||||
return nil, errcode.ClothesUpdateFailed
|
||||
}
|
||||
return &dto.ClothesUpdateByUUIDResponse{}, nil
|
||||
}
|
||||
|
||||
// DeleteByUUID 删除衣物(软删除)。
|
||||
func (s *ClothesService) DeleteByUUID(ctx context.Context, userID int64, request *dto.ClothesDeleteByUUIDRequest) (*dto.ClothesDeleteByUUIDResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.ClothesDeleteByUUID")
|
||||
defer span.End()
|
||||
|
||||
if _, cerr := s.getOwnedClothes(ctx, request.ClothesUUID, userID); cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
|
||||
// 事务内锁定衣物行 -> 读最新状态 -> 软删除 -> 条件回退额度 -> 写回退流水,
|
||||
// 数值变更与流水同事务强一致,并避免与异步处理失败回退并发导致漏退/双退。
|
||||
vipExpire := getUserVIPExpire(ctx, userID)
|
||||
var processedImage string
|
||||
err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var c mysqlmodel.Clothes
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("clothes_uuid = ?", request.ClothesUUID).
|
||||
First(&c).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
processedImage = c.ProcessedImage
|
||||
if err := tx.Where("clothes_uuid = ?", request.ClothesUUID).Delete(&mysqlmodel.Clothes{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 失败状态已在处理失败路径回退,不再回退,避免双重回退。
|
||||
if c.ImageProcessStatus == ImageProcessFailed {
|
||||
return nil
|
||||
}
|
||||
// 同事务回退额度(优先免费额度,不足回退凭证)。
|
||||
return refundCosQuotaTx(tx, userID, vipExpire, 1, 0, "删除衣物回退")
|
||||
})
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, errcode.ClothesNotFound
|
||||
}
|
||||
return nil, errcode.ClothesDeleteFailed
|
||||
}
|
||||
|
||||
// 事务提交成功后清理 COS 图片(尽力而为,失败仅记录日志,不影响删除结果)。
|
||||
if processedImage != "" {
|
||||
if derr := upload.FileDeleteCos(ctx, processedImage); derr != nil {
|
||||
slog.WarnContext(ctx, "delete clothes cos image failed", "clothes_uuid", request.ClothesUUID, "cos_key", processedImage, "error", derr)
|
||||
}
|
||||
}
|
||||
|
||||
return &dto.ClothesDeleteByUUIDResponse{}, nil
|
||||
}
|
||||
|
||||
// List 分页查询用户衣物。
|
||||
func (s *ClothesService) List(ctx context.Context, userID int64, request *dto.ClothesListRequest) (*dto.ClothesListResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.ClothesList")
|
||||
defer span.End()
|
||||
|
||||
page, pageSize := normalizePage(request.Page, request.PageSize)
|
||||
|
||||
list, total, err := s.clothesDao.ClothesListByUserID(ctx, userID, request.CategoryMain, request.CategorySub, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
items := make([]dto.ClothesGetByUUIDResponse, 0, len(list))
|
||||
for i := range list {
|
||||
items = append(items, buildClothesResponse(&list[i]))
|
||||
}
|
||||
|
||||
return &dto.ClothesListResponse{
|
||||
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
|
||||
List: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getOwnedClothes 查询衣物并校验归属。
|
||||
func (s *ClothesService) getOwnedClothes(ctx context.Context, clothesUUID string, userID int64) (*mysqlmodel.Clothes, *errcode.Error) {
|
||||
clothes, err := s.clothesDao.ClothesGetByUUID(ctx, clothesUUID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, errcode.ClothesNotFound
|
||||
}
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
if clothes.UserID != userID {
|
||||
return nil, errcode.ClothesNoPermission
|
||||
}
|
||||
return clothes, nil
|
||||
}
|
||||
|
||||
// buildClothesResponse 将衣物模型映射为响应,processed_image 转为可访问 URL。
|
||||
func buildClothesResponse(c *mysqlmodel.Clothes) dto.ClothesGetByUUIDResponse {
|
||||
processed := c.ProcessedImage
|
||||
if processed != "" {
|
||||
processed = upload.GetCosUrl(processed)
|
||||
}
|
||||
return dto.ClothesGetByUUIDResponse{
|
||||
ClothesUUID: c.ClothesUUID,
|
||||
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,
|
||||
Name: c.Name,
|
||||
Remark: c.Remark,
|
||||
ProcessedImage: processed,
|
||||
}
|
||||
}
|
||||
|
||||
// setIfNotEmpty 当值非空时写入 updates map。
|
||||
func setIfNotEmpty(m map[string]any, key, val string) {
|
||||
if val != "" {
|
||||
m[key] = val
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||
)
|
||||
|
||||
// CollectService 收藏业务服务。
|
||||
type CollectService struct {
|
||||
collectDao *mysqldao.CollectDao
|
||||
outfitDao *mysqldao.OutfitDao
|
||||
outfitItemDao *mysqldao.OutfitItemDao
|
||||
clothesDao *mysqldao.ClothesDao
|
||||
}
|
||||
|
||||
// NewCollectService 创建收藏服务。
|
||||
func NewCollectService() *CollectService {
|
||||
return &CollectService{
|
||||
collectDao: mysqldao.NewCollectDao(),
|
||||
outfitDao: mysqldao.NewOutfitDao(),
|
||||
outfitItemDao: mysqldao.NewOutfitItemDao(),
|
||||
clothesDao: mysqldao.NewClothesDao(),
|
||||
}
|
||||
}
|
||||
|
||||
// Collect 收藏或取消收藏社区穿搭。
|
||||
func (s *CollectService) Collect(ctx context.Context, userID int64, request *dto.CollectRequest) (*dto.CollectResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.CollectCollect")
|
||||
defer span.End()
|
||||
|
||||
outfit, err := s.outfitDao.OutfitGetByUUID(ctx, request.TargetUUID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, errcode.TargetNotFound
|
||||
}
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
// 非本人仅可收藏公开可见的穿搭。
|
||||
if outfit.UserID != userID && !isOutfitPublicVisible(outfit) {
|
||||
return nil, errcode.OutfitNoPermission
|
||||
}
|
||||
targetID := outfit.ID
|
||||
|
||||
existing, err := s.collectDao.CollectGetByTargetAndUser(ctx, CollectTypeOutfit, targetID, userID)
|
||||
notExist := false
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
notExist = true
|
||||
} else {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
}
|
||||
|
||||
switch request.Action {
|
||||
case ActionDo: // 收藏
|
||||
if cerr := s.collectDao.CollectUpsertOn(ctx, &mysqlmodel.Collect{
|
||||
CollectType: CollectTypeOutfit,
|
||||
TargetID: targetID,
|
||||
UserID: userID,
|
||||
Status: InteractStatusOn,
|
||||
}); cerr != nil {
|
||||
return nil, errcode.CollectFailed
|
||||
}
|
||||
_ = redisdao.CollectAdd(ctx, CollectTypeOutfit, targetID, userID)
|
||||
|
||||
case ActionCancel: // 取消收藏
|
||||
if !notExist && existing.Status == InteractStatusOn {
|
||||
if uerr := s.collectDao.CollectUpdateStatus(ctx, existing.ID, InteractStatusOff); uerr != nil {
|
||||
return nil, errcode.CollectFailed
|
||||
}
|
||||
_ = redisdao.CollectRemove(ctx, CollectTypeOutfit, targetID, userID)
|
||||
}
|
||||
}
|
||||
|
||||
return &dto.CollectResponse{}, nil
|
||||
}
|
||||
|
||||
// List 分页查询我收藏的穿搭。
|
||||
func (s *CollectService) List(ctx context.Context, userID int64, request *dto.CollectListRequest) (*dto.CollectListResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.CollectList")
|
||||
defer span.End()
|
||||
|
||||
page, pageSize := normalizePage(request.Page, request.PageSize)
|
||||
|
||||
pageIDs, total, err := s.collectDao.CollectListTargetIDsByUserPage(ctx, CollectTypeOutfit, userID, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
items := make([]dto.OutfitGetByUUIDResponse, 0, len(pageIDs))
|
||||
for _, oid := range pageIDs {
|
||||
outfit, gerr := s.outfitDao.OutfitGetByID(ctx, oid)
|
||||
if gerr != nil {
|
||||
// 穿搭可能已删除,跳过。
|
||||
continue
|
||||
}
|
||||
outfitItems, uerr := s.itemResponses(ctx, outfit.ID)
|
||||
if uerr != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, dto.OutfitGetByUUIDResponse{
|
||||
OutfitCreateResponse: buildOutfitCreateResponse(outfit, outfitItems),
|
||||
})
|
||||
}
|
||||
|
||||
return &dto.CollectListResponse{
|
||||
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
|
||||
List: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetByTarget 查询穿搭收藏状态与收藏数(Redis 优先,回落 MySQL)。
|
||||
func (s *CollectService) GetByTarget(ctx context.Context, userID int64, request *dto.CollectGetByTargetRequest) (*dto.CollectGetByTargetResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.CollectGetByTarget")
|
||||
defer span.End()
|
||||
|
||||
outfit, err := s.outfitDao.OutfitGetByUUID(ctx, request.TargetUUID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, errcode.TargetNotFound
|
||||
}
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
// 非本人仅可查询公开可见穿搭的收藏状态。
|
||||
if outfit.UserID != userID && !isOutfitPublicVisible(outfit) {
|
||||
return nil, errcode.OutfitNoPermission
|
||||
}
|
||||
targetID := outfit.ID
|
||||
|
||||
// 是否已收藏:Redis 优先,回落 MySQL。
|
||||
isCollected, err := redisdao.CollectIsMember(ctx, CollectTypeOutfit, targetID, userID)
|
||||
if err != nil {
|
||||
if rec, derr := s.collectDao.CollectGetByTargetAndUser(ctx, CollectTypeOutfit, targetID, userID); derr == nil {
|
||||
isCollected = rec.Status == InteractStatusOn
|
||||
}
|
||||
}
|
||||
|
||||
// 收藏总数:Redis 优先,回落 MySQL。
|
||||
count, err := redisdao.CollectCount(ctx, CollectTypeOutfit, targetID)
|
||||
if err != nil || count == 0 {
|
||||
if dbCnt, derr := s.collectDao.CollectCountByTarget(ctx, CollectTypeOutfit, targetID); derr == nil {
|
||||
count = dbCnt
|
||||
}
|
||||
}
|
||||
|
||||
return &dto.CollectGetByTargetResponse{
|
||||
UserIsCollected: isCollected,
|
||||
CollectCount: count,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// itemResponses 获取某穿搭的衣物明细(含图片 URL)。
|
||||
func (s *CollectService) itemResponses(ctx context.Context, outfitID int64) ([]dto.OutfitItemResponse, error) {
|
||||
its, err := s.outfitItemDao.OutfitItemListByOutfitID(ctx, outfitID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]int64, 0, len(its))
|
||||
for _, it := range its {
|
||||
ids = append(ids, it.ClothesID)
|
||||
}
|
||||
clothesList, err := s.clothesDao.ClothesGetByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clothesByID := make(map[int64]mysqlmodel.Clothes, len(clothesList))
|
||||
for _, c := range clothesList {
|
||||
clothesByID[c.ID] = c
|
||||
}
|
||||
resps := make([]dto.OutfitItemResponse, 0, len(its))
|
||||
for _, it := range its {
|
||||
if c, ok := clothesByID[it.ClothesID]; ok {
|
||||
resps = append(resps, buildOutfitItemResponse(&c))
|
||||
}
|
||||
}
|
||||
return resps, nil
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package service
|
||||
|
||||
// 本文件集中定义各业务域的状态/类型枚举常量,消除魔法数字。
|
||||
// 取值与 internal/mysqlmodel 各表字段注释保持一致。
|
||||
|
||||
// ===== 用户 User =====
|
||||
const (
|
||||
UserStatusNormal int8 = 1 // 正常
|
||||
UserStatusTempBan int8 = 2 // 临时禁用
|
||||
UserStatusPermBan int8 = 3 // 永久禁用
|
||||
UserStatusDeactivated int8 = 4 // 已注销
|
||||
|
||||
SexMale int8 = 1 // 男
|
||||
SexFemale int8 = 2 // 女
|
||||
|
||||
VIPTypeNone int8 = 1 // 非会员
|
||||
VIPTypeMonthly int8 = 2 // 月度会员
|
||||
VIPTypeYearly int8 = 3 // 年度会员
|
||||
VIPTypeTrial int8 = 4 // 体验会员(新用户注册赠送,天数由 UserDefaultQuota.GiftVIPDay 配置)
|
||||
)
|
||||
|
||||
// ===== 配额 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 // 广告激励(当天有效)
|
||||
|
||||
// ChangeTarget 调整额度类型
|
||||
QuotaChangeTargetAdjust int8 = 1 // 配额调整
|
||||
QuotaChangeTargetUse int8 = 2 // 使用
|
||||
|
||||
// ChangeType 变更类型
|
||||
QuotaChangeTypePurchase int8 = 1 // 购买获赠
|
||||
QuotaChangeTypeActivity int8 = 2 // 活动赠送
|
||||
QuotaChangeTypeConsume int8 = 3 // 上传消耗
|
||||
QuotaChangeTypeRefund int8 = 4 // 删除回退
|
||||
QuotaChangeTypeManual int8 = 5 // 人工调整
|
||||
QuotaChangeTypeRefundRevoke int8 = 6 // 退款回收
|
||||
)
|
||||
|
||||
// 新用户默认免费额度(内置兜底值)。
|
||||
// 优先使用 global.UserDefaultQuotaSetting 的配置值(配置为 0 时生效,表示不赠送该类免费额度);
|
||||
// 仅在该项未配置(指针为 nil)或配置为负数时回落这些常量。
|
||||
const (
|
||||
DefaultQuotaCos int64 = 20 // 免费上传额度
|
||||
DefaultQuotaAI int64 = 3 // 免费 AI 推荐额度
|
||||
DefaultQuotaOutfitShare int64 = 10 // 免费社区分享额度
|
||||
DefaultQuotaClosetShare int64 = 5 // 免费衣柜分享额度
|
||||
)
|
||||
|
||||
// 新用户注册赠送体验会员的内置兜底天数。
|
||||
// 优先使用 UserDefaultQuota.GiftVIPDay 的配置值(配置为 0 时生效,表示不赠送);
|
||||
// 仅在该项未配置(指针为 nil)或配置为负数时回落此常量。
|
||||
const DefaultVipDays int64 = 3
|
||||
|
||||
// ===== 衣物 Clothes 图片处理状态 =====
|
||||
const (
|
||||
ImageProcessMatting int8 = 1 // 抠图中
|
||||
ImageProcessClassifying int8 = 2 // 分类中
|
||||
ImageProcessQueuing int8 = 3 // 排队中
|
||||
ImageProcessSuccess int8 = 4 // 成功
|
||||
ImageProcessFailed int8 = 5 // 失败
|
||||
)
|
||||
|
||||
// ===== 穿搭 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 // 待用户保存(AI 推荐生成后未保存)
|
||||
)
|
||||
|
||||
// ===== 衣柜分享 ClosetShare =====
|
||||
const (
|
||||
ShareTypePartial int8 = 1 // 部分衣物分享
|
||||
ShareTypeSnapshot int8 = 2 // 全量快照分享
|
||||
|
||||
ShareStatusNormal int8 = 1 // 正常
|
||||
ShareStatusCancelled int8 = 2 // 用户取消
|
||||
ShareStatusExpired int8 = 3 // 已过期
|
||||
ShareStatusBanned int8 = 4 // 管理员封禁
|
||||
)
|
||||
|
||||
// ===== 审核日志 AuditLog =====
|
||||
const (
|
||||
AuditResultPass int8 = 1 // 通过
|
||||
AuditResultReject int8 = 2 // 驳回
|
||||
)
|
||||
|
||||
// ===== 收藏 / 点赞 =====
|
||||
const (
|
||||
CollectTypeOutfit int8 = 1 // 社区穿搭
|
||||
LikeTypeOutfit int8 = 1 // 社区穿搭
|
||||
|
||||
InteractStatusOn int8 = 1 // 已收藏 / 已点赞
|
||||
InteractStatusOff int8 = 0 // 已取消
|
||||
|
||||
ActionDo int8 = 1 // 收藏 / 点赞
|
||||
ActionCancel int8 = 2 // 取消
|
||||
)
|
||||
|
||||
// ===== 通知 Notification =====
|
||||
const (
|
||||
NotificationTypeLike int8 = 1 // 点赞通知
|
||||
NotificationTypeOrder int8 = 2 // 订单通知
|
||||
NotificationTypeSystem int8 = 3 // 系统通知
|
||||
|
||||
NotificationUnread int8 = 1 // 未读
|
||||
NotificationRead int8 = 2 // 已读
|
||||
)
|
||||
|
||||
// ===== 商品 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 // 退款失败
|
||||
)
|
||||
|
||||
// ===== VIP 流水 =====
|
||||
const (
|
||||
VIPChangeTypeOpen int8 = 1 // 开通
|
||||
VIPChangeTypeRenew int8 = 2 // 续费
|
||||
VIPChangeTypeActivity int8 = 3 // 活动赠送
|
||||
VIPChangeTypeExpire int8 = 4 // 到期失效
|
||||
VIPChangeTypeManual int8 = 5 // 人工调整
|
||||
VIPChangeTypeRefundRevoke int8 = 6 // 退款回收
|
||||
|
||||
VIPSourcePersonalPay int8 = 1 // 个人支付
|
||||
VIPSourceActivity int8 = 2 // 活动赠送
|
||||
VIPSourceSystem int8 = 3 // 系统
|
||||
VIPSourceManual int8 = 4 // 人工调整
|
||||
)
|
||||
|
||||
// ===== AI 流水状态 =====
|
||||
const (
|
||||
AIFlowStatusSuccess int8 = 1 // 成功
|
||||
AIFlowStatusFailed int8 = 2 // 失败
|
||||
)
|
||||
|
||||
// 订单支付有效期(秒)。
|
||||
const orderPayTTLSeconds int64 = 15 * 60
|
||||
|
||||
// ===== 权益发放补偿 BenefitGrantTask =====
|
||||
const (
|
||||
BenefitTypeVIP int8 = 1 // 会员
|
||||
BenefitTypeCos int8 = 2 // 上传额度
|
||||
BenefitTypeAI int8 = 3 // AI 推荐额度
|
||||
BenefitTypeClosetShare int8 = 4 // 衣柜分享额度
|
||||
BenefitTypeOutfitShare int8 = 5 // 搭配分享额度
|
||||
|
||||
BenefitTaskPending int8 = 0 // 待发放
|
||||
BenefitTaskSuccess int8 = 1 // 已发放
|
||||
BenefitTaskFailed int8 = 2 // 发放失败(重试超限)
|
||||
BenefitTaskProcessing int8 = 3 // 发放中(认领后,防并发重复发放)
|
||||
|
||||
benefitMaxRetry int = 3 // 默认最大重试次数
|
||||
|
||||
// benefitRetryCooldownSeconds 失败任务被回调重新触发的冷却期(秒):
|
||||
// 冷却期内不重置,防止微信回调重试风暴无限重置任务绕过重试上限。
|
||||
benefitRetryCooldownSeconds int64 = 300
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||
)
|
||||
|
||||
// GoodsService 商品业务服务。
|
||||
type GoodsService struct {
|
||||
goodsDao *mysqldao.GoodsDao
|
||||
}
|
||||
|
||||
// NewGoodsService 创建商品服务。
|
||||
func NewGoodsService() *GoodsService {
|
||||
return &GoodsService{
|
||||
goodsDao: mysqldao.NewGoodsDao(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetByCode 按商品编码查询商品(下架返回错误)。
|
||||
func (s *GoodsService) GetByCode(ctx context.Context, request *dto.GoodsGetByCodeRequest) (*dto.GoodsGetByCodeResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.GoodsGetByCode")
|
||||
defer span.End()
|
||||
|
||||
goods, err := s.goodsDao.GoodsGetByCode(ctx, request.GoodsCode)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, errcode.GoodsNotFound
|
||||
}
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
if goods.Status != GoodsStatusOnShelf {
|
||||
return nil, errcode.GoodsOffShelf
|
||||
}
|
||||
response := buildGoodsResponse(goods)
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// List 分页查询上架商品(按类型过滤)。
|
||||
func (s *GoodsService) List(ctx context.Context, request *dto.GoodsListRequest) (*dto.GoodsListResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.GoodsList")
|
||||
defer span.End()
|
||||
|
||||
page, pageSize := normalizePage(request.Page, request.PageSize)
|
||||
|
||||
list, total, err := s.goodsDao.GoodsList(ctx, request.GoodsType, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
items := make([]dto.GoodsGetByCodeResponse, 0, len(list))
|
||||
for i := range list {
|
||||
items = append(items, buildGoodsResponse(&list[i]))
|
||||
}
|
||||
|
||||
return &dto.GoodsListResponse{
|
||||
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
|
||||
List: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildGoodsResponse 将商品模型映射为响应。注意 dto.UploadQuota 对应 model.CosQuota。
|
||||
func buildGoodsResponse(g *mysqlmodel.Goods) dto.GoodsGetByCodeResponse {
|
||||
return dto.GoodsGetByCodeResponse{
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/tokenhub"
|
||||
)
|
||||
|
||||
// HoroscopeService 今日运势业务服务。
|
||||
type HoroscopeService struct {
|
||||
horoscopeDao *mysqldao.HoroscopeDao
|
||||
userDao *mysqldao.UserDao
|
||||
}
|
||||
|
||||
// NewHoroscopeService 创建运势服务。
|
||||
func NewHoroscopeService() *HoroscopeService {
|
||||
return &HoroscopeService{
|
||||
horoscopeDao: mysqldao.NewHoroscopeDao(),
|
||||
userDao: mysqldao.NewUserDao(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetToday 获取当前用户今日运势:由生日推算星座,Redis 优先、未命中回落 MySQL 并回填缓存。
|
||||
func (s *HoroscopeService) GetToday(ctx context.Context, userID int64) (*dto.HoroscopeResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.HoroscopeGetToday")
|
||||
defer span.End()
|
||||
|
||||
user, err := s.userDao.UserGetByID(ctx, userID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, errcode.UserNotFound
|
||||
}
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
if user.BirthDay == 0 {
|
||||
return nil, errcode.InvalidParams.WithDetails("请先完善生日信息")
|
||||
}
|
||||
|
||||
zodiac := zodiacByUnix(user.BirthDay)
|
||||
statDate := todayStatDate()
|
||||
|
||||
// 1. Redis 优先。
|
||||
if data, cerr := redisdao.HoroscopeGet(ctx, zodiac, statDate); cerr == nil && len(data) > 0 {
|
||||
var response dto.HoroscopeResponse
|
||||
if json.Unmarshal(data, &response) == nil {
|
||||
return &response, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 回落 MySQL。
|
||||
h, derr := s.horoscopeDao.HoroscopeGetByZodiacAndDate(ctx, zodiac, statDate)
|
||||
if derr != nil {
|
||||
if isNotFound(derr) {
|
||||
// 今日运势尚未生成,异步触发兜底生成,本次返回未生成。
|
||||
tokenhub.EnsureTodayFortunes()
|
||||
return nil, errcode.HoroscopeNotFound
|
||||
}
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
response := buildHoroscopeResponse(h)
|
||||
|
||||
// 回填缓存,TTL 至次日凌晨。
|
||||
if data, merr := json.Marshal(response); merr == nil {
|
||||
_ = redisdao.HoroscopeSet(ctx, zodiac, statDate, data, ttlUntilNextMidnight())
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// buildHoroscopeResponse 将运势模型映射为响应。
|
||||
func buildHoroscopeResponse(h *mysqlmodel.Horoscope) dto.HoroscopeResponse {
|
||||
return dto.HoroscopeResponse{
|
||||
StatDate: h.StatDate,
|
||||
Zodiac: h.Zodiac,
|
||||
OverallScore: h.OverallScore,
|
||||
LoveScore: h.LoveScore,
|
||||
CareerScore: h.CareerScore,
|
||||
WealthScore: h.WealthScore,
|
||||
DailyText: h.DailyText,
|
||||
LuckyColor: h.LuckyColor,
|
||||
LuckyNumber: h.LuckyNumber,
|
||||
}
|
||||
}
|
||||
|
||||
// ttlUntilNextMidnight 计算到次日 0 点的时长,用于运势缓存过期。
|
||||
func ttlUntilNextMidnight() time.Duration {
|
||||
now := time.Now()
|
||||
next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
|
||||
return next.Sub(now)
|
||||
}
|
||||
|
||||
// zodiacByUnix 根据生日(秒级 unix 时间戳)计算星座(中文名,与运势数据对齐)。
|
||||
func zodiacByUnix(birthUnix int64) string {
|
||||
t := time.Unix(birthUnix, 0)
|
||||
return zodiacByMonthDay(int(t.Month()), t.Day())
|
||||
}
|
||||
|
||||
// zodiacByMonthDay 根据月日返回星座中文名。
|
||||
func zodiacByMonthDay(month, day int) string {
|
||||
switch month {
|
||||
case 1:
|
||||
if day <= 19 {
|
||||
return "摩羯座"
|
||||
}
|
||||
return "水瓶座"
|
||||
case 2:
|
||||
if day <= 18 {
|
||||
return "水瓶座"
|
||||
}
|
||||
return "双鱼座"
|
||||
case 3:
|
||||
if day <= 20 {
|
||||
return "双鱼座"
|
||||
}
|
||||
return "白羊座"
|
||||
case 4:
|
||||
if day <= 19 {
|
||||
return "白羊座"
|
||||
}
|
||||
return "金牛座"
|
||||
case 5:
|
||||
if day <= 20 {
|
||||
return "金牛座"
|
||||
}
|
||||
return "双子座"
|
||||
case 6:
|
||||
if day <= 21 {
|
||||
return "双子座"
|
||||
}
|
||||
return "巨蟹座"
|
||||
case 7:
|
||||
if day <= 22 {
|
||||
return "巨蟹座"
|
||||
}
|
||||
return "狮子座"
|
||||
case 8:
|
||||
if day <= 22 {
|
||||
return "狮子座"
|
||||
}
|
||||
return "处女座"
|
||||
case 9:
|
||||
if day <= 22 {
|
||||
return "处女座"
|
||||
}
|
||||
return "天秤座"
|
||||
case 10:
|
||||
if day <= 23 {
|
||||
return "天秤座"
|
||||
}
|
||||
return "天蝎座"
|
||||
case 11:
|
||||
if day <= 22 {
|
||||
return "天蝎座"
|
||||
}
|
||||
return "射手座"
|
||||
case 12:
|
||||
if day <= 21 {
|
||||
return "射手座"
|
||||
}
|
||||
return "摩羯座"
|
||||
}
|
||||
return "摩羯座"
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||
)
|
||||
|
||||
// LikeService 点赞业务服务。
|
||||
type LikeService struct {
|
||||
likeDao *mysqldao.LikeDao
|
||||
outfitDao *mysqldao.OutfitDao
|
||||
}
|
||||
|
||||
// NewLikeService 创建点赞服务。
|
||||
func NewLikeService() *LikeService {
|
||||
return &LikeService{
|
||||
likeDao: mysqldao.NewLikeDao(),
|
||||
outfitDao: mysqldao.NewOutfitDao(),
|
||||
}
|
||||
}
|
||||
|
||||
// LikeCreate 点赞或取消点赞社区穿搭。
|
||||
func (s *LikeService) LikeCreate(ctx context.Context, userID int64, request *dto.LikeCreateRequest) (*dto.LikeResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.LikeLikeCreate")
|
||||
defer span.End()
|
||||
|
||||
outfit, cerr := s.getPublicOutfit(ctx, userID, request.TargetUUID)
|
||||
if cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
targetID := outfit.ID
|
||||
|
||||
existing, err := s.likeDao.LikeGetByTargetAndUser(ctx, LikeTypeOutfit, targetID, userID)
|
||||
notExist := false
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
notExist = true
|
||||
} else {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
}
|
||||
|
||||
switch request.Action {
|
||||
case ActionDo: // 点赞
|
||||
if cerr := s.likeDao.LikeUpsertOn(ctx, &mysqlmodel.Like{
|
||||
LikeType: LikeTypeOutfit,
|
||||
TargetID: targetID,
|
||||
UserID: userID,
|
||||
Status: InteractStatusOn,
|
||||
}); cerr != nil {
|
||||
return nil, errcode.LikeFailed
|
||||
}
|
||||
_ = redisdao.LikeAdd(ctx, LikeTypeOutfit, targetID, userID)
|
||||
|
||||
// 仅在「从取消/无记录变为点赞」时发通知,避免并发/重复点赞重复通知。
|
||||
wasLiked := !notExist && existing.Status == InteractStatusOn
|
||||
if !wasLiked && outfit.UserID != userID {
|
||||
sendNotification(ctx, outfit.UserID, userID, NotificationTypeLike, "收到新的点赞", "你的穿搭「"+outfit.OutfitName+"」收到了一个赞")
|
||||
}
|
||||
|
||||
case ActionCancel: // 取消点赞
|
||||
if !notExist && existing.Status == InteractStatusOn {
|
||||
if uerr := s.likeDao.LikeUpdateStatus(ctx, existing.ID, InteractStatusOff); uerr != nil {
|
||||
return nil, errcode.LikeFailed
|
||||
}
|
||||
_ = redisdao.LikeRemove(ctx, LikeTypeOutfit, targetID, userID)
|
||||
}
|
||||
}
|
||||
|
||||
return &dto.LikeResponse{}, nil
|
||||
}
|
||||
|
||||
// GetByTargetID 查询穿搭点赞状态与点赞数(Redis 优先)。
|
||||
func (s *LikeService) GetByTargetID(ctx context.Context, userID int64, request *dto.LikeGetByTargetIDRequest) (*dto.LikeGetByTargetIDResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.LikeGetByTargetID")
|
||||
defer span.End()
|
||||
|
||||
outfit, cerr := s.getPublicOutfit(ctx, userID, request.TargetUUID)
|
||||
if cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
targetID := outfit.ID
|
||||
|
||||
// 是否已点赞:Redis 优先,回落 MySQL。
|
||||
isLiked, err := redisdao.LikeIsMember(ctx, LikeTypeOutfit, targetID, userID)
|
||||
if err != nil {
|
||||
if rec, derr := s.likeDao.LikeGetByTargetAndUser(ctx, LikeTypeOutfit, targetID, userID); derr == nil {
|
||||
isLiked = rec.Status == InteractStatusOn
|
||||
}
|
||||
}
|
||||
|
||||
// 点赞总数:Redis 优先,回落 MySQL。
|
||||
count, err := redisdao.LikeCount(ctx, LikeTypeOutfit, targetID)
|
||||
if err != nil || count == 0 {
|
||||
if dbCnt, derr := s.likeDao.LikeCountByTarget(ctx, LikeTypeOutfit, targetID); derr == nil {
|
||||
count = dbCnt
|
||||
}
|
||||
}
|
||||
|
||||
return &dto.LikeGetByTargetIDResponse{
|
||||
UserIsLiked: isLiked,
|
||||
LikeCount: count,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getPublicOutfit 按 UUID 查询社区穿搭(须存在),非本人仅可操作公开可见的穿搭。
|
||||
func (s *LikeService) getPublicOutfit(ctx context.Context, userID int64, outfitUUID string) (*mysqlmodel.Outfit, *errcode.Error) {
|
||||
outfit, err := s.outfitDao.OutfitGetByUUID(ctx, outfitUUID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, errcode.TargetNotFound
|
||||
}
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
if outfit.UserID != userID && !isOutfitPublicVisible(outfit) {
|
||||
return nil, errcode.OutfitNoPermission
|
||||
}
|
||||
return outfit, nil
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||
)
|
||||
|
||||
// NotificationService 通知业务服务。
|
||||
type NotificationService struct {
|
||||
notificationDao *mysqldao.NotificationDao
|
||||
}
|
||||
|
||||
// NewNotificationService 创建通知服务。
|
||||
func NewNotificationService() *NotificationService {
|
||||
return &NotificationService{
|
||||
notificationDao: mysqldao.NewNotificationDao(),
|
||||
}
|
||||
}
|
||||
|
||||
// List 分页查询当前用户通知。
|
||||
func (s *NotificationService) List(ctx context.Context, userID int64, request *dto.NotificationListRequest) (*dto.NotificationListResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.NotificationList")
|
||||
defer span.End()
|
||||
|
||||
page, pageSize := normalizePage(request.Page, request.PageSize)
|
||||
|
||||
list, total, err := s.notificationDao.NotificationListByUserID(ctx, userID, request.IsRead, request.MsgType, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
items := make([]dto.NotificationGetByUUIDResponse, 0, len(list))
|
||||
for i := range list {
|
||||
items = append(items, buildNotificationResponse(&list[i]))
|
||||
}
|
||||
|
||||
return &dto.NotificationListResponse{
|
||||
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
|
||||
List: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetByUUID 获取通知详情(校验归属)。
|
||||
func (s *NotificationService) GetByUUID(ctx context.Context, userID int64, request *dto.NotificationGetByUUIDRequest) (*dto.NotificationGetByUUIDResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.NotificationGetByUUID")
|
||||
defer span.End()
|
||||
|
||||
n, err := s.notificationDao.NotificationGetByUUID(ctx, request.NotificationUUID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, errcode.NotificationNotFound
|
||||
}
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
if n.UserID != userID {
|
||||
return nil, errcode.Forbidden
|
||||
}
|
||||
response := buildNotificationResponse(n)
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// Read 标记通知已读(校验归属),并同步扣减未读数。
|
||||
func (s *NotificationService) Read(ctx context.Context, userID int64, request *dto.NotificationReadRequest) (*dto.NotificationUnreadResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.NotificationRead")
|
||||
defer span.End()
|
||||
|
||||
n, err := s.notificationDao.NotificationGetByUUID(ctx, request.NotificationUUID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, errcode.NotificationNotFound
|
||||
}
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
if n.UserID != userID {
|
||||
return nil, errcode.Forbidden
|
||||
}
|
||||
|
||||
// 已读则直接返回当前未读数。
|
||||
if n.IsRead == NotificationRead {
|
||||
cnt, _ := redisdao.NotificationUnreadGet(ctx, userID)
|
||||
return &dto.NotificationUnreadResponse{UnreadCount: cnt}, nil
|
||||
}
|
||||
|
||||
// 条件标记已读(仅未读 -> 已读),防并发重复扣减未读数。
|
||||
ok, err := s.notificationDao.NotificationUpdateReadByUUIDIfUnread(ctx, request.NotificationUUID)
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
if !ok {
|
||||
// 已被并发标记已读,直接返回当前未读数。
|
||||
cnt, _ := redisdao.NotificationUnreadGet(ctx, userID)
|
||||
return &dto.NotificationUnreadResponse{UnreadCount: cnt}, nil
|
||||
}
|
||||
cnt, _ := redisdao.NotificationUnreadDecr(ctx, userID)
|
||||
|
||||
return &dto.NotificationUnreadResponse{UnreadCount: cnt}, nil
|
||||
}
|
||||
|
||||
// Unread 获取当前用户未读数,Redis 优先、回落 MySQL。
|
||||
func (s *NotificationService) Unread(ctx context.Context, userID int64) (*dto.NotificationUnreadResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.NotificationUnread")
|
||||
defer span.End()
|
||||
|
||||
cnt, err := redisdao.NotificationUnreadGet(ctx, userID)
|
||||
if err == nil && cnt > 0 {
|
||||
return &dto.NotificationUnreadResponse{UnreadCount: cnt}, nil
|
||||
}
|
||||
|
||||
// Redis 为 0 或异常,回落 MySQL 统计。
|
||||
dbCnt, derr := s.notificationDao.NotificationCountUnread(ctx, userID)
|
||||
if derr != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
// 回填 Redis 未读数缓存,避免 key 过期导致后续标记已读时扣减被吞。
|
||||
_ = redisdao.NotificationUnreadSet(ctx, userID, dbCnt)
|
||||
return &dto.NotificationUnreadResponse{UnreadCount: dbCnt}, nil
|
||||
}
|
||||
|
||||
// buildNotificationResponse 将通知模型映射为响应。
|
||||
func buildNotificationResponse(n *mysqlmodel.UserNotification) dto.NotificationGetByUUIDResponse {
|
||||
return dto.NotificationGetByUUIDResponse{
|
||||
NotificationUUID: n.NotificationUUID,
|
||||
MsgType: n.MsgType,
|
||||
Title: n.Title,
|
||||
Content: n.Content,
|
||||
IsRead: n.IsRead,
|
||||
CreatedAt: n.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// sendNotification 发送一条通知并同步递增接收方未读数(供 like/order 等复用,best-effort)。
|
||||
func sendNotification(ctx context.Context, recipientUserID, senderID int64, msgType int8, title, content string) {
|
||||
dao := mysqldao.NewNotificationDao()
|
||||
n := &mysqlmodel.UserNotification{
|
||||
NotificationUUID: genUUID(),
|
||||
UserID: recipientUserID,
|
||||
MsgType: msgType,
|
||||
SenderID: senderID,
|
||||
Title: title,
|
||||
Content: content,
|
||||
IsRead: NotificationUnread,
|
||||
}
|
||||
if err := dao.NotificationCreate(ctx, n); err != nil {
|
||||
slog.WarnContext(ctx, "create notification failed",
|
||||
"user_id", recipientUserID, "msg_type", msgType, "error", err)
|
||||
return
|
||||
}
|
||||
if _, err := redisdao.NotificationUnreadIncr(ctx, recipientUserID); err != nil {
|
||||
slog.ErrorContext(ctx, "notification unread incr failed", "user_id", recipientUserID, "error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/wechat"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OrderService 订单与退款业务服务。
|
||||
type OrderService struct {
|
||||
orderDao *mysqldao.OrderDao
|
||||
goodsDao *mysqldao.GoodsDao
|
||||
userDao *mysqldao.UserDao
|
||||
refundDao *mysqldao.RefundFlowLogDao
|
||||
vipDao *mysqldao.VIPFlowLogDao
|
||||
}
|
||||
|
||||
// NewOrderService 创建订单服务。
|
||||
func NewOrderService() *OrderService {
|
||||
return &OrderService{
|
||||
orderDao: mysqldao.NewOrderDao(),
|
||||
goodsDao: mysqldao.NewGoodsDao(),
|
||||
userDao: mysqldao.NewUserDao(),
|
||||
refundDao: mysqldao.NewRefundFlowLogDao(),
|
||||
vipDao: mysqldao.NewVIPFlowLogDao(),
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建订单并发起微信 JSAPI 下单。
|
||||
func (s *OrderService) Create(ctx context.Context, userID int64, request *dto.OrderCreateRequest) (*dto.OrderCreateResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.OrderCreate")
|
||||
defer span.End()
|
||||
|
||||
goods, err := s.goodsDao.GoodsGetByCode(ctx, request.GoodsCode)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, errcode.GoodsNotFound
|
||||
}
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
if goods.Status != GoodsStatusOnShelf {
|
||||
return nil, errcode.GoodsOffShelf
|
||||
}
|
||||
|
||||
// 支付用户 openid。
|
||||
user, uerr := s.userDao.UserGetByID(ctx, userID)
|
||||
if uerr != nil {
|
||||
if isNotFound(uerr) {
|
||||
return nil, errcode.UserNotFound
|
||||
}
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
if user.WxOpenID == "" {
|
||||
return nil, errcode.OrderCreateFailed.WithDetails("缺少微信 openid")
|
||||
}
|
||||
|
||||
orderNo := genOrderNo()
|
||||
expireTime := nowUnix() + orderPayTTLSeconds
|
||||
payAmount := goods.DiscountPrice
|
||||
|
||||
order := &mysqlmodel.Order{
|
||||
OrderNo: orderNo,
|
||||
UserID: userID,
|
||||
GoodsID: goods.ID,
|
||||
GoodsName: goods.GoodsName,
|
||||
UnitPrice: goods.DiscountPrice,
|
||||
TotalAmount: payAmount,
|
||||
PayAmount: payAmount,
|
||||
PayChannel: PayChannelWechat,
|
||||
OrderStatus: OrderStatusPending,
|
||||
MemberDays: goods.MemberDays,
|
||||
CosQuota: goods.CosQuota,
|
||||
AIRecommendQuota: goods.AIRecommendQuota,
|
||||
ClosetShareQuota: goods.ClosetShareQuota,
|
||||
OutfitShareQuota: goods.OutfitShareQuota,
|
||||
ExpireTime: expireTime,
|
||||
}
|
||||
// 先落库再微信下单:微信下单失败时本地订单保留为待支付并超时自动过期,
|
||||
// 避免「先微信下单、落库失败」产生无本地记录的孤儿预支付单。
|
||||
if cerr := s.orderDao.OrderCreate(ctx, order); cerr != nil {
|
||||
return nil, errcode.OrderCreateFailed
|
||||
}
|
||||
|
||||
payParams, perr := wechat.JsapiPrepay(ctx, user.WxOpenID, orderNo, goods.GoodsName, payAmount)
|
||||
if perr != nil {
|
||||
return nil, errcode.OrderCreateFailed
|
||||
}
|
||||
|
||||
return &dto.OrderCreateResponse{
|
||||
OrderNo: orderNo,
|
||||
PayAmount: payAmount,
|
||||
ExpireTime: expireTime,
|
||||
PayParams: &dto.WechatPayParams{
|
||||
TimeStamp: payParams.TimeStamp,
|
||||
NonceStr: payParams.NonceStr,
|
||||
Package: payParams.Package,
|
||||
SignType: payParams.SignType,
|
||||
PaySign: payParams.PaySign,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PayNotify 处理微信支付结果回调(供回调 handler 调用):验签解密 + 幂等 + 发放权益。
|
||||
func (s *OrderService) PayNotify(ctx context.Context, request *http.Request) *errcode.Error {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.OrderPayNotify")
|
||||
defer span.End()
|
||||
|
||||
data, err := wechat.VerifyAndDecryptPayNotify(ctx, request)
|
||||
if err != nil {
|
||||
return errcode.RPCError
|
||||
}
|
||||
if data.TradeState != "SUCCESS" {
|
||||
return nil // 非支付成功,忽略。
|
||||
}
|
||||
|
||||
// 幂等锁。
|
||||
lockKey := fmtLockKey(redismodel.KeyLockPayCallback, data.OutTradeNo)
|
||||
lockVal := genUUID()
|
||||
if ok, _ := redisdao.TryLock(ctx, lockKey, lockVal, 30*time.Second); !ok {
|
||||
return nil // 并发回调,交由持锁者处理。
|
||||
}
|
||||
defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }()
|
||||
|
||||
// 先查订单并校验回调金额,防止金额被篡改导致错误发放。
|
||||
order, gerr := s.orderDao.OrderGetByOrderNo(ctx, data.OutTradeNo)
|
||||
if gerr != nil {
|
||||
if isNotFound(gerr) {
|
||||
slog.ErrorContext(ctx, "pay notify: order not found", "out_trade_no", data.OutTradeNo)
|
||||
return nil
|
||||
}
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
if data.Amount.Total != order.PayAmount {
|
||||
slog.ErrorContext(ctx, "pay notify: amount mismatch",
|
||||
"out_trade_no", data.OutTradeNo,
|
||||
"notify_amount", data.Amount.Total,
|
||||
"order_amount", order.PayAmount,
|
||||
)
|
||||
return nil // 金额不一致,不流转、不发放权益,人工介入。
|
||||
}
|
||||
|
||||
// 条件流转:待支付 -> 已支付,防重复回调。
|
||||
updated, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, data.OutTradeNo, OrderStatusPending, OrderStatusPaid)
|
||||
if uerr != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
if updated {
|
||||
s.finalizePaidOrder(ctx, order, data)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 流转失败:重新读取订单,处理「本地已过期/已取消但用户实际已付款」的竞态。
|
||||
current, gerr := s.orderDao.OrderGetByOrderNo(ctx, data.OutTradeNo)
|
||||
if gerr != nil {
|
||||
if isNotFound(gerr) {
|
||||
return nil
|
||||
}
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
|
||||
switch current.OrderStatus {
|
||||
case OrderStatusPaid:
|
||||
// 已被并发回调处理。兜底确保支付信息与权益发放已完成(均幂等),
|
||||
// 覆盖「订单已流转为 Paid 但权益发放前崩溃导致权益丢失」的场景。
|
||||
_ = s.orderDao.OrderUpdateByOrderNo(ctx, current.OrderNo, map[string]any{
|
||||
"pay_time": nowUnix(),
|
||||
"transaction_id": data.TransactionID,
|
||||
"callback_raw": data.Raw,
|
||||
})
|
||||
s.grantOrderBenefits(ctx, current)
|
||||
return nil
|
||||
case OrderStatusExpired:
|
||||
// 订单被系统过期但用户已付款:过期 -> 已支付并补发货,避免用户付款却不到账。
|
||||
ok, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, data.OutTradeNo, OrderStatusExpired, OrderStatusPaid)
|
||||
if uerr != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
if !ok {
|
||||
return nil // 已被其它回调处理。
|
||||
}
|
||||
s.finalizePaidOrder(ctx, current, data)
|
||||
return nil
|
||||
case OrderStatusCancelled:
|
||||
// 用户主动取消却仍支付(关闭订单与支付存在竞态窗口):自动发起退款,避免用户付款却拿不到权益。
|
||||
s.autoRefundCancelledOrder(ctx, current, data)
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel 取消待支付订单(校验归属)。
|
||||
func (s *OrderService) Cancel(ctx context.Context, userID int64, request *dto.OrderCancelRequest) (*dto.OrderResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.OrderCancel")
|
||||
defer span.End()
|
||||
|
||||
order, cerr := s.getOwnedOrder(ctx, request.OrderNo, userID)
|
||||
if cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
// 虚拟支付订单不可取消:虚拟支付无服务端关单 API,本地取消后微信侧单仍有效,
|
||||
// 用户继续支付会造成「已付款但权益不发放」。禁止取消从源头杜绝该资损场景。
|
||||
if order.PayChannel == PayChannelVirtual {
|
||||
return nil, errcode.VirtualOrderCancelNotAllowed
|
||||
}
|
||||
if order.OrderStatus == OrderStatusPaid {
|
||||
return nil, errcode.OrderAlreadyPaid
|
||||
}
|
||||
if order.OrderStatus != OrderStatusPending {
|
||||
return nil, errcode.OrderStatusInvalid
|
||||
}
|
||||
|
||||
ok, err := s.orderDao.OrderUpdateStatusByOrderNo(ctx, request.OrderNo, OrderStatusPending, OrderStatusCancelled)
|
||||
if err != nil {
|
||||
return nil, errcode.OrderCancelFailed
|
||||
}
|
||||
if !ok {
|
||||
return nil, errcode.OrderStatusInvalid
|
||||
}
|
||||
|
||||
// 本地取消成功后,尽力关闭微信侧订单,防止用户继续支付(失败仅告警,不阻断取消)。
|
||||
if cerr := wechat.CloseOrder(ctx, request.OrderNo); cerr != nil {
|
||||
slog.WarnContext(ctx, "close wechat order failed", "order_no", request.OrderNo, "error", cerr)
|
||||
}
|
||||
|
||||
order.OrderStatus = OrderStatusCancelled
|
||||
response := buildOrderResponse(order)
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// List 分页查询我的订单。
|
||||
func (s *OrderService) List(ctx context.Context, userID int64, request *dto.OrderListRequest) (*dto.OrderListResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.OrderList")
|
||||
defer span.End()
|
||||
|
||||
page, pageSize := normalizePage(request.Page, request.PageSize)
|
||||
|
||||
list, total, err := s.orderDao.OrderListByUserID(ctx, userID, request.Status, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
items := make([]dto.OrderResponse, 0, len(list))
|
||||
for i := range list {
|
||||
items = append(items, buildOrderResponse(&list[i]))
|
||||
}
|
||||
|
||||
return &dto.OrderListResponse{
|
||||
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
|
||||
List: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RefundApply 申请退款(仅已支付订单)。
|
||||
func (s *OrderService) RefundApply(ctx context.Context, userID int64, request *dto.RefundApplyRequest) (*dto.RefundResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.OrderRefundApply")
|
||||
defer span.End()
|
||||
|
||||
order, cerr := s.getOwnedOrder(ctx, request.OrderNo, userID)
|
||||
if cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
// 虚拟支付 iOS 订单不可主动退款:用户走 App Store 申请,Apple 通过退款问询推送回收。
|
||||
if order.PayChannel == PayChannelVirtual && order.XPayPlatform == XPayPlatformIOS {
|
||||
return nil, errcode.IOSRefundViaAppStore
|
||||
}
|
||||
if order.OrderStatus != OrderStatusPaid {
|
||||
return nil, errcode.RefundNotAllowed
|
||||
}
|
||||
|
||||
// 已有退款流水则按状态处理:非失败不允许重复申请;失败则复用旧记录重新申请。
|
||||
existing, gerr := s.refundDao.RefundFlowLogGetByOrderNo(ctx, request.OrderNo)
|
||||
if gerr != nil && !isNotFound(gerr) {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
if existing != nil && existing.Status != RefundStatusFailed {
|
||||
return nil, errcode.RefundNotAllowed
|
||||
}
|
||||
|
||||
refundNo := genRefundNo()
|
||||
applyTime := nowUnix()
|
||||
|
||||
if existing != nil {
|
||||
// 失败重试:复用旧记录,更新退款单号与状态。
|
||||
if err := s.refundDao.RefundFlowLogUpdateStatus(ctx, existing.RefundNo, map[string]any{
|
||||
"refund_no": refundNo,
|
||||
"refund_amount": order.PayAmount,
|
||||
"refund_reason": request.Reason,
|
||||
"status": RefundStatusPending,
|
||||
"apply_time": applyTime,
|
||||
"fail_reason": "",
|
||||
}); err != nil {
|
||||
return nil, errcode.RefundApplyFailed
|
||||
}
|
||||
} else {
|
||||
// 新建退款流水(order_no 唯一索引兜底并发,冲突即已有申请)。
|
||||
flow := &mysqlmodel.RefundFlowLog{
|
||||
OrderID: order.ID,
|
||||
OrderNo: order.OrderNo,
|
||||
UserID: userID,
|
||||
RefundNo: refundNo,
|
||||
RefundAmount: order.PayAmount,
|
||||
RefundReason: request.Reason,
|
||||
Status: RefundStatusPending,
|
||||
ApplyTime: applyTime,
|
||||
}
|
||||
if err := s.refundDao.RefundFlowLogCreate(ctx, flow); err != nil {
|
||||
if isDuplicateKey(err) {
|
||||
return nil, errcode.RefundNotAllowed
|
||||
}
|
||||
return nil, errcode.RefundApplyFailed
|
||||
}
|
||||
}
|
||||
|
||||
// 人工审核:不再自动调用微信退款,仅创建待审核退款申请,避免自动退款造成权益未回退的资损。
|
||||
// 运营审核通过后在微信后台手动发起退款,微信退款回调 RefundNotify 会更新订单与流水状态。
|
||||
return &dto.RefundResponse{
|
||||
RefundNo: refundNo,
|
||||
RefundAmount: order.PayAmount,
|
||||
Status: RefundStatusPending,
|
||||
ApplyTime: applyTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RefundNotify 处理微信退款结果回调(供回调 handler 调用):验签解密 + 幂等 + 更新状态。
|
||||
func (s *OrderService) RefundNotify(ctx context.Context, request *http.Request) *errcode.Error {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.OrderRefundNotify")
|
||||
defer span.End()
|
||||
|
||||
data, err := wechat.VerifyAndDecryptRefundNotify(ctx, request)
|
||||
if err != nil {
|
||||
return errcode.RPCError
|
||||
}
|
||||
|
||||
lockKey := fmtLockKey(redismodel.KeyLockRefundCallback, data.OutRefundNo)
|
||||
lockVal := genUUID()
|
||||
if ok, _ := redisdao.TryLock(ctx, lockKey, lockVal, 30*time.Second); !ok {
|
||||
return nil
|
||||
}
|
||||
defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }()
|
||||
|
||||
flow, ferr := s.refundDao.RefundFlowLogGetByRefundNo(ctx, data.OutRefundNo)
|
||||
if ferr != nil {
|
||||
if isNotFound(ferr) {
|
||||
return nil
|
||||
}
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
// 已终态则幂等返回。
|
||||
if flow.Status == RefundStatusSuccess || flow.Status == RefundStatusFailed {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 校验回调退款金额与流水一致,防止金额被篡改。
|
||||
if data.Amount.Refund != flow.RefundAmount {
|
||||
slog.ErrorContext(ctx, "refund notify: amount mismatch",
|
||||
"out_refund_no", data.OutRefundNo,
|
||||
"notify_amount", data.Amount.Refund,
|
||||
"flow_amount", flow.RefundAmount,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
if data.RefundStatus == "SUCCESS" {
|
||||
// 条件流转,防止并发/乱序回调覆盖状态。
|
||||
ok, _ := s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, data.OutRefundNo, flow.Status, map[string]any{
|
||||
"status": RefundStatusSuccess,
|
||||
"success_time": nowUnix(),
|
||||
"callback_raw": data.Raw,
|
||||
})
|
||||
if !ok {
|
||||
return nil // 已被并发回调处理。
|
||||
}
|
||||
order, gerr := s.orderDao.OrderGetByOrderNo(ctx, data.OutTradeNo)
|
||||
if gerr == nil {
|
||||
s.settleRefundSuccess(ctx, order, flow.RefundAmount)
|
||||
}
|
||||
} else {
|
||||
_, _ = s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, data.OutRefundNo, flow.Status, map[string]any{
|
||||
"status": RefundStatusFailed,
|
||||
"fail_reason": "微信退款未成功:" + data.RefundStatus,
|
||||
"callback_raw": data.Raw,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// autoRefundCancelledOrder 订单已取消但用户已付款:生成待审核退款单,交由管理员在后台手动退款。
|
||||
// 不再自动调用微信退款(所有退款均由管理员操作),避免「自动退款失败后流水卡死无重试」的资损风险。
|
||||
func (s *OrderService) autoRefundCancelledOrder(ctx context.Context, order *mysqlmodel.Order, data *wechat.PayNotifyData) {
|
||||
// 幂等:已有退款流水则跳过,避免重复生成待审核退款单。
|
||||
if existing, err := s.refundDao.RefundFlowLogGetByOrderNo(ctx, order.OrderNo); err == nil && existing != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 补记支付信息(订单已取消,此前未记录),便于管理员审核退款时核对。
|
||||
_ = s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{
|
||||
"pay_time": nowUnix(),
|
||||
"transaction_id": data.TransactionID,
|
||||
"callback_raw": data.Raw,
|
||||
})
|
||||
|
||||
refundNo := genRefundNo()
|
||||
flow := &mysqlmodel.RefundFlowLog{
|
||||
OrderID: order.ID,
|
||||
OrderNo: order.OrderNo,
|
||||
UserID: order.UserID,
|
||||
RefundNo: refundNo,
|
||||
RefundAmount: order.PayAmount,
|
||||
RefundReason: "订单已取消但已付款,待管理员退款",
|
||||
Status: RefundStatusPending,
|
||||
ApplyTime: nowUnix(),
|
||||
}
|
||||
if err := s.refundDao.RefundFlowLogCreate(ctx, flow); err != nil {
|
||||
if isDuplicateKey(err) {
|
||||
return
|
||||
}
|
||||
slog.ErrorContext(ctx, "auto refund: create refund flow failed", "order_no", order.OrderNo, "error", err)
|
||||
return
|
||||
}
|
||||
slog.InfoContext(ctx, "cancelled order refund flow created, waiting manual review",
|
||||
"order_no", order.OrderNo, "refund_no", refundNo)
|
||||
}
|
||||
|
||||
// ---- 内部辅助 ----
|
||||
|
||||
// getOwnedOrder 查询订单并校验归属。
|
||||
func (s *OrderService) getOwnedOrder(ctx context.Context, orderNo string, userID int64) (*mysqlmodel.Order, *errcode.Error) {
|
||||
order, err := s.orderDao.OrderGetByOrderNo(ctx, orderNo)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, errcode.OrderNotFound
|
||||
}
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
if order.UserID != userID {
|
||||
return nil, errcode.Forbidden
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
// finalizePaidOrder 记录支付信息、发放权益并发送通知(支付成功或过期补发货后调用)。
|
||||
func (s *OrderService) finalizePaidOrder(ctx context.Context, order *mysqlmodel.Order, data *wechat.PayNotifyData) {
|
||||
_ = s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{
|
||||
"pay_time": nowUnix(),
|
||||
"transaction_id": data.TransactionID,
|
||||
"callback_raw": data.Raw,
|
||||
})
|
||||
|
||||
s.grantOrderBenefits(ctx, order)
|
||||
|
||||
sendNotification(ctx, order.UserID, 0, NotificationTypeOrder, "订单支付成功", "你购买的「"+order.GoodsName+"」已到账")
|
||||
}
|
||||
|
||||
// grantOrderBenefits 支付成功后幂等发放会员天数与各类额度权益(使用下单时的订单快照,避免商品变更导致错发)。
|
||||
func (s *OrderService) grantOrderBenefits(ctx context.Context, order *mysqlmodel.Order) {
|
||||
benefits := []struct {
|
||||
benefitType int8
|
||||
amount int64
|
||||
}{
|
||||
{BenefitTypeVIP, order.MemberDays},
|
||||
{BenefitTypeCos, order.CosQuota},
|
||||
{BenefitTypeAI, order.AIRecommendQuota},
|
||||
{BenefitTypeClosetShare, order.ClosetShareQuota},
|
||||
{BenefitTypeOutfitShare, order.OutfitShareQuota},
|
||||
}
|
||||
for _, b := range benefits {
|
||||
if b.amount > 0 {
|
||||
s.grantBenefitIdempotent(ctx, order, b.benefitType, b.amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// grantBenefitIdempotent 幂等发放单个权益:先落补偿任务凭证(唯一索引 order_no+benefit_type),
|
||||
// 凭证创建成功才执行发放;凭证冲突说明已有记录,按状态跳过或交定时任务处理。
|
||||
func (s *OrderService) grantBenefitIdempotent(ctx context.Context, order *mysqlmodel.Order, benefitType int8, amount int64) {
|
||||
taskDao := mysqldao.NewBenefitGrantTaskDao()
|
||||
task := &mysqlmodel.BenefitGrantTask{
|
||||
UserID: order.UserID,
|
||||
OrderNo: order.OrderNo,
|
||||
GoodsID: order.GoodsID,
|
||||
BenefitType: benefitType,
|
||||
Amount: amount,
|
||||
Status: BenefitTaskPending,
|
||||
MaxRetry: benefitMaxRetry,
|
||||
}
|
||||
if err := taskDao.BenefitGrantTaskCreate(ctx, task); err != nil {
|
||||
// 非唯一冲突(DB 临时故障等):记录错误返回,交由补偿/人工,不能静默丢权益。
|
||||
if !isDuplicateKey(err) {
|
||||
slog.ErrorContext(ctx, "create benefit grant task failed",
|
||||
"order_no", order.OrderNo, "benefit_type", benefitType, "error", err)
|
||||
return
|
||||
}
|
||||
// 唯一冲突:已有记录。
|
||||
existing, gerr := taskDao.BenefitGrantTaskGetByOrderNoAndType(ctx, order.OrderNo, benefitType)
|
||||
if gerr != nil {
|
||||
slog.ErrorContext(ctx, "get benefit grant task failed",
|
||||
"order_no", order.OrderNo, "benefit_type", benefitType, "error", gerr)
|
||||
return
|
||||
}
|
||||
if existing.Status == BenefitTaskSuccess {
|
||||
return // 已发放成功。
|
||||
}
|
||||
if existing.Status == BenefitTaskFailed {
|
||||
// 曾失败过:冷却期内不重置(防微信回调重试风暴无限重置任务绕过重试上限),
|
||||
// 冷却期外重置为待发放并尝试发放(发放本身有幂等兜底,重置失败仅告警)。
|
||||
if nowUnix()-existing.UpdatedAt < benefitRetryCooldownSeconds {
|
||||
slog.WarnContext(ctx, "benefit grant task in cooldown, skip retry",
|
||||
"order_no", order.OrderNo, "benefit_type", benefitType, "task_id", existing.ID)
|
||||
return
|
||||
}
|
||||
if rerr := taskDao.BenefitGrantTaskReset(ctx, existing.ID); rerr != nil {
|
||||
slog.WarnContext(ctx, "reset benefit grant task failed",
|
||||
"order_no", order.OrderNo, "benefit_type", benefitType, "error", rerr)
|
||||
return
|
||||
}
|
||||
s.executeGrant(ctx, existing)
|
||||
}
|
||||
return // pending 交由定时任务处理。
|
||||
}
|
||||
|
||||
// 凭证创建成功,执行发放(claim 防并发)。
|
||||
s.executeGrant(ctx, task)
|
||||
}
|
||||
|
||||
// executeGrant 认领并发放单个权益任务(claim 保证同一任务仅发放一次,防并发重复发放)。
|
||||
func (s *OrderService) executeGrant(ctx context.Context, task *mysqlmodel.BenefitGrantTask) {
|
||||
taskDao := mysqldao.NewBenefitGrantTaskDao()
|
||||
claimed, err := taskDao.BenefitGrantTaskClaim(ctx, task.ID)
|
||||
if err != nil || !claimed {
|
||||
return // 已被其他实例处理。
|
||||
}
|
||||
if e := s.doGrantBenefit(ctx, task); e != nil {
|
||||
// 发放失败:回退为待发放并记录错误,交由补偿任务重试。
|
||||
_ = taskDao.BenefitGrantTaskUpdateByID(ctx, task.ID, map[string]any{
|
||||
"status": BenefitTaskPending,
|
||||
"retry_count": gorm.Expr("retry_count + 1"),
|
||||
"last_error": e.Error(),
|
||||
})
|
||||
slog.ErrorContext(ctx, "grant benefit failed",
|
||||
"order_no", task.OrderNo, "benefit_type", task.BenefitType, "error", e)
|
||||
return
|
||||
}
|
||||
_ = taskDao.BenefitGrantTaskUpdateByID(ctx, task.ID, map[string]any{"status": BenefitTaskSuccess})
|
||||
}
|
||||
|
||||
// doGrantBenefit 根据补偿任务执行实际权益发放:VIP 实际发放,额度类写入有有效期凭证。
|
||||
func (s *OrderService) doGrantBenefit(ctx context.Context, task *mysqlmodel.BenefitGrantTask) *errcode.Error {
|
||||
switch task.BenefitType {
|
||||
case BenefitTypeVIP:
|
||||
if err := s.grantVIP(ctx, task.UserID, task.Amount, task.OrderNo); err != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
return nil
|
||||
case BenefitTypeCos:
|
||||
return s.grantQuotaGrant(ctx, task, QuotaTypeCos)
|
||||
case BenefitTypeAI:
|
||||
return s.grantQuotaGrant(ctx, task, QuotaTypeAI)
|
||||
case BenefitTypeClosetShare:
|
||||
return s.grantQuotaGrant(ctx, task, QuotaTypeClosetShare)
|
||||
case BenefitTypeOutfitShare:
|
||||
return s.grantQuotaGrant(ctx, task, QuotaTypeOutfitShare)
|
||||
default:
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
}
|
||||
|
||||
// grantQuotaGrant 发放额度凭证(替代原 grantQuota 累加 user_quotas.total)。
|
||||
// 会员包(goods_type=1)凭证跟随 VIP(expire_time=0);加量包按商品 valid_days 设置独立到期时间。
|
||||
// ref_id 复用订单号,配合 (ref_id, quota_type) 唯一索引实现幂等。
|
||||
func (s *OrderService) grantQuotaGrant(ctx context.Context, task *mysqlmodel.BenefitGrantTask, quotaType int8) *errcode.Error {
|
||||
source := QuotaGrantSourceMember
|
||||
expireTime := int64(0) // 0 表示跟随 VIP
|
||||
|
||||
if goods, err := s.goodsDao.GoodsGetByID(ctx, task.GoodsID); err == nil && goods.GoodsType != GoodsTypeMember {
|
||||
// 加量包:独立有效期,从发放时刻起算 valid_days 天;未配置则兜底 30 天。
|
||||
source = QuotaGrantSourceAddon
|
||||
days := goods.ValidDays
|
||||
if days <= 0 {
|
||||
days = 30
|
||||
}
|
||||
expireTime = nowUnix() + days*86400
|
||||
}
|
||||
|
||||
grant := &mysqlmodel.QuotaGrant{
|
||||
UserID: task.UserID,
|
||||
QuotaType: quotaType,
|
||||
Amount: task.Amount,
|
||||
Used: 0,
|
||||
Source: source,
|
||||
ExpireTime: expireTime,
|
||||
RefID: task.OrderNo,
|
||||
}
|
||||
if err := mysqldao.NewQuotaGrantDao().QuotaGrantCreate(ctx, grant); err != nil {
|
||||
if isDuplicateKey(err) {
|
||||
return nil // 已发放,幂等。
|
||||
}
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// grantVIP 开通/续费会员,并写 VIP 流水(事务内幂等:累加与流水同事务,流水唯一索引兜底)。
|
||||
func (s *OrderService) grantVIP(ctx context.Context, userID, memberDays int64, orderNo string) error {
|
||||
user, err := s.userDao.UserGetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := nowUnix()
|
||||
|
||||
newVIPType := VIPTypeMonthly
|
||||
if memberDays >= 365 {
|
||||
newVIPType = VIPTypeYearly
|
||||
}
|
||||
|
||||
changeType := VIPChangeTypeOpen
|
||||
if user.VIPType != VIPTypeNone && user.VIPExpire > now {
|
||||
changeType = VIPChangeTypeRenew
|
||||
}
|
||||
|
||||
flow := &mysqlmodel.VIPFlowLog{
|
||||
UserID: userID,
|
||||
ChangeType: changeType,
|
||||
OldVIPType: user.VIPType,
|
||||
NewVIPType: newVIPType,
|
||||
OldVIPExpire: user.VIPExpire,
|
||||
OrderNo: orderNo,
|
||||
Source: VIPSourcePersonalPay,
|
||||
Reason: "购买会员",
|
||||
}
|
||||
if err := s.userDao.VIPGrantIdempotent(ctx, userID, now, memberDays, newVIPType, flow); err != nil {
|
||||
if isDuplicateKey(err) {
|
||||
return nil // 已发放,幂等。
|
||||
}
|
||||
return err
|
||||
}
|
||||
// 用户信息已变更,失效缓存。
|
||||
_ = redisdao.UserInfoDel(ctx, userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// settleRefundSuccess 退款成功后将订单置为已退款、回退已发放权益并记录退款信息,发送通知。
|
||||
// 兼容两种退款来源:已支付订单(Paid->Refunded,需回收已发放权益)与已取消但已付款订单
|
||||
// (Cancelled->Refunded,其权益从未发放,跳过回收避免误扣其它来源的会员天数/额度凭证)。
|
||||
func (s *OrderService) settleRefundSuccess(ctx context.Context, order *mysqlmodel.Order, refundAmount int64) {
|
||||
from := order.OrderStatus
|
||||
if from != OrderStatusPaid && from != OrderStatusCancelled {
|
||||
return
|
||||
}
|
||||
ok, _ := s.orderDao.OrderUpdateStatusByOrderNo(ctx, order.OrderNo, from, OrderStatusRefunded)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_ = s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{
|
||||
"refund_time": nowUnix(),
|
||||
"refund_amount": refundAmount,
|
||||
})
|
||||
// 仅已支付订单回退已发放权益;已取消订单权益从未发放,无需回收。
|
||||
if from == OrderStatusPaid {
|
||||
s.revokeOrderBenefits(ctx, order)
|
||||
}
|
||||
sendNotification(ctx, order.UserID, 0, NotificationTypeOrder, "退款成功", "订单「"+order.GoodsName+"」已退款")
|
||||
}
|
||||
|
||||
// revokeOrderBenefits 退款成功后回退该订单发放的会员天数并失效赠送额度凭证。
|
||||
// 会员天数走 revokeVIP;额度类通过失效凭证回收(未用部分回收,已用部分不追回,凭证软删除后不计入有效额度)。
|
||||
func (s *OrderService) revokeOrderBenefits(ctx context.Context, order *mysqlmodel.Order) {
|
||||
if order.MemberDays > 0 {
|
||||
s.revokeVIP(ctx, order.UserID, order.MemberDays, order.OrderNo)
|
||||
}
|
||||
if err := mysqldao.NewQuotaGrantDao().QuotaGrantInvalidateByRefID(ctx, order.OrderNo); err != nil {
|
||||
slog.WarnContext(ctx, "invalidate quota grants failed", "order_no", order.OrderNo, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// revokeVIP 幂等回退会员天数(退款回收)。
|
||||
func (s *OrderService) revokeVIP(ctx context.Context, userID, memberDays int64, orderNo string) {
|
||||
user, err := s.userDao.UserGetByID(ctx, userID)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "revoke vip: get user failed", "user_id", userID, "error", err)
|
||||
return
|
||||
}
|
||||
flow := &mysqlmodel.VIPFlowLog{
|
||||
UserID: userID,
|
||||
ChangeType: VIPChangeTypeRefundRevoke,
|
||||
OldVIPType: user.VIPType,
|
||||
NewVIPType: user.VIPType,
|
||||
OldVIPExpire: user.VIPExpire,
|
||||
OrderNo: orderNo,
|
||||
Source: VIPSourceSystem,
|
||||
Reason: "退款回收会员",
|
||||
}
|
||||
if err := s.userDao.VIPRevokeIdempotent(ctx, userID, nowUnix(), memberDays, flow); err != nil {
|
||||
if !isDuplicateKey(err) {
|
||||
slog.ErrorContext(ctx, "revoke vip failed", "order_no", orderNo, "error", err)
|
||||
}
|
||||
}
|
||||
// 会员信息已变更,失效缓存。
|
||||
_ = redisdao.UserInfoDel(ctx, userID)
|
||||
}
|
||||
|
||||
// buildOrderResponse 将订单模型映射为响应。
|
||||
func buildOrderResponse(o *mysqlmodel.Order) dto.OrderResponse {
|
||||
return dto.OrderResponse{
|
||||
OrderNo: o.OrderNo,
|
||||
GoodsName: o.GoodsName,
|
||||
UnitPrice: o.UnitPrice,
|
||||
PayAmount: o.PayAmount,
|
||||
PayChannel: o.PayChannel,
|
||||
OrderStatus: o.OrderStatus,
|
||||
MemberDays: o.MemberDays,
|
||||
ExpireTime: o.ExpireTime,
|
||||
PayTime: o.PayTime,
|
||||
RefundTime: o.RefundTime,
|
||||
RefundAmount: o.RefundAmount,
|
||||
CreatedAt: o.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// genOrderNo 生成唯一商户订单号。
|
||||
func genOrderNo() string {
|
||||
return "OD" + time.Now().Format("20060102150405") + strings.ReplaceAll(genUUID(), "-", "")[:16]
|
||||
}
|
||||
|
||||
// genRefundNo 生成唯一商户退款单号。
|
||||
func genRefundNo() string {
|
||||
return "RF" + time.Now().Format("20060102150405") + strings.ReplaceAll(genUUID(), "-", "")[:16]
|
||||
}
|
||||
|
||||
// fmtLockKey 拼接分布式锁 key(key 模板含单个 %s 占位符)。
|
||||
func fmtLockKey(tmpl, arg string) string {
|
||||
return strings.Replace(tmpl, "%s", arg, 1)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
|
||||
)
|
||||
|
||||
// StartOrderExpireScheduler 启动订单超时过期定时任务:每分钟将超时未支付订单批量置为已过期。
|
||||
func StartOrderExpireScheduler() {
|
||||
go func() {
|
||||
slog.Info("order expire scheduler started")
|
||||
for {
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("order expire scheduler panic", "recover", r)
|
||||
}
|
||||
}()
|
||||
runOrderExpire()
|
||||
}()
|
||||
time.Sleep(time.Minute)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// runOrderExpire 扫描并过期超时待支付订单。条件更新天然幂等,多实例并发无害,无需分布式锁。
|
||||
func runOrderExpire() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
ctx, span := global.Tracer.Start(ctx, "scheduler.OrderExpire")
|
||||
defer span.End()
|
||||
|
||||
affected, err := mysqldao.NewOrderDao().OrderExpireByTime(ctx, nowUnix())
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "expire orders failed", "error", err)
|
||||
return
|
||||
}
|
||||
if affected > 0 {
|
||||
slog.InfoContext(ctx, "expired pending orders", "count", affected)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/upload"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OutfitService 穿搭业务服务。
|
||||
type OutfitService struct {
|
||||
outfitDao *mysqldao.OutfitDao
|
||||
outfitItemDao *mysqldao.OutfitItemDao
|
||||
clothesDao *mysqldao.ClothesDao
|
||||
auditLogDao *mysqldao.OutfitAuditLogDao
|
||||
}
|
||||
|
||||
// NewOutfitService 创建穿搭服务。
|
||||
func NewOutfitService() *OutfitService {
|
||||
return &OutfitService{
|
||||
outfitDao: mysqldao.NewOutfitDao(),
|
||||
outfitItemDao: mysqldao.NewOutfitItemDao(),
|
||||
clothesDao: mysqldao.NewClothesDao(),
|
||||
auditLogDao: mysqldao.NewOutfitAuditLogDao(),
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建穿搭。
|
||||
func (s *OutfitService) Create(ctx context.Context, userID int64, request *dto.OutfitCreateRequest) (*dto.OutfitCreateResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.OutfitCreate")
|
||||
defer span.End()
|
||||
|
||||
clothesIDs, items, cerr := s.resolveOwnedClothes(ctx, userID, request.Items)
|
||||
if cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
|
||||
source := request.Source
|
||||
if source != OutfitSourceUser && source != OutfitSourceAI {
|
||||
source = OutfitSourceUser
|
||||
}
|
||||
|
||||
outfit := &mysqlmodel.Outfit{
|
||||
OutfitUUID: genUUID(),
|
||||
UserID: userID,
|
||||
OutfitName: request.OutfitName,
|
||||
OutfitDesc: request.OutfitDesc,
|
||||
Scene: request.Scene,
|
||||
Source: source,
|
||||
IsPublic: OutfitPrivate,
|
||||
AuditStatus: OutfitAuditPending,
|
||||
Status: OutfitStatusNormal,
|
||||
}
|
||||
if err := s.outfitDao.OutfitCreate(ctx, outfit); err != nil {
|
||||
return nil, errcode.OutfitCreateFailed
|
||||
}
|
||||
|
||||
if err := s.createItems(ctx, outfit.ID, clothesIDs); err != nil {
|
||||
return nil, errcode.OutfitCreateFailed
|
||||
}
|
||||
|
||||
response := buildOutfitCreateResponse(outfit, items)
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// GetByUUID 获取穿搭详情。私有穿搭仅本人可见。
|
||||
func (s *OutfitService) GetByUUID(ctx context.Context, userID int64, request *dto.OutfitGetByUUIDRequest) (*dto.OutfitGetByUUIDResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.OutfitGetByUUID")
|
||||
defer span.End()
|
||||
|
||||
outfit, err := s.outfitDao.OutfitGetByUUID(ctx, request.OutfitUUID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, errcode.OutfitNotFound
|
||||
}
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
// 可见性:本人,或已公开且审核通过且正常。
|
||||
if outfit.UserID != userID && !isOutfitPublicVisible(outfit) {
|
||||
return nil, errcode.OutfitNoPermission
|
||||
}
|
||||
|
||||
items, lerr := s.itemResponses(ctx, outfit.ID)
|
||||
if lerr != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
return &dto.OutfitGetByUUIDResponse{
|
||||
OutfitCreateResponse: buildOutfitCreateResponse(outfit, items),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateByUUID 修改穿搭基础信息与明细。
|
||||
func (s *OutfitService) UpdateByUUID(ctx context.Context, userID int64, request *dto.OutfitUpdateByUUIDRequest) (*dto.OutfitUpdateByUUIDResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.OutfitUpdateByUUID")
|
||||
defer span.End()
|
||||
|
||||
outfit, cerr := s.getOwnedOutfit(ctx, request.OutfitUUID, userID)
|
||||
if cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
|
||||
clothesIDs, _, verr := s.resolveOwnedClothes(ctx, userID, request.Items)
|
||||
if verr != nil {
|
||||
return nil, verr
|
||||
}
|
||||
|
||||
updates := map[string]any{}
|
||||
setIfNotEmpty(updates, "outfit_name", request.OutfitName)
|
||||
setIfNotEmpty(updates, "outfit_desc", request.OutfitDesc)
|
||||
setIfNotEmpty(updates, "scene", request.Scene)
|
||||
if len(updates) > 0 {
|
||||
if err := s.outfitDao.OutfitUpdateByUUID(ctx, request.OutfitUUID, updates); err != nil {
|
||||
return nil, errcode.OutfitUpdateFailed
|
||||
}
|
||||
}
|
||||
|
||||
// 重建明细(事务内先删后建,避免中途失败留下空明细)。
|
||||
rebuildErr := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("outfit_id = ?", outfit.ID).Delete(&mysqlmodel.OutfitItem{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
items := make([]mysqlmodel.OutfitItem, 0, len(clothesIDs))
|
||||
for _, cid := range clothesIDs {
|
||||
items = append(items, mysqlmodel.OutfitItem{OutfitID: outfit.ID, ClothesID: cid})
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Create(&items).Error
|
||||
})
|
||||
if rebuildErr != nil {
|
||||
return nil, errcode.OutfitUpdateFailed
|
||||
}
|
||||
|
||||
return &dto.OutfitUpdateByUUIDResponse{}, nil
|
||||
}
|
||||
|
||||
// List 分页查询我的穿搭。
|
||||
func (s *OutfitService) List(ctx context.Context, userID int64, request *dto.OutfitListRequest) (*dto.OutfitListResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.OutfitList")
|
||||
defer span.End()
|
||||
|
||||
page, pageSize := normalizePage(request.Page, request.PageSize)
|
||||
|
||||
list, total, err := s.outfitDao.OutfitListByUserID(ctx, userID, request.IsPublic, request.Source, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
items, berr := s.buildOutfitListResponses(ctx, list)
|
||||
if berr != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
return &dto.OutfitListResponse{
|
||||
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
|
||||
List: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Delete 删除穿搭(软删除穿搭 + 清理明细)。
|
||||
func (s *OutfitService) Delete(ctx context.Context, userID int64, request *dto.OutfitDeleteRequest) (*dto.OutfitDeleteResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.OutfitDelete")
|
||||
defer span.End()
|
||||
|
||||
outfit, cerr := s.getOwnedOutfit(ctx, request.OutfitUUID, userID)
|
||||
if cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
if err := s.outfitDao.OutfitDeleteByUUID(ctx, request.OutfitUUID); err != nil {
|
||||
return nil, errcode.OutfitDeleteFailed
|
||||
}
|
||||
_ = s.outfitItemDao.OutfitItemDeleteByOutfitID(ctx, outfit.ID)
|
||||
return &dto.OutfitDeleteResponse{}, nil
|
||||
}
|
||||
|
||||
// Public 设置穿搭公开状态。公开时自动审核通过并写审核日志、消耗社区分享额度。
|
||||
func (s *OutfitService) Public(ctx context.Context, userID int64, request *dto.OutfitPublicRequest) (*dto.OutfitPublicResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.OutfitPublic")
|
||||
defer span.End()
|
||||
|
||||
outfit, cerr := s.getOwnedOutfit(ctx, request.OutfitUUID, userID)
|
||||
if cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
|
||||
// 公开/取消公开流程加锁串行化:消除并发下「A 扣额失败回退公开态、B 幂等返回成功但最终未公开」
|
||||
// 的语义冲突;锁失败返回请求过于频繁,Redis 异常时降级放行(与项目 fail-open 策略一致)。
|
||||
lockKey := fmtLockKey(redismodel.KeyLockOutfitPublic, request.OutfitUUID)
|
||||
lockVal := genUUID()
|
||||
if ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, 10*time.Second); lerr != nil {
|
||||
slog.WarnContext(ctx, "acquire outfit public lock failed, proceed without lock",
|
||||
"outfit_uuid", request.OutfitUUID, "error", lerr)
|
||||
} else if !ok {
|
||||
return nil, errcode.TooManyRequests
|
||||
} else {
|
||||
defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }()
|
||||
}
|
||||
|
||||
// 取消公开:直接置为不公开。
|
||||
if request.IsPublic == OutfitPrivate {
|
||||
if outfit.IsPublic == OutfitPrivate {
|
||||
return &dto.OutfitPublicResponse{}, nil
|
||||
}
|
||||
if err := s.outfitDao.OutfitUpdateIsPublic(ctx, request.OutfitUUID, OutfitPrivate); err != nil {
|
||||
return nil, errcode.OutfitUpdateFailed
|
||||
}
|
||||
return &dto.OutfitPublicResponse{}, nil
|
||||
}
|
||||
|
||||
// 已公开则无需重复处理。
|
||||
if outfit.IsPublic == OutfitPublic {
|
||||
return &dto.OutfitPublicResponse{}, nil
|
||||
}
|
||||
|
||||
// 首次公开:先条件更新(私密 -> 公开+审核通过),成功才消耗额度,防并发重复扣减。
|
||||
ok, perr := s.outfitDao.OutfitPublish(ctx, request.OutfitUUID)
|
||||
if perr != nil {
|
||||
return nil, errcode.OutfitUpdateFailed
|
||||
}
|
||||
if !ok {
|
||||
// 已被并发公开,幂等返回。
|
||||
return &dto.OutfitPublicResponse{}, nil
|
||||
}
|
||||
|
||||
if qerr := consumeDailyQuota(ctx, userID, QuotaTypeOutfitShare, 1, "穿搭社区分享消耗"); qerr != nil {
|
||||
// 扣额度失败,回退公开状态。
|
||||
_ = s.outfitDao.OutfitUpdateIsPublic(ctx, request.OutfitUUID, OutfitPrivate)
|
||||
return nil, qerr
|
||||
}
|
||||
|
||||
// 写审核日志(自动通过,best-effort:失败仅告警不阻断公开)。
|
||||
if err := s.auditLogDao.OutfitAuditLogCreate(ctx, &mysqlmodel.OutfitAuditLog{
|
||||
TargetID: outfit.ID,
|
||||
AuditResult: AuditResultPass,
|
||||
AuditTime: nowUnix(),
|
||||
}); err != nil {
|
||||
slog.WarnContext(ctx, "write outfit audit log failed", "outfit_id", outfit.ID, "error", err)
|
||||
}
|
||||
|
||||
// 记录当日社区分享计数(统计)。
|
||||
recordDailyUsage(ctx, userID, QuotaTypeOutfitShare)
|
||||
|
||||
return &dto.OutfitPublicResponse{}, nil
|
||||
}
|
||||
|
||||
// ---- 内部辅助 ----
|
||||
|
||||
// getOwnedOutfit 查询穿搭并校验归属。
|
||||
func (s *OutfitService) getOwnedOutfit(ctx context.Context, outfitUUID string, userID int64) (*mysqlmodel.Outfit, *errcode.Error) {
|
||||
outfit, err := s.outfitDao.OutfitGetByUUID(ctx, outfitUUID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, errcode.OutfitNotFound
|
||||
}
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
if outfit.UserID != userID {
|
||||
return nil, errcode.OutfitNoPermission
|
||||
}
|
||||
return outfit, nil
|
||||
}
|
||||
|
||||
// isOutfitPublicVisible 判断穿搭是否对他人公开可见(公开 + 审核通过 + 正常)。
|
||||
func isOutfitPublicVisible(o *mysqlmodel.Outfit) bool {
|
||||
return o.IsPublic == OutfitPublic && o.AuditStatus == OutfitAuditPass && o.Status == OutfitStatusNormal
|
||||
}
|
||||
|
||||
// resolveOwnedClothes 校验请求中的衣物均属于该用户,返回有序去重的 clothesID 与 uuid 列表。
|
||||
func (s *OutfitService) resolveOwnedClothes(ctx context.Context, userID int64, items []dto.OutfitItemRequest) ([]int64, []dto.OutfitItemResponse, *errcode.Error) {
|
||||
uuids := make([]string, 0, len(items))
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
for _, it := range items {
|
||||
if _, ok := seen[it.ClothesUUID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[it.ClothesUUID] = struct{}{}
|
||||
uuids = append(uuids, it.ClothesUUID)
|
||||
}
|
||||
|
||||
clothesList, err := s.clothesDao.ClothesGetByUUIDs(ctx, uuids)
|
||||
if err != nil {
|
||||
return nil, nil, errcode.DatabaseError
|
||||
}
|
||||
byUUID := make(map[string]mysqlmodel.Clothes, len(clothesList))
|
||||
for _, c := range clothesList {
|
||||
byUUID[c.ClothesUUID] = c
|
||||
}
|
||||
|
||||
ids := make([]int64, 0, len(uuids))
|
||||
respItems := make([]dto.OutfitItemResponse, 0, len(uuids))
|
||||
for _, u := range uuids {
|
||||
c, ok := byUUID[u]
|
||||
if !ok {
|
||||
return nil, nil, errcode.ClothesNotFound
|
||||
}
|
||||
if c.UserID != userID {
|
||||
return nil, nil, errcode.ClothesNoPermission
|
||||
}
|
||||
ids = append(ids, c.ID)
|
||||
respItems = append(respItems, buildOutfitItemResponse(&c))
|
||||
}
|
||||
return ids, respItems, nil
|
||||
}
|
||||
|
||||
// createItems 批量创建穿搭明细。
|
||||
func (s *OutfitService) createItems(ctx context.Context, outfitID int64, clothesIDs []int64) error {
|
||||
items := make([]mysqlmodel.OutfitItem, 0, len(clothesIDs))
|
||||
for _, cid := range clothesIDs {
|
||||
items = append(items, mysqlmodel.OutfitItem{OutfitID: outfitID, ClothesID: cid})
|
||||
}
|
||||
return s.outfitItemDao.OutfitItemCreateBatch(ctx, items)
|
||||
}
|
||||
|
||||
// itemResponses 获取某穿搭的衣物明细(含图片 URL)。
|
||||
func (s *OutfitService) itemResponses(ctx context.Context, outfitID int64) ([]dto.OutfitItemResponse, error) {
|
||||
items, err := s.outfitItemDao.OutfitItemListByOutfitID(ctx, outfitID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]int64, 0, len(items))
|
||||
for _, it := range items {
|
||||
ids = append(ids, it.ClothesID)
|
||||
}
|
||||
clothesList, err := s.clothesDao.ClothesGetByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clothesByID := make(map[int64]mysqlmodel.Clothes, len(clothesList))
|
||||
for _, c := range clothesList {
|
||||
clothesByID[c.ID] = c
|
||||
}
|
||||
resps := make([]dto.OutfitItemResponse, 0, len(items))
|
||||
for _, it := range items {
|
||||
if c, ok := clothesByID[it.ClothesID]; ok {
|
||||
resps = append(resps, buildOutfitItemResponse(&c))
|
||||
}
|
||||
}
|
||||
return resps, nil
|
||||
}
|
||||
|
||||
// buildOutfitListResponses 批量组装穿搭列表(一次性加载全部明细与衣物,避免 N+1)。
|
||||
func (s *OutfitService) buildOutfitListResponses(ctx context.Context, list []mysqlmodel.Outfit) ([]dto.OutfitCreateResponse, error) {
|
||||
if len(list) == 0 {
|
||||
return []dto.OutfitCreateResponse{}, nil
|
||||
}
|
||||
|
||||
outfitIDs := make([]int64, 0, len(list))
|
||||
for _, o := range list {
|
||||
outfitIDs = append(outfitIDs, o.ID)
|
||||
}
|
||||
|
||||
allItems, err := s.outfitItemDao.OutfitItemListByOutfitIDs(ctx, outfitIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
clothesIDSet := make(map[int64]struct{}, len(allItems))
|
||||
itemsByOutfit := make(map[int64][]int64, len(list))
|
||||
for _, it := range allItems {
|
||||
itemsByOutfit[it.OutfitID] = append(itemsByOutfit[it.OutfitID], it.ClothesID)
|
||||
clothesIDSet[it.ClothesID] = struct{}{}
|
||||
}
|
||||
|
||||
clothesIDs := make([]int64, 0, len(clothesIDSet))
|
||||
for id := range clothesIDSet {
|
||||
clothesIDs = append(clothesIDs, id)
|
||||
}
|
||||
clothesList, err := s.clothesDao.ClothesGetByIDs(ctx, clothesIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clothesByID := make(map[int64]mysqlmodel.Clothes, len(clothesList))
|
||||
for _, c := range clothesList {
|
||||
clothesByID[c.ID] = c
|
||||
}
|
||||
|
||||
resps := make([]dto.OutfitCreateResponse, 0, len(list))
|
||||
for i := range list {
|
||||
o := &list[i]
|
||||
items := make([]dto.OutfitItemResponse, 0, len(itemsByOutfit[o.ID]))
|
||||
for _, cid := range itemsByOutfit[o.ID] {
|
||||
if c, ok := clothesByID[cid]; ok {
|
||||
items = append(items, buildOutfitItemResponse(&c))
|
||||
}
|
||||
}
|
||||
resps = append(resps, buildOutfitCreateResponse(o, items))
|
||||
}
|
||||
return resps, nil
|
||||
}
|
||||
|
||||
// buildOutfitItemResponse 组装穿搭明细(processed_image 转为可访问 URL)。
|
||||
func buildOutfitItemResponse(c *mysqlmodel.Clothes) dto.OutfitItemResponse {
|
||||
img := c.ProcessedImage
|
||||
if img != "" {
|
||||
img = upload.GetCosUrl(img)
|
||||
}
|
||||
return dto.OutfitItemResponse{ClothesUUID: c.ClothesUUID, ProcessedImage: img}
|
||||
}
|
||||
|
||||
// buildOutfitCreateResponse 组装穿搭响应。
|
||||
func buildOutfitCreateResponse(o *mysqlmodel.Outfit, items []dto.OutfitItemResponse) dto.OutfitCreateResponse {
|
||||
return dto.OutfitCreateResponse{
|
||||
OutfitUUID: o.OutfitUUID,
|
||||
OutfitName: o.OutfitName,
|
||||
OutfitDesc: o.OutfitDesc,
|
||||
Scene: o.Scene,
|
||||
Source: o.Source,
|
||||
IsPublic: o.IsPublic,
|
||||
AuditStatus: o.AuditStatus,
|
||||
Items: items,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user