first commit
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
---
|
||||
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` 判断;本次不引入新依赖。
|
||||
Reference in New Issue
Block a user