feat(trade): implement second-hand trading/flea market feature #2

Merged
lan merged 1 commits from feature/flea-market into master 2026-04-26 15:21:31 +08:00
13 changed files with 1131 additions and 399 deletions

View File

@@ -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. 常见错误示例
### 错误1Handler直接依赖Repository
```go
// ❌ 错误
type PostHandler struct {
postRepo *repository.PostRepository
}
// ✅ 正确
type PostHandler struct {
postService service.PostService
}
```
### 错误2Service直接使用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)
}
```
### 错误3Repository返回指针类型
```go
// ❌ 错误
func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db: db}
}
// ✅ 正确
func NewUserRepository(db *gorm.DB) UserRepository {
return &userRepository{db: db}
}
```
### 错误4Filter类型放在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
```
带以上标记的文件均为自动生成文件。

View File

@@ -143,7 +143,10 @@ func InitializeApp() (*App, error) {
setupService := wire.ProvideSetupService(userRepository, roleRepository, casbinService, config) setupService := wire.ProvideSetupService(userRepository, roleRepository, casbinService, config)
setupHandler := wire.ProvideSetupHandler(setupService) setupHandler := wire.ProvideSetupHandler(setupService)
wsHandler := wire.ProvideWSHandler(hub, chatService, groupService, jwtService, callService, userRepository) 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) hotRankWorker := wire.ProvideHotRankWorker(config, postRepository, channelRepository, cache)
app := NewApp(config, db, router, pushService, hotRankWorker, server, logger) app := NewApp(config, db, router, pushService, hotRankWorker, server, logger)
return app, nil return app, nil
@@ -184,6 +187,7 @@ func ProvideRouter(
adminVerificationHandler *handler.AdminVerificationHandler, adminVerificationHandler *handler.AdminVerificationHandler,
adminProfileAuditHandler *handler.AdminProfileAuditHandler, adminProfileAuditHandler *handler.AdminProfileAuditHandler,
setupHandler *handler.SetupHandler, setupHandler *handler.SetupHandler,
tradeHandler *handler.TradeHandler,
logService *service.LogService, logService *service.LogService,
activityService service.UserActivityService, activityService service.UserActivityService,
casbinService service.CasbinService, casbinService service.CasbinService,
@@ -221,6 +225,7 @@ func ProvideRouter(
adminVerificationHandler, adminVerificationHandler,
adminProfileAuditHandler, adminProfileAuditHandler,
setupHandler, setupHandler,
tradeHandler,
logService, logService,
activityService, activityService,
casbinService, casbinService,

View File

@@ -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
}

100
internal/dto/trade_dto.go Normal file
View File

@@ -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"`
}

View File

@@ -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 = &currentUserID
}
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 = &currentUserID
}
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})
}

View File

@@ -179,6 +179,11 @@ func autoMigrate(db *gorm.DB) error {
// 帖子内链引用关系 // 帖子内链引用关系
&PostReference{}, &PostReference{},
// 二手交易相关
&TradeItem{},
&TradeImage{},
&TradeFavorite{},
) )
if err != nil { if err != nil {
return err return err

112
internal/model/trade.go Normal file
View File

@@ -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"
}

View File

@@ -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
}

View File

@@ -43,6 +43,7 @@ type Router struct {
adminVerificationHandler *handler.AdminVerificationHandler adminVerificationHandler *handler.AdminVerificationHandler
adminProfileAuditHandler *handler.AdminProfileAuditHandler adminProfileAuditHandler *handler.AdminProfileAuditHandler
setupHandler *handler.SetupHandler setupHandler *handler.SetupHandler
tradeHandler *handler.TradeHandler
wsHandler *handler.WSHandler wsHandler *handler.WSHandler
logService *service.LogService logService *service.LogService
jwtService *service.JWTService jwtService *service.JWTService
@@ -82,6 +83,7 @@ func New(
adminVerificationHandler *handler.AdminVerificationHandler, adminVerificationHandler *handler.AdminVerificationHandler,
adminProfileAuditHandler *handler.AdminProfileAuditHandler, adminProfileAuditHandler *handler.AdminProfileAuditHandler,
setupHandler *handler.SetupHandler, setupHandler *handler.SetupHandler,
tradeHandler *handler.TradeHandler,
logService *service.LogService, logService *service.LogService,
activityService service.UserActivityService, activityService service.UserActivityService,
casbinService service.CasbinService, casbinService service.CasbinService,
@@ -124,6 +126,7 @@ func New(
adminVerificationHandler: adminVerificationHandler, adminVerificationHandler: adminVerificationHandler,
adminProfileAuditHandler: adminProfileAuditHandler, adminProfileAuditHandler: adminProfileAuditHandler,
setupHandler: setupHandler, setupHandler: setupHandler,
tradeHandler: tradeHandler,
logService: logService, logService: logService,
jwtService: jwtService, jwtService: jwtService,
casbinService: casbinService, 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 { if r.scheduleHandler != nil {
schedule := v1.Group("/schedule") schedule := v1.Group("/schedule")

View File

@@ -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
}

View File

@@ -34,6 +34,7 @@ var HandlerSet = wire.NewSet(
handler.NewVerificationHandler, handler.NewVerificationHandler,
handler.NewAdminProfileAuditHandler, handler.NewAdminProfileAuditHandler,
handler.NewPostHandler, handler.NewPostHandler,
handler.NewTradeHandler,
// 需要特殊处理的 Handler // 需要特殊处理的 Handler
ProvideUserHandler, ProvideUserHandler,

View File

@@ -46,6 +46,9 @@ var RepositorySet = wire.NewSet(
// 帖子内链引用关系 // 帖子内链引用关系
repository.NewPostRefRepository, repository.NewPostRefRepository,
// 二手交易相关
repository.NewTradeRepository,
) )
// ProvideUserActivityRepository 提供用户活跃数据仓储 // ProvideUserActivityRepository 提供用户活跃数据仓储

View File

@@ -68,6 +68,9 @@ var ServiceSet = wire.NewSet(
// 帖子内链引用 // 帖子内链引用
ProvidePostRefService, ProvidePostRefService,
// 二手交易
ProvideTradeService,
// 日志服务 // 日志服务
ProvideAsyncLogManager, ProvideAsyncLogManager,
ProvideOperationLogService, ProvideOperationLogService,
@@ -496,3 +499,7 @@ func ProvidePostRefService(
) service.PostRefService { ) service.PostRefService {
return service.NewPostRefService(refRepo, postRepo) return service.NewPostRefService(refRepo, postRepo)
} }
func ProvideTradeService(tradeRepo repository.TradeRepository) service.TradeService {
return service.NewTradeService(tradeRepo)
}