--- name: 补齐 mysqldao 数据访问层 overview: 根据 mysqlmodel 全部实体与 dto 暴露的业务接口,补齐 internal/mysqldao 下所有 DAO(单表 CRUD + 常用查询,含分页、软删除、原子扣减、幂等),沿用现有 user.go 风格。 todos: - id: fix-user-dao content: 修正 user.go 统一 d.db 并移除 UserQuotaListByUserID status: completed - id: add-clothes-outfit-closet-dao content: 新增 clothes.go、outfit.go、closet.go 三个 DAO 文件 status: completed - id: add-like-collect-goods-dao content: 新增 like.go、collect.go、goods.go 三个 DAO 文件 status: completed - id: add-order-quota-dao content: 新增 order.go、quota.go 两个 DAO 文件 status: completed dependencies: - fix-user-dao - id: add-horoscope-notification-audit-vip-dao content: 新增 horoscope.go、notification.go、auditlog.go、vipflowlog.go 四个 DAO 文件 status: completed - id: verify-build content: 运行 go build、go vet 与 lint 验证编译通过 status: completed dependencies: - fix-user-dao - add-clothes-outfit-closet-dao - add-like-collect-goods-dao - add-order-quota-dao - add-horoscope-notification-audit-vip-dao --- ## 产品概述 根据现有业务实体(`internal/mysqlmodel`)与接口 DTO(`internal/dto`)推断出的业务逻辑,补齐 `internal/mysqldao` 数据访问层,为后续 service 层提供统一、可测试、可替换的 GORM DAO。 ## 核心功能 - **覆盖全部业务实体**:为 User、Clothes、Outfit/OutfitItem、ClosetShare/ClosetShareItem、Like、Collect、Goods、Order/RefundFlowLog、Quota/UserDailyUsage/QuotaFlowLog、Horoscope/AI 流水日志、UserNotification、审核日志、VIPFlowLog 提供 DAO。 - **统一 CRUD 与列表**:主键/UUID/业务编码查询、创建、条件更新、软删除,以及带总数返回的分页列表。 - **业务一致性能力**:Like/Collect 幂等(查记录→更新或创建)、订单/退款回调条件更新防重、配额原子扣减与回退、每日使用计数原子自增、Horoscope 与 AI 流水的幂等查询。 - **风格对齐**:沿用现有 `struct{db *gorm.DB} + NewXxxDao()` 模式,并将方法体统一改为 `d.db.WithContext(ctx)`(顺带修正 user.go 中直接使用 `global.MysqlEngine` 的写法)。 ## 约束与边界 - 仅修改/新增 `internal/mysqldao/` 内文件,不实现 service、handler、routers。 - DAO 不引入 `app`/`errcode`,错误统一返回 `error`(含 `gorm.ErrRecordNotFound`),由上层转换。 - 全部使用 GORM 参数绑定防 SQL 注入,不拼接 SQL 字符串。 ## 技术栈 - 语言:Go - ORM:GORM v2(复用 `global.MysqlEngine *gorm.DB`) - 数据模型:`internal/mysqlmodel`(`Model` 基类含 `ID int64` 主键、`CreatedAt/UpdatedAt int64`、`DeletedAt soft_delete.DeletedAt` 软删除) ## 实现方案 ### DAO 组织方式 沿用现有 `user.go` 模式:每个 DAO 为 `struct{ db *gorm.DB }`,提供 `NewXxxDao()` 构造函数注入 `global.MysqlEngine`,方法统一使用 `d.db.WithContext(ctx)`。一个文件承载一个或多个强相关 DAO(如 Outfit 与 OutfitItem 同文件)。 ### 关键技术决策 1. **统一 `d.db`**:修正 user.go 直接调用 `global.MysqlEngine` 的写法,使 DAO 可替换、可测试。 2. **软删除**:依赖 `Model.DeletedAt soft_delete.DeletedAt`,gorm 查询自动过滤已删除记录;删除统一走 `db.Delete(&model)` 软删,不硬删。 3. **分页 List 约定**:DAO List 方法签名统一为 `(ctx, 筛选条件..., page, pageSize int) (list, total int64, err error)`,内部先 `Count(&total)` 再 `Offset((page-1)*pageSize).Limit(pageSize).Order("id DESC")`。 4. **Like/Collect 幂等**:目标表 `TargetID` 为 int64 主键(UUID→ID 转换在 service 层);DAO 提供 `GetByTargetAndUser` 供上层判断存在则 `UpdateStatus`,不存在则 `Create`。 5. **订单/退款回调幂等**:提供条件更新 `UpdateStatusByOrderNo(orderNo, fromStatus, toStatus)`,用 `WHERE order_no = ? AND order_status = ?`,以 `RowsAffected == 0` 判定重复回调或状态非法。 6. **配额原子扣减**:`IncreaseUsed` 用 `Where("user_id = ? AND quota_type = ? AND used + ? <= total", ...).UpdateColumn("used", gorm.Expr("used + ?", n))`,以 RowsAffected 判断是否超额;`DecreaseUsed` 用 `Where("used >= ?", n)` 递减回退。 7. **每日使用计数原子自增**:`UserDailyUsage` 按 `user_id + stat_date` 唯一,提供按日期查询 + 对应字段 `UpdateColumn(..., gorm.Expr("... + 1"))` 自增。 8. **错误处理**:查询未命中返回 `gorm.ErrRecordNotFound`(`First` 直接返回),不吞错;Create/Update 直接透传 `.Error`。 ## 目录结构 本次共涉及 13 个文件(1 个修改 + 12 个新增),全部位于 `internal/mysqldao/`: ``` internal/mysqldao/ ├── user.go # [MODIFY] UserDao 方法体统一 d.db;删除 UserQuotaListByUserID(迁移至 QuotaDao) ├── clothes.go # [NEW] ClothesDao ├── outfit.go # [NEW] OutfitDao + OutfitItemDao ├── closet.go # [NEW] ClosetShareDao + ClosetShareItemDao ├── like.go # [NEW] LikeDao ├── collect.go # [NEW] CollectDao ├── goods.go # [NEW] GoodsDao ├── order.go # [NEW] OrderDao + RefundFlowLogDao ├── quota.go # [NEW] QuotaDao + UserDailyUsageDao + QuotaFlowLogDao ├── horoscope.go # [NEW] HoroscopeDao + AIHoroscopeFlowLogDao + AIRecommendFlowLogDao ├── notification.go # [NEW] NotificationDao ├── auditlog.go # [NEW] ClosetAuditLogDao + OutfitAuditLogDao └── vipflowlog.go # [NEW] VIPFlowLogDao ``` ## 各 DAO 方法清单 ### ClothesDao(clothes.go) - `ClothesCreate(ctx, *Clothes) error` - `ClothesGetByUUID(ctx, uuid string) (*Clothes, error)` - `ClothesGetByID(ctx, id int64) (*Clothes, error)` - `ClothesGetByUUIDs(ctx, uuids []string) ([]Clothes, error)` - `ClothesGetByIDs(ctx, ids []int64) ([]Clothes, error)` - `ClothesListByUserID(ctx, userID int64, categoryMain, categorySub string, page, pageSize int) ([]Clothes, int64, error)`(大类/小类为空则不筛选) - `ClothesUpdateByUUID(ctx, uuid string, updates map[string]any) error` - `ClothesDeleteByUUID(ctx, uuid string) error`(软删) ### OutfitDao + OutfitItemDao(outfit.go) - `OutfitCreate/GetByUUID/GetByID/GetByUUIDs` - `OutfitListByUserID(ctx, userID int64, isPublic, source int8, page, pageSize int) ([]Outfit, int64, error)`(isPublic/source 为 0 时不筛选) - `OutfitUpdateByUUID(ctx, uuid string, updates map[string]any) error` - `OutfitUpdateIsPublic(ctx, uuid string, isPublic int8) error` - `OutfitDeleteByUUID(ctx, uuid string) error` - `OutfitItemCreateBatch(ctx, items []OutfitItem) error` - `OutfitItemListByOutfitID(ctx, outfitID int64) ([]OutfitItem, error)` - `OutfitItemListByOutfitIDs(ctx, outfitIDs []int64) ([]OutfitItem, error)` - `OutfitItemDeleteByOutfitID(ctx, outfitID int64) error` ### ClosetShareDao + ClosetShareItemDao(closet.go) - `ClosetShareCreate/GetByUUID` - `ClosetShareListByUserID(ctx, userID int64, status int8, page, pageSize int) ([]ClosetShare, int64, error)` - `ClosetShareUpdateStatus(ctx, shareUUID string, status int8) error`(取消/封禁) - `ClosetShareItemCreateBatch/ListByShareUUID/ListByShareUUIDs/DeleteByShareUUID` ### LikeDao(like.go) - `LikeCreate(ctx, *Like) error` - `LikeGetByTargetAndUser(ctx, likeType int8, targetID, userID int64) (*Like, error)` - `LikeUpdateStatus(ctx, id int64, status int8) error` - `LikeCountByTarget(ctx, likeType int8, targetID int64) (int64, error)` - `LikeListUserIDsByTarget(ctx, likeType int8, targetID int64) ([]int64, error)` ### CollectDao(collect.go) - `CollectCreate/GetByTargetAndUser/UpdateStatus` - `CollectCountByTarget(ctx, collectType int8, targetID int64) (int64, error)` - `CollectListTargetIDsByUser(ctx, collectType int8, userID int64) ([]int64, error)` ### GoodsDao(goods.go) - `GoodsGetByCode(ctx, code string) (*Goods, error)` - `GoodsGetByID(ctx, id int64) (*Goods, error)` - `GoodsList(ctx, goodsType int8, page, pageSize int) ([]Goods, int64, error)`(仅 `status = 1` 上架;goodsType 为 0 时不筛选) ### OrderDao + RefundFlowLogDao(order.go) - `OrderCreate/GetByID/GetByOrderNo` - `OrderListByUserID(ctx, userID int64, status int8, page, pageSize int) ([]Order, int64, error)` - `OrderUpdateByOrderNo(ctx, orderNo string, updates map[string]any) error` - `OrderUpdateStatusByOrderNo(ctx, orderNo string, fromStatus, toStatus int8) (bool, error)`(条件更新,RowsAffected 判重) - `RefundFlowLogCreate/GetByRefundNo/GetByOrderNo` - `RefundFlowLogUpdateStatus(ctx, refundNo string, updates map[string]any) error` ### QuotaDao + UserDailyUsageDao + QuotaFlowLogDao(quota.go) - `QuotaListByUserID(ctx, userID int64) ([]Quota, error)`(自 user.go 迁移) - `QuotaGetByUserIDAndType(ctx, userID int64, quotaType int8) (*Quota, error)` - `QuotaCreate(ctx, *Quota) error` - `QuotaIncreaseUsed(ctx, userID int64, quotaType int8, n int64) (bool, error)`(原子扣减,超额返回 false) - `QuotaDecreaseUsed(ctx, userID int64, quotaType int8, n int64) error`(回退) - `UserDailyUsageGetByUserIDAndDate/Create` - `UserDailyUsageIncrementAI/IncrementOutfitShare/IncrementClosetShare` - `QuotaFlowLogCreate/ListByUserID(userID, quotaType, page, pageSize)` ### HoroscopeDao + AI 流水(horoscope.go) - `HoroscopeGetByZodiacAndDate(ctx, zodiac string, statDate int64) (*Horoscope, error)` - `HoroscopeCreate(ctx, *Horoscope) error` - `AIHoroscopeFlowLogGetByRequestID/Create` - `AIRecommendFlowLogGetByRequestID/Create` ### NotificationDao(notification.go) - `NotificationCreate/GetByUUID` - `NotificationListByUserID(ctx, userID int64, isRead int8, page, pageSize int) ([]UserNotification, int64, error)` - `NotificationUpdateReadByUUID(ctx, uuid string) error` - `NotificationCountUnread(ctx, userID int64) (int64, error)` ### 审核日志(auditlog.go) - `ClosetAuditLogCreate/ListByTargetID` - `OutfitAuditLogCreate/ListByTargetID` ### VIPFlowLogDao(vipflowlog.go) - `VIPFlowLogCreate` - `VIPFlowLogListByUserID(ctx, userID int64, page, pageSize int) ([]VIPFlowLog, int64, error)` ## 关键代码结构 ### DAO 构造与查询统一模式 ``` type XxxDao struct{ db *gorm.DB } func NewXxxDao() *XxxDao { return &XxxDao{db: global.MysqlEngine} } func (d *XxxDao) XxxGetByID(ctx context.Context, id int64) (*mysqlmodel.Xxx, error) { var m mysqlmodel.Xxx if err := d.db.WithContext(ctx).First(&m, id).Error; err != nil { return nil, err } return &m, nil } ``` ### 分页 List 模式 ``` func (d *XxxDao) XxxList(ctx context.Context, page, pageSize int) ([]mysqlmodel.Xxx, int64, error) { var list []mysqlmodel.Xxx var total int64 q := d.db.WithContext(ctx).Model(&mysqlmodel.Xxx{}) if err := q.Count(&total).Error; err != nil { return nil, 0, err } err := q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&list).Error return list, total, err } ``` ### 原子扣减 / 条件更新模式 ``` // 配额原子扣减:超额时 RowsAffected 为 0 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 { return false, res.Error } return res.RowsAffected > 0, nil // 订单状态条件流转:防重复回调 res := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}). Where("order_no = ? AND order_status = ?", orderNo, fromStatus). Update("order_status", toStatus) return res.RowsAffected > 0, res.Error ``` ## 实施要点 - **影响范围可控**:仅新增/修改 `internal/mysqldao/`,不触碰 `mysqlmodel`、`dto`、`redisdao` 及其他模块;对外仅移除 user.go 的 `UserQuotaListByUserID`(当前无调用方)。 - **性能**:列表查询采用先 Count 再分页的单次复用 Query 链;批量查询 items 使用 `WHERE id IN (?)` 一次取回,避免 N+1;无额外开销。 - **安全**:所有条件使用 `?` 占位符绑定,不拼接 SQL;软删除交由 gorm 处理。 - **兼容性**:`gorm.ErrRecordNotFound` 原样返回,供 service 层用 `errors.Is` 判断;本次不引入新依赖。