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,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{},
// 二手交易相关
&TradeItem{},
&TradeImage{},
&TradeFavorite{},
)
if err != nil {
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
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")

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.NewAdminProfileAuditHandler,
handler.NewPostHandler,
handler.NewTradeHandler,
// 需要特殊处理的 Handler
ProvideUserHandler,

View File

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

View File

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