first commit

This commit is contained in:
2026-09-11 01:41:52 +08:00
commit 9f22e6ccae
263 changed files with 28935 additions and 0 deletions
+16
View File
@@ -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"` // 发放数量
}
+28
View File
@@ -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
}
+52
View File
@@ -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"`
}
+91
View File
@@ -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"`
}
+29
View File
@@ -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"` // 收藏数
}
+14
View File
@@ -0,0 +1,14 @@
package dto
// PageRequest 分页请求参数(GET query,嵌入各列表请求)
type PageRequest struct {
Page int `form:"page" binding:"omitempty,min=1"`
PageSize int `form:"page_size" binding:"omitempty,min=1,max=50"`
}
// PageResponse 分页响应(嵌入各列表响应)
type PageResponse struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
+34
View File
@@ -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"`
}
+16
View File
@@ -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"` // 今日幸运数字
}
+21
View File
@@ -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"` // 点赞数
}
+39
View File
@@ -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"`
}
+86
View File
@@ -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"`
}
+83
View File
@@ -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{}
+44
View File
@@ -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"`
}
+68
View File
@@ -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过期时间(秒)
}
+50
View File
@@ -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
}
+55
View File
@@ -0,0 +1,55 @@
package middleware
import (
"log/slog"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// sanitizeQuery 对 query 中的敏感参数值脱敏,避免日志泄露 token/code/密钥等。
func sanitizeQuery(query string) string {
if query == "" {
return ""
}
values, err := url.ParseQuery(query)
if err != nil {
return "***"
}
sensitive := map[string]bool{
"token": true, "code": true, "secret": true, "password": true,
"access_token": true, "refresh_token": true, "key": true,
}
for k := range values {
if sensitive[strings.ToLower(k)] {
values.Set(k, "***")
}
}
return values.Encode()
}
func GinSlogMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := sanitizeQuery(c.Request.URL.RawQuery)
// 处理请求
c.Next()
// 请求结束后打日志
slog.InfoContext(c.Request.Context(), "GIN_REQUEST",
"request_id", GetRequestID(c.Request.Context()),
"method", c.Request.Method,
"path", path,
"query", query,
"status", c.Writer.Status(),
"ip", c.ClientIP(),
"latency", time.Since(start).String(),
"user_agent", c.Request.UserAgent(),
)
}
}
+113
View File
@@ -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
}
+16
View File
@@ -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)
}
+37
View File
@@ -0,0 +1,37 @@
package middleware
import (
"context"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
const requestIDKey ctxKey = "requestID"
// RequestID 从请求头取 X-Request-ID,没有则生成 UUID。
// 注入 context 并写入响应头,便于全链路追踪。
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
rid := c.GetHeader("X-Request-ID")
if rid == "" {
rid = uuid.New().String()
}
// 写入 gin context(供 handler 取用)
c.Set("requestID", rid)
// 写入 request context(供 service/dao 的 slog 取用)
ctx := context.WithValue(c.Request.Context(), requestIDKey, rid)
c.Request = c.Request.WithContext(ctx)
// 响应头
c.Header("X-Request-ID", rid)
c.Next()
}
}
// GetRequestID 从 context 取出 request-id。
func GetRequestID(ctx context.Context) string {
rid, _ := ctx.Value(requestIDKey).(string)
return rid
}
+79
View File
@@ -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
}
+121
View File
@@ -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
}
+201
View File
@@ -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
}
+138
View File
@@ -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
}
+109
View File
@@ -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
}
+67
View File
@@ -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
}
+127
View File
@@ -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
}
+102
View File
@@ -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
}
+114
View File
@@ -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(&notification).Error; err != nil {
span.RecordError(err)
return nil, err
}
return &notification, 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
}
+197
View File
@@ -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
}
+216
View File
@@ -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
}
+502
View File
@@ -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(&quota).Error; err != nil {
span.RecordError(err)
return nil, err
}
return &quota, 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(&quotas).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
}
+176
View File
@@ -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
}
+65
View File
@@ -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
}
+41
View File
@@ -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"
}
+25
View File
@@ -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"
}
+22
View File
@@ -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"
}
+35
View File
@@ -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"
}
+32
View File
@@ -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"
}
+14
View File
@@ -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"
}
+24
View File
@@ -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"
}
+19
View File
@@ -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"
}
+14
View File
@@ -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"
}
+54
View File
@@ -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
}
+17
View File
@@ -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"
}
+34
View File
@@ -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"
}
+28
View File
@@ -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"
}
+45
View File
@@ -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"
}
+23
View File
@@ -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"
}
+22
View File
@@ -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"
}
+25
View File
@@ -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"
}
+20
View File
@@ -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"
}
+122
View File
@@ -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
}
+27
View File
@@ -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
}
+79
View File
@@ -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
}
+37
View File
@@ -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
}
+79
View File
@@ -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
}
+44
View File
@@ -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
}
+89
View File
@@ -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
}
+23
View File
@@ -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
}
+186
View File
@@ -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)
}
+71
View File
@@ -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
}
+9
View File
@@ -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 用)
)
+4
View File
@@ -0,0 +1,4 @@
package redismodel
// 衣柜分享模块:分享访问计数。
const KeyClosetShareView = RedisPrefix + "closet:share:view:%s" // share_uuid,衣柜分享访问计数(INCR)
+4
View File
@@ -0,0 +1,4 @@
package redismodel
// 收藏模块:按业务类型 + 目标 ID 分桶的收藏集合。
const KeyCollectSet = RedisPrefix + "collect:set:%d:%d" // collect_type:target_id,成员为 user_id(Set,1=社区穿搭)
+4
View File
@@ -0,0 +1,4 @@
package redismodel
// 星座模块:按星座 + 日期缓存的运势内容。
const KeyHoroscope = RedisPrefix + "horoscope:%s:%d" // zodiac:stat_date(YYYYMMDD),星座运势缓存(String/JSON)
+41
View File
@@ -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
}
+4
View File
@@ -0,0 +1,4 @@
package redismodel
// 点赞模块:按业务类型 + 目标 ID 分桶的点赞集合。
const KeyLikeSet = RedisPrefix + "like:set:%d:%d" // like_type:target_id,成员为 user_id(Set,1=社区穿搭)
+13
View File
@@ -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,穿搭公开/取消公开串行化
)
+4
View File
@@ -0,0 +1,4 @@
package redismodel
// 通知模块:用户未读消息计数。
const KeyNotificationUnread = RedisPrefix + "notification:unread:%d" // user_id,未读消息数(INCR/DECR)
+4
View File
@@ -0,0 +1,4 @@
package redismodel
// 穿搭模块:公开穿搭浏览计数。
const KeyOutfitView = RedisPrefix + "outfit:view:%d" // outfit_id,公开穿搭浏览计数(INCR)
+9
View File
@@ -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)
)
+4
View File
@@ -0,0 +1,4 @@
package redismodel
// 用户模块:用户鉴权核心信息缓存。
const KeyUserInfo = RedisPrefix + "user:info:%d" // user_id -> 用户鉴权核心信息(String/JSON,含 Status)
+41
View File
@@ -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)
}
+61
View File
@@ -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)
}
+125
View File
@@ -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)
}
+158
View File
@@ -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)
}
+83
View File
@@ -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)
}
+51
View File
@@ -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)
}
+35
View File
@@ -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)
}
+62
View File
@@ -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)
}
+99
View File
@@ -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)
}
+181
View File
@@ -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})
}
+156
View File
@@ -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)
}
+57
View File
@@ -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)
}
+119
View File
@@ -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)
}
+133
View File
@@ -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
}
+113
View File
@@ -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()
}
+256
View File
@@ -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)
}
}
+112
View File
@@ -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
}
}
+366
View File
@@ -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
}
+472
View File
@@ -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
}
}
+183
View File
@@ -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
}
+213
View File
@@ -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
)
+84
View File
@@ -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,
}
}
+172
View File
@@ -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 "摩羯座"
}
+125
View File
@@ -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
}
+158
View File
@@ -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)
}
}
+747
View File
@@ -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)
}
+46
View File
@@ -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)
}
}
+424
View File
@@ -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