Files
zt-closet-backend/.codebuddy/plans/补齐_mysqldao_数据访问层_a1ecd74f.md
2026-09-11 01:41:52 +08:00

245 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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` 判断;本次不引入新依赖。