package mohong import ( "context" "strings" "hfb_sys/backend/internal/model" "gorm.io/gorm" "gorm.io/gorm/clause" ) func (r *Repository) ListPublicProducts(ctx context.Context, query ProductListQuery) (*PaginatedResult, error) { query = normalizePage(query) db := r.db.WithContext(ctx).Model(&model.MohongProduct{}).Where("status = ?", model.MohongProductStatusOnSale) if query.CategoryID > 0 { // 多对多分类 + 主分类兜底 db = db.Where( `(id IN (SELECT product_id FROM mohong_product_categories WHERE category_id = ?) OR category_id = ?)`, query.CategoryID, query.CategoryID, ) } if kw := strings.TrimSpace(query.Keyword); kw != "" { like := "%" + kw + "%" db = db.Where("(title LIKE ? OR description LIKE ?)", like, like) } var total int64 if err := db.Count(&total).Error; err != nil { return nil, err } var rows []model.MohongProduct if err := db.Order("sort_order DESC, id DESC"). Offset((query.Page - 1) * query.PageSize). Limit(query.PageSize). Find(&rows).Error; err != nil { return nil, err } return r.productPageDTO(ctx, rows, total, query, false) } func (r *Repository) FindPublicProduct(ctx context.Context, id uint64) (*ProductDTO, error) { var row model.MohongProduct if err := r.db.WithContext(ctx). Where("id = ? AND status = ?", id, model.MohongProductStatusOnSale). First(&row).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, ErrProductNotFound } return nil, err } name := "" if row.CategoryID != nil { name = r.loadCategoryNameMap(ctx, []uint64{*row.CategoryID})[*row.CategoryID] } dto := toProductDTOWithCategory(row, name, false) return &dto, nil } func (r *Repository) AdminListProducts(ctx context.Context, query ProductListQuery) (*PaginatedResult, error) { query = normalizePage(query) db := r.db.WithContext(ctx).Model(&model.MohongProduct{}) if status := strings.TrimSpace(query.Status); status != "" { db = db.Where("status = ?", status) } if query.CategoryID > 0 { db = db.Where( `(id IN (SELECT product_id FROM mohong_product_categories WHERE category_id = ?) OR category_id = ?)`, query.CategoryID, query.CategoryID, ) } if kw := strings.TrimSpace(query.Keyword); kw != "" { like := "%" + kw + "%" db = db.Where("(title LIKE ? OR description LIKE ?)", like, like) } var total int64 if err := db.Count(&total).Error; err != nil { return nil, err } var rows []model.MohongProduct if err := db.Order("sort_order DESC, id DESC"). Offset((query.Page - 1) * query.PageSize). Limit(query.PageSize). Find(&rows).Error; err != nil { return nil, err } return r.productPageDTO(ctx, rows, total, query, true) } func (r *Repository) productPageDTO(ctx context.Context, rows []model.MohongProduct, total int64, query ProductListQuery, admin bool) (*PaginatedResult, error) { ids := make([]uint64, 0, len(rows)) for _, row := range rows { if row.CategoryID != nil { ids = append(ids, *row.CategoryID) } } names := r.loadCategoryNameMap(ctx, ids) items := make([]ProductDTO, 0, len(rows)) for _, row := range rows { name := "" if row.CategoryID != nil { name = names[*row.CategoryID] } items = append(items, toProductDTOWithCategory(row, name, admin)) } return &PaginatedResult{Items: items, Total: total, Page: query.Page, PageSize: query.PageSize}, nil } func (r *Repository) AdminFindProduct(ctx context.Context, id uint64) (*ProductDTO, error) { var row model.MohongProduct if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, ErrProductNotFound } return nil, err } name := "" if row.CategoryID != nil { name = r.loadCategoryNameMap(ctx, []uint64{*row.CategoryID})[*row.CategoryID] } dto := toProductDTOWithCategory(row, name, true) return &dto, nil } func (r *Repository) CreateProduct(ctx context.Context, adminID uint64, req CreateProductRequest) (*ProductDTO, error) { status := strings.TrimSpace(req.Status) if status == "" { status = model.MohongProductStatusDraft } if !isValidProductStatus(status) { return nil, ErrInvalidRequest } stock := -1 if req.Stock != nil { stock = *req.Stock } unit := strings.TrimSpace(req.Unit) if unit == "" { unit = "份" } row := model.MohongProduct{ CategoryID: normalizeCategoryID(req.CategoryID), Title: strings.TrimSpace(req.Title), CoverURL: strings.TrimSpace(req.CoverURL), ImageURLs: encodeStringList(req.ImageURLs), Description: strings.TrimSpace(req.Description), PriceCent: req.PriceCent, OriginalPriceCent: maxInt64(req.OriginalPriceCent, 0), Unit: unit, Stock: stock, SortOrder: req.SortOrder, Status: status, QrcodeImageURL: strings.TrimSpace(req.QrcodeImageURL), CreatedBy: &adminID, } if row.Title == "" || row.PriceCent <= 0 { return nil, ErrInvalidRequest } if err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Create(&row).Error; err != nil { return err } return syncProductCategories(tx, row.ID, row.CategoryID) }); err != nil { return nil, err } return r.AdminFindProduct(ctx, row.ID) } func (r *Repository) UpdateProduct(ctx context.Context, id uint64, req UpdateProductRequest) (*ProductDTO, error) { var row model.MohongProduct if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, ErrProductNotFound } return nil, err } updates := map[string]any{} if req.ClearCategory { updates["category_id"] = nil } else if req.CategoryID != nil { updates["category_id"] = normalizeCategoryID(req.CategoryID) } if req.Title != nil { title := strings.TrimSpace(*req.Title) if title == "" { return nil, ErrInvalidRequest } updates["title"] = title } if req.CoverURL != nil { updates["cover_url"] = strings.TrimSpace(*req.CoverURL) } if req.ImageURLs != nil { updates["image_urls"] = encodeStringList(req.ImageURLs) } if req.Description != nil { updates["description"] = strings.TrimSpace(*req.Description) } if req.PriceCent != nil { if *req.PriceCent <= 0 { return nil, ErrInvalidRequest } updates["price_cent"] = *req.PriceCent } if req.OriginalPriceCent != nil { updates["original_price_cent"] = maxInt64(*req.OriginalPriceCent, 0) } if req.Unit != nil { unit := strings.TrimSpace(*req.Unit) if unit == "" { unit = "份" } updates["unit"] = unit } if req.Stock != nil { updates["stock"] = *req.Stock } if req.SortOrder != nil { updates["sort_order"] = *req.SortOrder } if req.Status != nil { status := strings.TrimSpace(*req.Status) if !isValidProductStatus(status) { return nil, ErrInvalidRequest } updates["status"] = status } if req.QrcodeImageURL != nil { updates["qrcode_image_url"] = strings.TrimSpace(*req.QrcodeImageURL) } if err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if len(updates) > 0 { if err := tx.Model(&row).Updates(updates).Error; err != nil { return err } } // 刷新主分类后同步关联表(至少保证主分类在关联里) var latest model.MohongProduct if err := tx.First(&latest, id).Error; err != nil { return err } return syncProductCategories(tx, latest.ID, latest.CategoryID) }); err != nil { return nil, err } return r.AdminFindProduct(ctx, id) } // syncProductCategories 保证主分类写入多对多表;不删除其它已有关联(种子数据可挂多分类)。 func syncProductCategories(tx *gorm.DB, productID uint64, primary *uint64) error { if primary == nil || *primary == 0 { return nil } rel := model.MohongProductCategory{ProductID: productID, CategoryID: *primary} return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&rel).Error } func (r *Repository) DeleteProduct(ctx context.Context, id uint64) error { res := r.db.WithContext(ctx).Delete(&model.MohongProduct{}, id) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return ErrProductNotFound } return nil } func isValidProductStatus(status string) bool { switch status { case model.MohongProductStatusDraft, model.MohongProductStatusOnSale, model.MohongProductStatusOffSale: return true default: return false } } func normalizePage(query ProductListQuery) ProductListQuery { if query.Page < 1 { query.Page = 1 } if query.PageSize < 1 { query.PageSize = 20 } if query.PageSize > 100 { query.PageSize = 100 } return query } func maxInt64(a, b int64) int64 { if a > b { return a } return b } func normalizeCategoryID(id *uint64) *uint64 { if id == nil || *id == 0 { return nil } return id }