feat(trade): implement second-hand trading/flea market feature #2
396
ARCHITECTURE.md
396
ARCHITECTURE.md
@@ -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
|
||||
```
|
||||
|
||||
带以上标记的文件均为自动生成文件。
|
||||
@@ -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,
|
||||
|
||||
101
internal/dto/trade_converter.go
Normal file
101
internal/dto/trade_converter.go
Normal 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
100
internal/dto/trade_dto.go
Normal 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"`
|
||||
}
|
||||
220
internal/handler/trade_handler.go
Normal file
220
internal/handler/trade_handler.go
Normal 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 = ¤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})
|
||||
}
|
||||
@@ -179,6 +179,11 @@ func autoMigrate(db *gorm.DB) error {
|
||||
|
||||
// 帖子内链引用关系
|
||||
&PostReference{},
|
||||
|
||||
// 二手交易相关
|
||||
&TradeItem{},
|
||||
&TradeImage{},
|
||||
&TradeFavorite{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
112
internal/model/trade.go
Normal file
112
internal/model/trade.go
Normal 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"
|
||||
}
|
||||
325
internal/repository/trade_repo.go
Normal file
325
internal/repository/trade_repo.go
Normal 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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
230
internal/service/trade_service.go
Normal file
230
internal/service/trade_service.go
Normal 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
|
||||
}
|
||||
@@ -34,6 +34,7 @@ var HandlerSet = wire.NewSet(
|
||||
handler.NewVerificationHandler,
|
||||
handler.NewAdminProfileAuditHandler,
|
||||
handler.NewPostHandler,
|
||||
handler.NewTradeHandler,
|
||||
|
||||
// 需要特殊处理的 Handler
|
||||
ProvideUserHandler,
|
||||
|
||||
@@ -46,6 +46,9 @@ var RepositorySet = wire.NewSet(
|
||||
|
||||
// 帖子内链引用关系
|
||||
repository.NewPostRefRepository,
|
||||
|
||||
// 二手交易相关
|
||||
repository.NewTradeRepository,
|
||||
)
|
||||
|
||||
// ProvideUserActivityRepository 提供用户活跃数据仓储
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user