feat(trade): implement second-hand trading/flea market feature
Some checks failed
Build Backend / build-docker (pull_request) Has been cancelled
Build Backend / build (pull_request) Has been cancelled
Deploy / deploy (pull_request) Has been cancelled

Add complete trade functionality including:
- TradeItem, TradeImage, TradeFavorite models with auto-migration
- TradeRepository with CRUD and favorite operations
- TradeService with business logic for listing, creating, updating, status management
- TradeHandler with RESTful endpoints for trade operations
- DTOs and converters for request/response handling

Routes include: list, get by id, create, update, delete, status update, view recording, favorite/unfavorite.
This commit is contained in:
lafay
2026-04-26 15:20:26 +08:00
parent 1b50d6c792
commit 0ca02f7ef3
13 changed files with 1131 additions and 399 deletions

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
}