diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index 07c0580..0000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,396 +0,0 @@ -# 后端架构设计规范 - -## 1. 分层架构原则 - -### 层级定义 -``` -Handler (控制器层) → Service (业务逻辑层) → Repository (数据访问层) → Infrastructure (基础设施层) -``` - -### 层级职责 - -| 层级 | 职责 | 依赖关系 | -|------|------|----------| -| Handler | 接收HTTP请求、参数校验、调用Service、返回响应 | 仅依赖Service | -| Service | 实现业务逻辑、事务管理、组合多个Repository | 仅依赖Repository接口 | -| Repository | 数据CRUD操作、数据库事务管理 | 仅依赖Model和GORM | -| Infrastructure | 缓存、消息队列、RPC等外部服务 | 被Service调用 | - -### 关键约束 -- **Handler禁止直接依赖Repository或直接操作数据库** -- **Service禁止直接依赖`gorm.DB`,必须通过Repository间接操作** -- **所有外部依赖通过接口抽象** - ---- - -## 2. Repository 层规范 - -### 2.1 接口 + 私有实现模式 - -所有Repository必须采用接口定义 + 私有实现结构体的模式: - -```go -// ✅ 正确:接口 + 私有实现 -type UserRepository interface { - Create(user *model.User) error - GetByID(id string) (*model.User, error) -} - -type userRepository struct { - db *gorm.DB -} - -func NewUserRepository(db *gorm.DB) UserRepository { - return &userRepository{db: db} -} - -func (r *userRepository) Create(user *model.User) error { - return r.db.Create(user).Error -} -``` - -```go -// ❌ 错误:直接暴露结构体指针 -type UserRepository struct { - db *gorm.DB -} - -func NewUserRepository(db *gorm.DB) *UserRepository { - return &UserRepository{db: db} -} -``` - -### 2.2 Repository方法设计原则 - -- 方法签名中**使用接口类型而非指针**:`func (r *userRepository) GetByID(...) (*model.User, error)` -- **避免在Repository层直接返回指针切片**,使用值切片更安全 -- **事务操作在Repository内部封装**,Service不应直接调用`db.Transaction()` - -### 2.3 新增Repository流程 - -1. 在`repository/`目录下创建`xxx_repo.go`文件 -2. 定义`XxxRepository`接口,包含所有数据访问方法 -3. 创建私有实现结构体`xxxRepository` -4. 创建构造函数`NewXxxRepository(db *gorm.DB) XxxRepository` -5. 在`wire/repository.go`的`RepositorySet`中注册 - ---- - -## 3. Service 层规范 - -### 3.1 接口 + 私有实现模式 - -```go -// ✅ 正确 -type UserService interface { - CreateUser(ctx context.Context, req *dto.CreateUserRequest) (*model.User, error) - GetUserByID(ctx context.Context, id string) (*model.User, error) -} - -type userService struct { - userRepo repository.UserRepository - // 其他Repo都使用接口类型 -} - -func NewUserService(userRepo repository.UserRepository) UserService { - return &userService{userRepo: userRepo} -} -``` - -### 3.2 Service依赖管理 - -- **所有Repository依赖必须使用接口类型**,禁止使用`*repository.XxxRepository`指针 -- **禁止在Service中直接持有`gorm.DB`**,如有数据库操作必须下沉到Repository -- **如需事务支持**,在Repository中封装事务方法,Service调用封装好的方法 - -### 3.3 Service间依赖 - -- Service之间通过接口依赖,避免循环依赖 -- 如Service A需要调用Service B的方法,确保该方法在接口中定义 - ---- - -## 4. Handler 层规范 - -### 4.1 Handler只依赖Service - -```go -// ✅ 正确 -type UserHandler struct { - userService service.UserService // 仅依赖Service接口 -} - -func NewUserHandler(userService service.UserService) *UserHandler { - return &UserHandler{userService: userService} -} -``` - -```go -// ❌ 错误:直接依赖Repository或Cache -type UserHandler struct { - userRepo *repository.UserRepository // 禁止 - cache cache.Cache // 禁止 -} -``` - -### 4.2 参数和类型分离 - -- **Query参数和Filter类型**应定义在`dto/`包中,而非`repository/`包 -- Handler负责将HTTP参数转换为DTO后传递给Service - ---- - -## 5. DTO/Filter 类型规范 - -### 5.1 放置位置 - -- **所有查询参数、过滤条件、分页参数**定义在`internal/dto/`目录下 -- **禁止在`repository/`包中定义供Handler使用的Filter类型** - -### 5.2 文件组织 - -``` -internal/dto/ -├── filter.go # 通用过滤条件(LogFilter, LoginFilter等) -├── user_converter.go # 用户相关转换 -└── ... -``` - -### 5.3 Filter类型定义示例 - -```go -// internal/dto/filter.go - -type LogFilter struct { - UserID string - Operation string - TargetType string - StartTime string - EndTime string -} - -type PaginationParams struct { - Page int - PageSize int -} -``` - ---- - -## 6. Wire 依赖注入规范 - -### 6.1 Provider函数设计 - -```go -// ✅ 正确:Provider返回接口类型 -func ProvideUserService( - userRepo repository.UserRepository, // 接口类型 - cacheBackend cache.Cache, -) service.UserService { - return service.NewUserService(userRepo, cacheBackend) -} - -// ❌ 错误:返回指针类型 -func ProvideUserService(userRepo *repository.UserRepository) *service.UserService -``` - -### 6.2 NewXxxService构造函数签名 - -- **构造函数参数使用接口类型** -- **返回值使用接口类型** - -```go -// ✅ 正确 -func NewUserService( - userRepo repository.UserRepository, // 接口 - cache cache.Cache, -) UserService { // 返回接口 - return &userService{userRepo: userRepo} -} -``` - -### 6.3 禁止的直接注入 - -- **禁止在Handler的Provider中直接注入Repository和Cache**,应仅注入Service -- **禁止在Service的Provider中直接注入`gorm.DB`** - ---- - -## 7. 事务管理规范 - -### 7.1 事务边界在Repository层 - -```go -// ✅ 正确:事务封装在Repository内部 -func (r *userRepository) CreateUserWithProfile(user *model.User, profile *model.Profile) error { - return r.db.Transaction(func(tx *gorm.DB) error { - if err := tx.Create(user).Error; err != nil { - return err - } - profile.UserID = user.ID - return tx.Create(profile).Error - }) -} -``` - -### 7.2 Service调用事务方法 - -```go -// ✅ Service通过调用Repository的封装方法使用事务 -func (s *userService) CreateUser(ctx context.Context, req *dto.CreateUserRequest) error { - user := &model.User{Name: req.Name} - profile := &model.Profile{Bio: req.Bio} - return s.userRepo.CreateUserWithProfile(user, profile) -} -``` - ---- - -## 8. 常见错误示例 - -### 错误1:Handler直接依赖Repository - -```go -// ❌ 错误 -type PostHandler struct { - postRepo *repository.PostRepository -} - -// ✅ 正确 -type PostHandler struct { - postService service.PostService -} -``` - -### 错误2:Service直接使用gorm.DB - -```go -// ❌ 错误 -type postService struct { - db *gorm.DB -} - -func (s *postService) CreatePost(post *model.Post) error { - return s.db.Create(post).Error -} - -// ✅ 正确:使用Repository -func (s *postService) CreatePost(post *model.Post) error { - return s.postRepo.Create(post) -} -``` - -### 错误3:Repository返回指针类型 - -```go -// ❌ 错误 -func NewUserRepository(db *gorm.DB) *UserRepository { - return &UserRepository{db: db} -} - -// ✅ 正确 -func NewUserRepository(db *gorm.DB) UserRepository { - return &userRepository{db: db} -} -``` - -### 错误4:Filter类型放在repository包 - -```go -// ❌ 错误:repository包不应该被handler直接引用 -package repository -type LogFilter struct { - UserID string -} - -// ✅ 正确:放在dto包 -package dto -type LogFilter struct { - UserID string -} -``` - ---- - -## 9. 新功能开发 Checklist - -开发新功能时,确保满足以下规范: - -- [ ] **Repository定义** - - [ ] 接口 + 私有实现结构体 - - [ ] 构造函数返回接口类型 - - [ ] 方法参数使用值类型而非指针 - - [ ] 在`wire/repository.go`中注册 - -- [ ] **Service定义** - - [ ] 接口 + 私有实现结构体 - - [ ] 所有Repository依赖使用接口类型 - - [ ] 不直接持有`gorm.DB` - - [ ] 事务操作下沉到Repository - -- [ ] **Handler定义** - - [ ] 仅依赖Service接口 - - [ ] 不直接依赖Repository或Cache - - [ ] Query/Filter参数使用dto包类型 - -- [ ] **Wire Provider** - - [ ] 参数使用接口类型 - - [ ] 返回值使用接口类型 - - [ ] 不直接注入`gorm.DB`到Service - ---- - -## 10. 相关文件位置参考 - -| 类别 | 目录 | -|------|------| -| Handler | `internal/handler/` | -| Service | `internal/service/` | -| Repository | `internal/repository/` | -| DTO/Filter | `internal/dto/` | -| Model | `internal/model/` | -| Wire | `internal/wire/` | -| Cache | `internal/cache/` | - ---- - -## 11. Wire 生成的代码规范 - -### 11.1 禁止直接修改生成的文件 - -以下文件由 Wire 工具自动生成,**禁止直接修改**: - -- `cmd/server/wire_gen.go` -- `cmd/server/wire.go` 中的 Provider 函数签名由 Wire 维护 - -如果需要修改这些文件: -1. **修改源文件**(如 `wire.go` 中的结构) -2. **运行 `wire` 命令**重新生成 - -### 11.2 正确的修改流程 - -```bash -# 1. 修改 wire.go 或 wire/*.go -# 2. 运行 wire 重新生成 -go run -mod=mod github.com/google/wire/cmd/wire ./cmd/server -``` - -### 11.3 Provider 依赖添加 - -新增依赖时,必须同步修改: - -1. `wire/service.go` 中的 Provider 函数 -2. `wire/handler.go` 中的 Handler Provider 函数 -3. `cmd/server/wire.go` 中的 `ProvideRouter` 函数 -4. `wire/repository.go`(如需新增 Repository) -5. 重新运行 `wire ./cmd/server` 生成 `wire_gen.go` - -### 11.4 生成文件识别 - -``` -// Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire -//go:build !wireinject -``` - -带以上标记的文件均为自动生成文件。 diff --git a/cmd/server/wire_gen.go b/cmd/server/wire_gen.go index 18463af..42423ec 100644 --- a/cmd/server/wire_gen.go +++ b/cmd/server/wire_gen.go @@ -143,7 +143,10 @@ func InitializeApp() (*App, error) { setupService := wire.ProvideSetupService(userRepository, roleRepository, casbinService, config) setupHandler := wire.ProvideSetupHandler(setupService) wsHandler := wire.ProvideWSHandler(hub, chatService, groupService, jwtService, callService, userRepository) - router := ProvideRouter(userRepository, userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, pushHandler, systemMessageHandler, groupHandler, stickerHandler, voteHandler, channelHandler, scheduleHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, qrCodeHandler, materialHandler, callHandler, reportHandler, adminReportHandler, verificationHandler, adminVerificationHandler, adminProfileAuditHandler, setupHandler, logService, userActivityService, casbinService, wsHandler) + tradeRepository := repository.NewTradeRepository(db) + tradeService := wire.ProvideTradeService(tradeRepository) + tradeHandler := handler.NewTradeHandler(tradeService) + router := ProvideRouter(userRepository, userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, pushHandler, systemMessageHandler, groupHandler, stickerHandler, voteHandler, channelHandler, scheduleHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, qrCodeHandler, materialHandler, callHandler, reportHandler, adminReportHandler, verificationHandler, adminVerificationHandler, adminProfileAuditHandler, setupHandler, tradeHandler, logService, userActivityService, casbinService, wsHandler) hotRankWorker := wire.ProvideHotRankWorker(config, postRepository, channelRepository, cache) app := NewApp(config, db, router, pushService, hotRankWorker, server, logger) return app, nil @@ -184,6 +187,7 @@ func ProvideRouter( adminVerificationHandler *handler.AdminVerificationHandler, adminProfileAuditHandler *handler.AdminProfileAuditHandler, setupHandler *handler.SetupHandler, + tradeHandler *handler.TradeHandler, logService *service.LogService, activityService service.UserActivityService, casbinService service.CasbinService, @@ -221,6 +225,7 @@ func ProvideRouter( adminVerificationHandler, adminProfileAuditHandler, setupHandler, + tradeHandler, logService, activityService, casbinService, diff --git a/internal/dto/trade_converter.go b/internal/dto/trade_converter.go new file mode 100644 index 0000000..edddb90 --- /dev/null +++ b/internal/dto/trade_converter.go @@ -0,0 +1,101 @@ +package dto + +import ( + "with_you/internal/model" +) + +func ConvertTradeImageToResponse(img *model.TradeImage) TradeImageResponse { + if img == nil { + return TradeImageResponse{} + } + return TradeImageResponse{ + ID: img.ID, + URL: img.URL, + ThumbnailURL: img.ThumbnailURL, + PreviewURL: img.PreviewURL, + PreviewURLLarge: img.PreviewURLLarge, + Width: img.Width, + Height: img.Height, + } +} + +func ConvertTradeImagesToResponse(images []model.TradeImage) []TradeImageResponse { + result := make([]TradeImageResponse, 0, len(images)) + for i := range images { + result = append(result, ConvertTradeImageToResponse(&images[i])) + } + return result +} + +func ConvertTradeItemToResponse(item *model.TradeItem, isFavorited bool) *TradeItemResponse { + if item == nil { + return nil + } + resp := &TradeItemResponse{ + ID: item.ID, + UserID: item.UserID, + TradeType: string(item.TradeType), + Category: string(item.Category), + Title: item.Title, + Content: item.Content, + Price: item.Price, + OriginalPrice: item.OriginalPrice, + Condition: item.Condition, + Location: item.Location, + Status: string(item.Status), + ViewsCount: item.ViewsCount, + FavoritesCount: item.FavoritesCount, + IsFavorited: isFavorited, + Images: ConvertTradeImagesToResponse(item.Images), + CreatedAt: item.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: item.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + if len(item.Segments) > 0 { + resp.Segments = item.Segments + } + if item.User != nil { + resp.Author = ConvertUserToResponse(item.User) + } + return resp +} + +func ConvertTradeItemsToResponse(items []*model.TradeItem, favoriteMap map[string]bool) []*TradeItemResponse { + result := make([]*TradeItemResponse, 0, len(items)) + for _, item := range items { + isFav := favoriteMap[item.ID] + result = append(result, ConvertTradeItemToResponse(item, isFav)) + } + return result +} + +func ConvertTradeItemToDetailResponse(item *model.TradeItem, isFavorited bool) *TradeItemDetailResponse { + if item == nil { + return nil + } + resp := &TradeItemDetailResponse{ + ID: item.ID, + UserID: item.UserID, + TradeType: string(item.TradeType), + Category: string(item.Category), + Title: item.Title, + Content: item.Content, + Price: item.Price, + OriginalPrice: item.OriginalPrice, + Condition: item.Condition, + Location: item.Location, + Status: string(item.Status), + ViewsCount: item.ViewsCount, + FavoritesCount: item.FavoritesCount, + IsFavorited: isFavorited, + Images: ConvertTradeImagesToResponse(item.Images), + CreatedAt: item.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: item.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + if len(item.Segments) > 0 { + resp.Segments = item.Segments + } + if item.User != nil { + resp.Author = ConvertUserToResponse(item.User) + } + return resp +} \ No newline at end of file diff --git a/internal/dto/trade_dto.go b/internal/dto/trade_dto.go new file mode 100644 index 0000000..1de5f61 --- /dev/null +++ b/internal/dto/trade_dto.go @@ -0,0 +1,100 @@ +package dto + +import ( + "with_you/internal/model" +) + +type TradeImageResponse struct { + ID string `json:"id"` + URL string `json:"url"` + ThumbnailURL string `json:"thumbnail_url"` + PreviewURL string `json:"preview_url"` + PreviewURLLarge string `json:"preview_url_large"` + Width int `json:"width"` + Height int `json:"height"` +} + +type TradeItemResponse struct { + ID string `json:"id"` + UserID string `json:"user_id"` + TradeType string `json:"trade_type"` + Category string `json:"category"` + Title string `json:"title"` + Content string `json:"content"` + Segments model.MessageSegments `json:"segments,omitempty"` + Images []TradeImageResponse `json:"images"` + Price *float64 `json:"price,omitempty"` + OriginalPrice *float64 `json:"original_price,omitempty"` + Condition string `json:"condition,omitempty"` + Location string `json:"location,omitempty"` + Status string `json:"status"` + ViewsCount int `json:"views_count"` + FavoritesCount int `json:"favorites_count"` + IsFavorited bool `json:"is_favorited"` + Author *UserResponse `json:"author"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type TradeItemDetailResponse struct { + ID string `json:"id"` + UserID string `json:"user_id"` + TradeType string `json:"trade_type"` + Category string `json:"category"` + Title string `json:"title"` + Content string `json:"content"` + Segments model.MessageSegments `json:"segments,omitempty"` + Images []TradeImageResponse `json:"images"` + Price *float64 `json:"price,omitempty"` + OriginalPrice *float64 `json:"original_price,omitempty"` + Condition string `json:"condition,omitempty"` + Location string `json:"location,omitempty"` + Status string `json:"status"` + ViewsCount int `json:"views_count"` + FavoritesCount int `json:"favorites_count"` + IsFavorited bool `json:"is_favorited"` + Author *UserResponse `json:"author"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type CreateTradeItemRequest struct { + TradeType string `json:"trade_type" binding:"required,oneof=sell buy"` + Category string `json:"category" binding:"required,oneof=digital book clothing daily cosmetics sport ticket other"` + Title string `json:"title" binding:"required,max=100"` + Content string `json:"content"` + Segments model.MessageSegments `json:"segments"` + Images []string `json:"images"` + Price *float64 `json:"price"` + OriginalPrice *float64 `json:"original_price"` + Condition string `json:"condition" binding:"omitempty,oneof=brand_new like_new lightly_used well_used"` + Location string `json:"location" binding:"omitempty,max=100"` +} + +type UpdateTradeItemRequest struct { + TradeType *string `json:"trade_type" binding:"omitempty,oneof=sell buy"` + Category *string `json:"category" binding:"omitempty,oneof=digital book clothing daily cosmetics sport ticket other"` + Title *string `json:"title" binding:"omitempty,max=100"` + Content *string `json:"content"` + Segments model.MessageSegments `json:"segments"` + Images []string `json:"images"` + Price *float64 `json:"price"` + OriginalPrice *float64 `json:"original_price"` + Condition *string `json:"condition" binding:"omitempty,oneof=brand_new like_new lightly_used well_used"` + Location *string `json:"location" binding:"omitempty,max=100"` +} + +type UpdateTradeItemStatusRequest struct { + Status string `json:"status" binding:"required,oneof=active sold bought offline"` +} + +type TradeCursorPageResponse struct { + Items []*TradeItemResponse `json:"list"` + NextCursor string `json:"next_cursor,omitempty"` + PrevCursor string `json:"prev_cursor,omitempty"` + HasMore bool `json:"has_more"` +} + +type TradeFavoriteResponse struct { + Favorited bool `json:"favorited"` +} \ No newline at end of file diff --git a/internal/handler/trade_handler.go b/internal/handler/trade_handler.go new file mode 100644 index 0000000..00185ad --- /dev/null +++ b/internal/handler/trade_handler.go @@ -0,0 +1,220 @@ +package handler + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + "with_you/internal/dto" + "with_you/internal/model" + "with_you/internal/pkg/response" + "with_you/internal/service" +) + +type TradeHandler struct { + tradeService service.TradeService +} + +func NewTradeHandler(tradeService service.TradeService) *TradeHandler { + return &TradeHandler{tradeService: tradeService} +} + +func (h *TradeHandler) List(c *gin.Context) { + currentUserID := c.GetString("user_id") + if currentUserID == "" { + currentUserID = "" + } + + var tradeType *model.TradeType + if v := c.Query("trade_type"); v != "" { + t := model.TradeType(v) + tradeType = &t + } + + var category *model.TradeCategory + if v := c.Query("category"); v != "" { + cat := model.TradeCategory(v) + category = &cat + } + + cursorStr := c.Query("cursor") + pageSize := 20 + if v := c.Query("page_size"); v != "" { + if ps, err := strconv.Atoi(v); err == nil && ps > 0 && ps <= 100 { + pageSize = ps + } + } + + keyword := c.Query("keyword") + + var currentUserIDPtr *string + if currentUserID != "" { + currentUserIDPtr = ¤tUserID + } + + var resp *dto.TradeCursorPageResponse + var err error + if keyword != "" { + resp, err = h.tradeService.Search(c.Request.Context(), keyword, tradeType, category, cursorStr, pageSize, currentUserIDPtr) + } else { + resp, err = h.tradeService.List(c.Request.Context(), tradeType, category, cursorStr, pageSize, currentUserIDPtr) + } + if err != nil { + response.HandleError(c, err, "获取交易列表失败") + return + } + response.Success(c, resp) +} + +func (h *TradeHandler) GetByID(c *gin.Context) { + id := c.Param("id") + currentUserID := c.GetString("user_id") + + var currentUserIDPtr *string + if currentUserID != "" { + currentUserIDPtr = ¤tUserID + } + + resp, err := h.tradeService.GetByID(c.Request.Context(), id, currentUserIDPtr) + if err != nil { + response.HandleError(c, err, "获取交易详情失败") + return + } + response.Success(c, resp) +} + +func (h *TradeHandler) Create(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + response.Unauthorized(c, "请先登录") + return + } + + var req dto.CreateTradeItemRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + item, err := h.tradeService.Create(c.Request.Context(), userID, req) + if err != nil { + response.HandleError(c, err, "创建交易失败") + return + } + + var currentUserIDPtr *string + if userID != "" { + currentUserIDPtr = &userID + } + detail, err := h.tradeService.GetByID(c.Request.Context(), item.ID, currentUserIDPtr) + if err != nil { + response.Success(c, item) + return + } + response.Success(c, detail) +} + +func (h *TradeHandler) Update(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + response.Unauthorized(c, "请先登录") + return + } + + id := c.Param("id") + + var req dto.UpdateTradeItemRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + if err := h.tradeService.Update(c.Request.Context(), userID, id, req); err != nil { + response.HandleError(c, err, "更新交易失败") + return + } + + var currentUserIDPtr *string + if userID != "" { + currentUserIDPtr = &userID + } + detail, err := h.tradeService.GetByID(c.Request.Context(), id, currentUserIDPtr) + if err != nil { + response.Success(c, nil) + return + } + response.Success(c, detail) +} + +func (h *TradeHandler) Delete(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + response.Unauthorized(c, "请先登录") + return + } + + id := c.Param("id") + if err := h.tradeService.Delete(c.Request.Context(), userID, id); err != nil { + response.HandleError(c, err, "删除交易失败") + return + } + response.Success(c, nil) +} + +func (h *TradeHandler) UpdateStatus(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + response.Unauthorized(c, "请先登录") + return + } + + id := c.Param("id") + + var req dto.UpdateTradeItemStatusRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + if err := h.tradeService.UpdateStatus(c.Request.Context(), userID, id, model.TradeItemStatus(req.Status)); err != nil { + response.HandleError(c, err, "更新交易状态失败") + return + } + response.Success(c, nil) +} + +func (h *TradeHandler) RecordView(c *gin.Context) { + id := c.Param("id") + _ = h.tradeService.RecordView(c.Request.Context(), id) + response.Success(c, nil) +} + +func (h *TradeHandler) Favorite(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + response.Unauthorized(c, "请先登录") + return + } + + id := c.Param("id") + if err := h.tradeService.Favorite(c.Request.Context(), userID, id); err != nil { + response.HandleError(c, err, "收藏失败") + return + } + response.Success(c, dto.TradeFavoriteResponse{Favorited: true}) +} + +func (h *TradeHandler) Unfavorite(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + response.Unauthorized(c, "请先登录") + return + } + + id := c.Param("id") + if err := h.tradeService.Unfavorite(c.Request.Context(), userID, id); err != nil { + response.HandleError(c, err, "取消收藏失败") + return + } + response.Success(c, dto.TradeFavoriteResponse{Favorited: false}) +} \ No newline at end of file diff --git a/internal/model/init.go b/internal/model/init.go index 3c9f3a3..753fc0c 100644 --- a/internal/model/init.go +++ b/internal/model/init.go @@ -179,6 +179,11 @@ func autoMigrate(db *gorm.DB) error { // 帖子内链引用关系 &PostReference{}, + + // 二手交易相关 + &TradeItem{}, + &TradeImage{}, + &TradeFavorite{}, ) if err != nil { return err diff --git a/internal/model/trade.go b/internal/model/trade.go new file mode 100644 index 0000000..fb78473 --- /dev/null +++ b/internal/model/trade.go @@ -0,0 +1,112 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type TradeType string + +const ( + TradeTypeSell TradeType = "sell" + TradeTypeBuy TradeType = "buy" +) + +type TradeCategory string + +const ( + TradeCategoryDigital TradeCategory = "digital" + TradeCategoryBook TradeCategory = "book" + TradeCategoryClothing TradeCategory = "clothing" + TradeCategoryDaily TradeCategory = "daily" + TradeCategoryCosmetics TradeCategory = "cosmetics" + TradeCategorySport TradeCategory = "sport" + TradeCategoryTicket TradeCategory = "ticket" + TradeCategoryOther TradeCategory = "other" +) + +type TradeItemStatus string + +const ( + TradeItemActive TradeItemStatus = "active" + TradeItemSold TradeItemStatus = "sold" + TradeItemBought TradeItemStatus = "bought" + TradeItemOffline TradeItemStatus = "offline" +) + +type TradeItem struct { + ID string `json:"id" gorm:"type:varchar(36);primaryKey"` + UserID string `json:"user_id" gorm:"type:varchar(36);index;not null"` + TradeType TradeType `json:"trade_type" gorm:"type:varchar(10);not null;index"` + Category TradeCategory `json:"category" gorm:"type:varchar(20);not null;index"` + Title string `json:"title" gorm:"type:varchar(100);not null"` + Content string `json:"content" gorm:"type:text"` + Segments MessageSegments `json:"segments,omitempty" gorm:"type:text"` + User *User `json:"user,omitempty" gorm:"foreignKey:UserID"` + Images []TradeImage `json:"images" gorm:"foreignKey:TradeItemID"` + Price *float64 `json:"price,omitempty" gorm:"type:decimal(10,2)"` + OriginalPrice *float64 `json:"original_price,omitempty" gorm:"type:decimal(10,2)"` + Condition string `json:"condition,omitempty" gorm:"type:varchar(20)"` + Location string `json:"location,omitempty" gorm:"type:varchar(100)"` + Status TradeItemStatus `json:"status" gorm:"type:varchar(10);not null;default:'active';index"` + ViewsCount int `json:"views_count" gorm:"default:0"` + FavoritesCount int `json:"favorites_count" gorm:"default:0"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime:false"` +} + +func (t *TradeItem) BeforeCreate(tx *gorm.DB) error { + if t.ID == "" { + t.ID = uuid.New().String() + } + return nil +} + +func (TradeItem) TableName() string { + return "trade_items" +} + +type TradeImage struct { + ID string `json:"id" gorm:"type:varchar(36);primaryKey"` + TradeItemID string `json:"trade_item_id" gorm:"type:varchar(36);not null;index"` + URL string `json:"url" gorm:"type:text;not null"` + ThumbnailURL string `json:"thumbnail_url" gorm:"type:text"` + PreviewURL string `json:"preview_url" gorm:"type:text"` + PreviewURLLarge string `json:"preview_url_large" gorm:"type:text"` + Width int `json:"width" gorm:"default:0"` + Height int `json:"height" gorm:"default:0"` + SortOrder int `json:"sort_order" gorm:"default:0"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` +} + +func (ti *TradeImage) BeforeCreate(tx *gorm.DB) error { + if ti.ID == "" { + ti.ID = uuid.New().String() + } + return nil +} + +func (TradeImage) TableName() string { + return "trade_images" +} + +type TradeFavorite struct { + ID string `json:"id" gorm:"type:varchar(36);primaryKey"` + TradeItemID string `json:"trade_item_id" gorm:"type:varchar(36);not null;index;uniqueIndex:idx_trade_fav_item_user,priority:1"` + UserID string `json:"user_id" gorm:"type:varchar(36);not null;index;uniqueIndex:idx_trade_fav_item_user,priority:2"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` +} + +func (f *TradeFavorite) BeforeCreate(tx *gorm.DB) error { + if f.ID == "" { + f.ID = uuid.New().String() + } + return nil +} + +func (TradeFavorite) TableName() string { + return "trade_favorites" +} diff --git a/internal/repository/trade_repo.go b/internal/repository/trade_repo.go new file mode 100644 index 0000000..f87e0a6 --- /dev/null +++ b/internal/repository/trade_repo.go @@ -0,0 +1,325 @@ +package repository + +import ( + "context" + + "with_you/internal/model" + "with_you/internal/pkg/cursor" + + "gorm.io/gorm" +) + +type TradeRepository interface { + Create(ctx context.Context, item *model.TradeItem, imageUrls []string) error + GetByID(ctx context.Context, id string) (*model.TradeItem, error) + Update(ctx context.Context, item *model.TradeItem) error + UpdateWithImages(ctx context.Context, item *model.TradeItem, imageUrls *[]string) error + Delete(ctx context.Context, id string) error + UpdateStatus(ctx context.Context, id string, status model.TradeItemStatus) error + ListByCursor(ctx context.Context, tradeType *model.TradeType, category *model.TradeCategory, status *model.TradeItemStatus, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.TradeItem], error) + SearchByCursor(ctx context.Context, keyword string, tradeType *model.TradeType, category *model.TradeCategory, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.TradeItem], error) + ListByUserByCursor(ctx context.Context, userID string, status *model.TradeItemStatus, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.TradeItem], error) + IncrementViews(ctx context.Context, id string) error + IncrementFavorites(ctx context.Context, id string) error + DecrementFavorites(ctx context.Context, id string) error + + CreateFavorite(ctx context.Context, tradeItemID, userID string) error + DeleteFavorite(ctx context.Context, tradeItemID, userID string) error + IsFavorited(ctx context.Context, tradeItemID, userID string) (bool, error) + IsFavoritedBatch(ctx context.Context, tradeItemIDs []string, userID string) (map[string]bool, error) +} + +type tradeRepository struct { + db *gorm.DB +} + +func NewTradeRepository(db *gorm.DB) TradeRepository { + return &tradeRepository{db: db} +} + +func (r *tradeRepository) getDB(ctx context.Context) *gorm.DB { + if tx := GetTxFromContext(ctx); tx != nil { + return tx + } + return r.db +} + +func (r *tradeRepository) Create(ctx context.Context, item *model.TradeItem, imageUrls []string) error { + db := r.getDB(ctx).WithContext(ctx) + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(item).Error; err != nil { + return err + } + for i, url := range imageUrls { + img := model.TradeImage{ + TradeItemID: item.ID, + URL: url, + SortOrder: i, + } + if err := tx.Create(&img).Error; err != nil { + return err + } + } + return nil + }) +} + +func (r *tradeRepository) GetByID(ctx context.Context, id string) (*model.TradeItem, error) { + db := r.getDB(ctx).WithContext(ctx) + var item model.TradeItem + if err := db.Preload("User").Preload("Images", func(db *gorm.DB) *gorm.DB { + return db.Order("sort_order ASC") + }).First(&item, "id = ?", id).Error; err != nil { + return nil, err + } + return &item, nil +} + +func (r *tradeRepository) Update(ctx context.Context, item *model.TradeItem) error { + db := r.getDB(ctx).WithContext(ctx) + return db.Save(item).Error +} + +func (r *tradeRepository) UpdateWithImages(ctx context.Context, item *model.TradeItem, imageUrls *[]string) error { + db := r.getDB(ctx).WithContext(ctx) + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.Save(item).Error; err != nil { + return err + } + if imageUrls != nil { + if err := tx.Where("trade_item_id = ?", item.ID).Delete(&model.TradeImage{}).Error; err != nil { + return err + } + for i, url := range *imageUrls { + img := model.TradeImage{ + TradeItemID: item.ID, + URL: url, + SortOrder: i, + } + if err := tx.Create(&img).Error; err != nil { + return err + } + } + } + return nil + }) +} + +func (r *tradeRepository) Delete(ctx context.Context, id string) error { + db := r.getDB(ctx).WithContext(ctx) + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("trade_item_id = ?", id).Delete(&model.TradeImage{}).Error; err != nil { + return err + } + if err := tx.Where("trade_item_id = ?", id).Delete(&model.TradeFavorite{}).Error; err != nil { + return err + } + return tx.Delete(&model.TradeItem{}, "id = ?", id).Error + }) +} + +func (r *tradeRepository) UpdateStatus(ctx context.Context, id string, status model.TradeItemStatus) error { + db := r.getDB(ctx).WithContext(ctx) + return db.Model(&model.TradeItem{}).Where("id = ?", id).Update("status", status).Error +} + +func (r *tradeRepository) ListByCursor(ctx context.Context, tradeType *model.TradeType, category *model.TradeCategory, status *model.TradeItemStatus, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.TradeItem], error) { + db := r.getDB(ctx).WithContext(ctx) + query := db.Model(&model.TradeItem{}) + + if tradeType != nil { + query = query.Where("trade_type = ?", *tradeType) + } + if category != nil { + query = query.Where("category = ?", *category) + } + defaultStatus := model.TradeItemActive + if status != nil { + defaultStatus = *status + } + query = query.Where("status = ?", defaultStatus) + + builder := cursor.NewBuilder(query, cursor.SortByCreatedAtDesc). + WithCursor(req.Cursor, req.Direction). + WithPageSize(req.PageSize) + + if builder.Error() != nil { + return cursor.NewCursorPageResult([]*model.TradeItem{}, "", "", false), nil + } + + var items []*model.TradeItem + q := builder.Build().Preload("User").Preload("Images", func(db *gorm.DB) *gorm.DB { + return db.Order("sort_order ASC") + }) + if err := q.Find(&items).Error; err != nil { + return nil, err + } + + pageSize := builder.GetPageSize() + hasMore := cursor.HasMore(len(items), pageSize) + if hasMore { + items = items[:pageSize] + } + + var nextCursor, prevCursor string + if len(items) > 0 { + if hasMore { + last := items[len(items)-1] + nextCursor = cursor.NewCursor(cursor.FormatTime(last.CreatedAt), last.ID, cursor.SortByCreatedAtDesc).Encode() + } + first := items[0] + prevCursor = cursor.NewCursor(cursor.FormatTime(first.CreatedAt), first.ID, cursor.SortByCreatedAtDesc).Encode() + } + + return cursor.NewCursorPageResult(items, nextCursor, prevCursor, hasMore), nil +} + +func (r *tradeRepository) SearchByCursor(ctx context.Context, keyword string, tradeType *model.TradeType, category *model.TradeCategory, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.TradeItem], error) { + db := r.getDB(ctx).WithContext(ctx) + query := db.Model(&model.TradeItem{}).Where("status = ?", model.TradeItemActive) + + if tradeType != nil { + query = query.Where("trade_type = ?", *tradeType) + } + if category != nil { + query = query.Where("category = ?", *category) + } + if keyword != "" { + searchPattern := "%" + keyword + "%" + query = query.Where("title LIKE ? OR content LIKE ?", searchPattern, searchPattern) + } + + builder := cursor.NewBuilder(query, cursor.SortByCreatedAtDesc). + WithCursor(req.Cursor, req.Direction). + WithPageSize(req.PageSize) + + if builder.Error() != nil { + return cursor.NewCursorPageResult([]*model.TradeItem{}, "", "", false), nil + } + + var items []*model.TradeItem + q := builder.Build().Preload("User").Preload("Images", func(db *gorm.DB) *gorm.DB { + return db.Order("sort_order ASC") + }) + if err := q.Find(&items).Error; err != nil { + return nil, err + } + + pageSize := builder.GetPageSize() + hasMore := cursor.HasMore(len(items), pageSize) + if hasMore { + items = items[:pageSize] + } + + var nextCursor, prevCursor string + if len(items) > 0 { + if hasMore { + last := items[len(items)-1] + nextCursor = cursor.NewCursor(cursor.FormatTime(last.CreatedAt), last.ID, cursor.SortByCreatedAtDesc).Encode() + } + first := items[0] + prevCursor = cursor.NewCursor(cursor.FormatTime(first.CreatedAt), first.ID, cursor.SortByCreatedAtDesc).Encode() + } + + return cursor.NewCursorPageResult(items, nextCursor, prevCursor, hasMore), nil +} + +func (r *tradeRepository) ListByUserByCursor(ctx context.Context, userID string, status *model.TradeItemStatus, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.TradeItem], error) { + db := r.getDB(ctx).WithContext(ctx) + query := db.Model(&model.TradeItem{}).Where("user_id = ?", userID) + + if status != nil { + query = query.Where("status = ?", *status) + } + + builder := cursor.NewBuilder(query, cursor.SortByCreatedAtDesc). + WithCursor(req.Cursor, req.Direction). + WithPageSize(req.PageSize) + + if builder.Error() != nil { + return cursor.NewCursorPageResult([]*model.TradeItem{}, "", "", false), nil + } + + var items []*model.TradeItem + q := builder.Build().Preload("User").Preload("Images", func(db *gorm.DB) *gorm.DB { + return db.Order("sort_order ASC") + }) + if err := q.Find(&items).Error; err != nil { + return nil, err + } + + pageSize := builder.GetPageSize() + hasMore := cursor.HasMore(len(items), pageSize) + if hasMore { + items = items[:pageSize] + } + + var nextCursor, prevCursor string + if len(items) > 0 { + if hasMore { + last := items[len(items)-1] + nextCursor = cursor.NewCursor(cursor.FormatTime(last.CreatedAt), last.ID, cursor.SortByCreatedAtDesc).Encode() + } + first := items[0] + prevCursor = cursor.NewCursor(cursor.FormatTime(first.CreatedAt), first.ID, cursor.SortByCreatedAtDesc).Encode() + } + + return cursor.NewCursorPageResult(items, nextCursor, prevCursor, hasMore), nil +} + +func (r *tradeRepository) IncrementViews(ctx context.Context, id string) error { + db := r.getDB(ctx).WithContext(ctx) + return db.Model(&model.TradeItem{}).Where("id = ?", id).UpdateColumn("views_count", gorm.Expr("views_count + 1")).Error +} + +func (r *tradeRepository) IncrementFavorites(ctx context.Context, id string) error { + db := r.getDB(ctx).WithContext(ctx) + return db.Model(&model.TradeItem{}).Where("id = ?", id).UpdateColumn("favorites_count", gorm.Expr("favorites_count + 1")).Error +} + +func (r *tradeRepository) DecrementFavorites(ctx context.Context, id string) error { + db := r.getDB(ctx).WithContext(ctx) + return db.Model(&model.TradeItem{}).Where("id = ? AND favorites_count > 0", id).UpdateColumn("favorites_count", gorm.Expr("favorites_count - 1")).Error +} + +func (r *tradeRepository) CreateFavorite(ctx context.Context, tradeItemID, userID string) error { + db := r.getDB(ctx).WithContext(ctx) + fav := model.TradeFavorite{ + TradeItemID: tradeItemID, + UserID: userID, + } + return db.Create(&fav).Error +} + +func (r *tradeRepository) DeleteFavorite(ctx context.Context, tradeItemID, userID string) error { + db := r.getDB(ctx).WithContext(ctx) + return db.Where("trade_item_id = ? AND user_id = ?", tradeItemID, userID).Delete(&model.TradeFavorite{}).Error +} + +func (r *tradeRepository) IsFavorited(ctx context.Context, tradeItemID, userID string) (bool, error) { + db := r.getDB(ctx).WithContext(ctx) + var count int64 + if err := db.Model(&model.TradeFavorite{}).Where("trade_item_id = ? AND user_id = ?", tradeItemID, userID).Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +func (r *tradeRepository) IsFavoritedBatch(ctx context.Context, tradeItemIDs []string, userID string) (map[string]bool, error) { + db := r.getDB(ctx).WithContext(ctx) + result := make(map[string]bool, len(tradeItemIDs)) + if len(tradeItemIDs) == 0 { + return result, nil + } + for _, id := range tradeItemIDs { + result[id] = false + } + var favorites []model.TradeFavorite + if err := db.Where("trade_item_id IN ? AND user_id = ?", tradeItemIDs, userID).Find(&favorites).Error; err != nil { + return nil, err + } + for _, fav := range favorites { + result[fav.TradeItemID] = true + } + return result, nil +} \ No newline at end of file diff --git a/internal/router/router.go b/internal/router/router.go index 2dc9bd7..b78db3f 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -43,6 +43,7 @@ type Router struct { adminVerificationHandler *handler.AdminVerificationHandler adminProfileAuditHandler *handler.AdminProfileAuditHandler setupHandler *handler.SetupHandler + tradeHandler *handler.TradeHandler wsHandler *handler.WSHandler logService *service.LogService jwtService *service.JWTService @@ -81,8 +82,9 @@ func New( verificationHandler *handler.VerificationHandler, adminVerificationHandler *handler.AdminVerificationHandler, adminProfileAuditHandler *handler.AdminProfileAuditHandler, - setupHandler *handler.SetupHandler, - logService *service.LogService, + setupHandler *handler.SetupHandler, + tradeHandler *handler.TradeHandler, + logService *service.LogService, activityService service.UserActivityService, casbinService service.CasbinService, wsHandler *handler.WSHandler, @@ -124,6 +126,7 @@ func New( adminVerificationHandler: adminVerificationHandler, adminProfileAuditHandler: adminProfileAuditHandler, setupHandler: setupHandler, + tradeHandler: tradeHandler, logService: logService, jwtService: jwtService, casbinService: casbinService, @@ -311,6 +314,22 @@ func (r *Router) setupRoutes() { } } + // 二手交易路由 + if r.tradeHandler != nil { + trade := v1.Group("/trade") + { + trade.GET("", middleware.OptionalAuth(r.jwtService), r.tradeHandler.List) + trade.GET("/:id", middleware.OptionalAuth(r.jwtService), r.tradeHandler.GetByID) + trade.POST("", authMiddleware, middleware.RequireVerified(r.userRepo), r.tradeHandler.Create) + trade.PUT("/:id", authMiddleware, middleware.RequireVerified(r.userRepo), r.tradeHandler.Update) + trade.DELETE("/:id", authMiddleware, middleware.RequireVerified(r.userRepo), r.tradeHandler.Delete) + trade.PUT("/:id/status", authMiddleware, middleware.RequireVerified(r.userRepo), r.tradeHandler.UpdateStatus) + trade.POST("/:id/view", middleware.OptionalAuth(r.jwtService), r.tradeHandler.RecordView) + trade.POST("/:id/favorite", authMiddleware, middleware.RequireVerified(r.userRepo), r.tradeHandler.Favorite) + trade.DELETE("/:id/favorite", authMiddleware, middleware.RequireVerified(r.userRepo), r.tradeHandler.Unfavorite) + } + } + // 课表路由 if r.scheduleHandler != nil { schedule := v1.Group("/schedule") diff --git a/internal/service/trade_service.go b/internal/service/trade_service.go new file mode 100644 index 0000000..2a7019d --- /dev/null +++ b/internal/service/trade_service.go @@ -0,0 +1,230 @@ +package service + +import ( + "context" + "errors" + "fmt" + "time" + + "with_you/internal/dto" + "with_you/internal/model" + "with_you/internal/pkg/cursor" + "with_you/internal/repository" + + "gorm.io/gorm" +) + +type TradeService interface { + Create(ctx context.Context, userID string, req dto.CreateTradeItemRequest) (*model.TradeItem, error) + GetByID(ctx context.Context, id string, currentUserID *string) (*dto.TradeItemDetailResponse, error) + Update(ctx context.Context, userID string, id string, req dto.UpdateTradeItemRequest) error + Delete(ctx context.Context, userID string, id string) error + UpdateStatus(ctx context.Context, userID string, id string, status model.TradeItemStatus) error + List(ctx context.Context, tradeType *model.TradeType, category *model.TradeCategory, cursorStr string, pageSize int, currentUserID *string) (*dto.TradeCursorPageResponse, error) + Search(ctx context.Context, keyword string, tradeType *model.TradeType, category *model.TradeCategory, cursorStr string, pageSize int, currentUserID *string) (*dto.TradeCursorPageResponse, error) + RecordView(ctx context.Context, id string) error + Favorite(ctx context.Context, userID string, id string) error + Unfavorite(ctx context.Context, userID string, id string) error +} + +type tradeService struct { + tradeRepo repository.TradeRepository +} + +func NewTradeService(tradeRepo repository.TradeRepository) TradeService { + return &tradeService{tradeRepo: tradeRepo} +} + +func (s *tradeService) Create(ctx context.Context, userID string, req dto.CreateTradeItemRequest) (*model.TradeItem, error) { + item := &model.TradeItem{ + UserID: userID, + TradeType: model.TradeType(req.TradeType), + Category: model.TradeCategory(req.Category), + Title: req.Title, + Content: req.Content, + Segments: req.Segments, + Price: req.Price, + OriginalPrice: req.OriginalPrice, + Condition: req.Condition, + Location: req.Location, + Status: model.TradeItemActive, + } + if item.Content == "" { + item.Content = item.Title + } + + images := req.Images + if images == nil { + images = []string{} + } + if err := s.tradeRepo.Create(ctx, item, images); err != nil { + return nil, fmt.Errorf("create trade item: %w", err) + } + return item, nil +} + +func (s *tradeService) GetByID(ctx context.Context, id string, currentUserID *string) (*dto.TradeItemDetailResponse, error) { + item, err := s.tradeRepo.GetByID(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("trade item not found") + } + return nil, err + } + + isFavorited := false + if currentUserID != nil { + fav, err := s.tradeRepo.IsFavorited(ctx, id, *currentUserID) + if err == nil { + isFavorited = fav + } + } + + _ = s.tradeRepo.IncrementViews(ctx, id) + + return dto.ConvertTradeItemToDetailResponse(item, isFavorited), nil +} + +func (s *tradeService) Update(ctx context.Context, userID string, id string, req dto.UpdateTradeItemRequest) error { + item, err := s.tradeRepo.GetByID(ctx, id) + if err != nil { + return fmt.Errorf("trade item not found") + } + if item.UserID != userID { + return fmt.Errorf("not authorized") + } + + if req.TradeType != nil { + item.TradeType = model.TradeType(*req.TradeType) + } + if req.Category != nil { + item.Category = model.TradeCategory(*req.Category) + } + if req.Title != nil { + item.Title = *req.Title + } + if req.Content != nil { + item.Content = *req.Content + } + if req.Segments != nil { + item.Segments = req.Segments + } + if req.Price != nil { + item.Price = req.Price + } + if req.OriginalPrice != nil { + item.OriginalPrice = req.OriginalPrice + } + if req.Condition != nil { + item.Condition = *req.Condition + } + if req.Location != nil { + item.Location = *req.Location + } + item.UpdatedAt = time.Now() + + if req.Images != nil { + return s.tradeRepo.UpdateWithImages(ctx, item, &req.Images) + } + return s.tradeRepo.Update(ctx, item) +} + +func (s *tradeService) Delete(ctx context.Context, userID string, id string) error { + item, err := s.tradeRepo.GetByID(ctx, id) + if err != nil { + return fmt.Errorf("trade item not found") + } + if item.UserID != userID { + return fmt.Errorf("not authorized") + } + return s.tradeRepo.Delete(ctx, id) +} + +func (s *tradeService) UpdateStatus(ctx context.Context, userID string, id string, status model.TradeItemStatus) error { + item, err := s.tradeRepo.GetByID(ctx, id) + if err != nil { + return fmt.Errorf("trade item not found") + } + if item.UserID != userID { + return fmt.Errorf("not authorized") + } + return s.tradeRepo.UpdateStatus(ctx, id, status) +} + +func (s *tradeService) List(ctx context.Context, tradeType *model.TradeType, category *model.TradeCategory, cursorStr string, pageSize int, currentUserID *string) (*dto.TradeCursorPageResponse, error) { + req := cursor.NewPageRequest(cursorStr, cursor.Forward, pageSize) + result, err := s.tradeRepo.ListByCursor(ctx, tradeType, category, nil, req) + if err != nil { + return nil, err + } + return s.buildCursorResponse(ctx, result, currentUserID) +} + +func (s *tradeService) Search(ctx context.Context, keyword string, tradeType *model.TradeType, category *model.TradeCategory, cursorStr string, pageSize int, currentUserID *string) (*dto.TradeCursorPageResponse, error) { + req := cursor.NewPageRequest(cursorStr, cursor.Forward, pageSize) + result, err := s.tradeRepo.SearchByCursor(ctx, keyword, tradeType, category, req) + if err != nil { + return nil, err + } + return s.buildCursorResponse(ctx, result, currentUserID) +} + +func (s *tradeService) buildCursorResponse(ctx context.Context, result *cursor.CursorPageResult[*model.TradeItem], currentUserID *string) (*dto.TradeCursorPageResponse, error) { + favoriteMap := make(map[string]bool) + if currentUserID != nil && len(result.Items) > 0 { + ids := make([]string, 0, len(result.Items)) + for _, item := range result.Items { + ids = append(ids, item.ID) + } + fm, err := s.tradeRepo.IsFavoritedBatch(ctx, ids, *currentUserID) + if err == nil { + favoriteMap = fm + } + } + + resp := &dto.TradeCursorPageResponse{ + Items: dto.ConvertTradeItemsToResponse(result.Items, favoriteMap), + NextCursor: result.NextCursor, + PrevCursor: result.PrevCursor, + HasMore: result.HasMore, + } + return resp, nil +} + +func (s *tradeService) RecordView(ctx context.Context, id string) error { + return s.tradeRepo.IncrementViews(ctx, id) +} + +func (s *tradeService) Favorite(ctx context.Context, userID string, id string) error { + _, err := s.tradeRepo.GetByID(ctx, id) + if err != nil { + return fmt.Errorf("trade item not found") + } + exists, err := s.tradeRepo.IsFavorited(ctx, id, userID) + if err != nil { + return err + } + if exists { + return nil + } + if err := s.tradeRepo.CreateFavorite(ctx, id, userID); err != nil { + return err + } + _ = s.tradeRepo.IncrementFavorites(ctx, id) + return nil +} + +func (s *tradeService) Unfavorite(ctx context.Context, userID string, id string) error { + exists, err := s.tradeRepo.IsFavorited(ctx, id, userID) + if err != nil { + return err + } + if !exists { + return nil + } + if err := s.tradeRepo.DeleteFavorite(ctx, id, userID); err != nil { + return err + } + _ = s.tradeRepo.DecrementFavorites(ctx, id) + return nil +} \ No newline at end of file diff --git a/internal/wire/handler.go b/internal/wire/handler.go index a20434e..cf67305 100644 --- a/internal/wire/handler.go +++ b/internal/wire/handler.go @@ -34,6 +34,7 @@ var HandlerSet = wire.NewSet( handler.NewVerificationHandler, handler.NewAdminProfileAuditHandler, handler.NewPostHandler, + handler.NewTradeHandler, // 需要特殊处理的 Handler ProvideUserHandler, diff --git a/internal/wire/repository.go b/internal/wire/repository.go index e4e98b1..79f000b 100644 --- a/internal/wire/repository.go +++ b/internal/wire/repository.go @@ -46,6 +46,9 @@ var RepositorySet = wire.NewSet( // 帖子内链引用关系 repository.NewPostRefRepository, + + // 二手交易相关 + repository.NewTradeRepository, ) // ProvideUserActivityRepository 提供用户活跃数据仓储 diff --git a/internal/wire/service.go b/internal/wire/service.go index 0c6846a..8799494 100644 --- a/internal/wire/service.go +++ b/internal/wire/service.go @@ -68,6 +68,9 @@ var ServiceSet = wire.NewSet( // 帖子内链引用 ProvidePostRefService, + // 二手交易 + ProvideTradeService, + // 日志服务 ProvideAsyncLogManager, ProvideOperationLogService, @@ -496,3 +499,7 @@ func ProvidePostRefService( ) service.PostRefService { return service.NewPostRefService(refRepo, postRepo) } + +func ProvideTradeService(tradeRepo repository.TradeRepository) service.TradeService { + return service.NewTradeService(tradeRepo) +}