package httpapi import ( "encoding/json" "errors" "net/http" "strings" "time" "github.com/gin-gonic/gin" "github.com/sundynix/pets-be/internal/breeds" "github.com/sundynix/pets-be/internal/model" "gorm.io/gorm" ) func (a *API) breedQuery(c *gin.Context) *gorm.DB { return search(a.db.Model(&model.Breed{}), c, "name", "english_name", "aliases", "pinyin", "pinyin_initials") } func (a *API) listBreeds(c *gin.Context) { if len([]rune(c.Query("q"))) > 128 { fail(c, 400, "搜索内容过长") return } query := a.breedQuery(c).Where("enabled = true") var rows []model.Breed if err := query.Order("initial ASC, pinyin ASC, name ASC").Find(&rows).Error; err != nil { fail(c, 500, "读取品种失败") return } // Users only need the selection fields; no internal source metadata. items := make([]gin.H, 0, len(rows)) for _, row := range rows { items = append(items, gin.H{"id": row.ID, "name": row.Name, "english_name": row.EnglishName, "aliases": row.Aliases, "pinyin": row.Pinyin, "pinyin_initials": row.PinyinInitials, "initial": row.Initial, "has_details": row.Summary != "" || row.Origin != "", "origin": row.Origin}) } ok(c, items) } func (a *API) adminBreeds(c *gin.Context) { query := a.breedQuery(c).Preload("References") if status := c.Query("status"); status != "" { if status != "enabled" && status != "disabled" { fail(c, 400, "状态无效") return } query = query.Where("enabled = ?", status == "enabled") } if initial := c.Query("initial"); initial != "" { if len(initial) != 1 || (initial != "#" && (initial < "A" || initial > "Z")) { fail(c, 400, "字母无效") return } query = query.Where("initial = ?", initial) } rows, page, valid := adminRows[model.Breed](c, query, "initial ASC, pinyin ASC, name ASC") if !valid { return } page.Items = rows ok(c, page) } type breedInput struct { LifeSpan string `json:"life_span"` Exercise string `json:"exercise"` Grooming string `json:"grooming"` Traits map[string]int `json:"traits"` FCINumber string `json:"fci_number"` Origin string `json:"origin"` BreedGroup string `json:"breed_group"` Height string `json:"height"` Weight string `json:"weight"` Summary string `json:"summary"` Temperament string `json:"temperament"` CareNotes string `json:"care_notes"` Name string `json:"name" binding:"required"` EnglishName string `json:"english_name"` Aliases string `json:"aliases"` Enabled *bool `json:"enabled" binding:"required"` } func bindBreed(c *gin.Context) (model.Breed, bool) { var in breedInput if c.ShouldBindJSON(&in) != nil { fail(c, 400, "请填写品种名称和状态") return model.Breed{}, false } in.Name = strings.TrimSpace(in.Name) in.EnglishName = strings.TrimSpace(in.EnglishName) in.Aliases = strings.TrimSpace(in.Aliases) if in.Name == "" || len([]rune(in.Name)) > 128 || len([]rune(in.EnglishName)) > 128 || len([]rune(in.Aliases)) > 512 { fail(c, 400, "品种资料长度无效") return model.Breed{}, false } if len([]rune(in.Origin)) > 128 || len([]rune(in.BreedGroup)) > 255 || len([]rune(in.Height)) > 128 || len([]rune(in.Weight)) > 128 || len([]rune(in.Summary)) > 2000 || len([]rune(in.Temperament)) > 255 || len([]rune(in.CareNotes)) > 2000 { fail(c, 400, "百科资料长度无效") return model.Breed{}, false } if len([]rune(in.LifeSpan)) > 128 || len([]rune(in.Exercise)) > 255 || len([]rune(in.Grooming)) > 255 || len(in.FCINumber) > 16 { fail(c, 400, "照顾资料长度无效") return model.Breed{}, false } validTraits := map[string]bool{"运动需求": true, "运动活力": true, "训练接受度": true, "掉毛程度": true, "吠叫倾向": true, "梳毛需求": true, "亲近家人": true} for key, value := range in.Traits { if !validTraits[key] || value < 1 || value > 5 { fail(c, 400, "指数需要为 1–5") return model.Breed{}, false } } scores, _ := json.Marshal(in.Traits) full, short, initial := breeds.SortFields(in.Name) return model.Breed{LifeSpan: strings.TrimSpace(in.LifeSpan), Exercise: strings.TrimSpace(in.Exercise), Grooming: strings.TrimSpace(in.Grooming), FCINumber: strings.TrimSpace(in.FCINumber), Traits: string(scores), Origin: strings.TrimSpace(in.Origin), BreedGroup: strings.TrimSpace(in.BreedGroup), Height: strings.TrimSpace(in.Height), Weight: strings.TrimSpace(in.Weight), Summary: strings.TrimSpace(in.Summary), Temperament: strings.TrimSpace(in.Temperament), CareNotes: strings.TrimSpace(in.CareNotes), Name: in.Name, EnglishName: in.EnglishName, Aliases: in.Aliases, Pinyin: full, PinyinInitials: short, Initial: initial, Enabled: *in.Enabled}, true } func breedWriteError(c *gin.Context, err error) { // pgx PostgreSQL unique_violation, without disclosing DB details. type sqlState interface{ SQLState() string } var state sqlState if errors.As(err, &state) && state.SQLState() == "23505" { fail(c, http.StatusConflict, "该品种名称已存在") return } fail(c, 500, "保存品种失败") } func (a *API) createBreed(c *gin.Context) { row, valid := bindBreed(c) if !valid { return } row.Source = "管理端录入" row.EditorialLocked = true row.EncyclopediaUpdatedAt = time.Now().Format("2006-01-02") // GORM defaults true for zero values on Create; explicitly select false after insertion. err := a.db.Transaction(func(tx *gorm.DB) error { enabled := row.Enabled if err := tx.Create(&row).Error; err != nil { return err } if !enabled { row.Enabled = false return tx.Model(&row).Update("enabled", false).Error } return nil }) if err != nil { breedWriteError(c, err) return } created(c, row) } func (a *API) updateBreed(c *gin.Context) { if !validID(c.Param("id")) { fail(c, 404, "品种不存在") return } update, valid := bindBreed(c) if !valid { return } var row model.Breed if a.db.First(&row, "id = ?", c.Param("id")).Error != nil { fail(c, 404, "品种不存在") return } if row.FieldSources == nil { row.FieldSources = map[string]string{} } before := map[string]string{"name": row.Name, "english_name": row.EnglishName, "aliases": row.Aliases, "origin": row.Origin, "breed_group": row.BreedGroup, "height": row.Height, "weight": row.Weight, "summary": row.Summary, "temperament": row.Temperament, "care_notes": row.CareNotes, "life_span": row.LifeSpan, "exercise": row.Exercise, "grooming": row.Grooming, "fci_number": row.FCINumber, "traits": row.Traits} after := map[string]string{"name": update.Name, "english_name": update.EnglishName, "aliases": update.Aliases, "origin": update.Origin, "breed_group": update.BreedGroup, "height": update.Height, "weight": update.Weight, "summary": update.Summary, "temperament": update.Temperament, "care_notes": update.CareNotes, "life_span": update.LifeSpan, "exercise": update.Exercise, "grooming": update.Grooming, "fci_number": update.FCINumber, "traits": update.Traits} var previousTraits map[string]int if json.Unmarshal([]byte(row.Traits), &previousTraits) == nil { normalized, _ := json.Marshal(previousTraits) before["traits"] = string(normalized) } for key, value := range after { if value != before[key] && !(key == "traits" && value == "null") { row.FieldSources[key] = "editorial" } } row.Origin, row.BreedGroup, row.Height, row.Weight, row.Summary, row.Temperament, row.CareNotes = update.Origin, update.BreedGroup, update.Height, update.Weight, update.Summary, update.Temperament, update.CareNotes row.LifeSpan, row.Exercise, row.Grooming, row.FCINumber = update.LifeSpan, update.Exercise, update.Grooming, update.FCINumber if update.Traits != "null" { row.Traits = update.Traits } row.EditorialLocked = true row.EncyclopediaUpdatedAt = time.Now().Format("2006-01-02") row.Name, row.EnglishName, row.Aliases = update.Name, update.EnglishName, update.Aliases row.Pinyin, row.PinyinInitials, row.Initial, row.Enabled = update.Pinyin, update.PinyinInitials, update.Initial, update.Enabled if err := a.db.Save(&row).Error; err != nil { breedWriteError(c, err) return } ok(c, row) } func (a *API) setBreedEnabled(c *gin.Context) { var in struct { Enabled *bool `json:"enabled" binding:"required"` } if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil { fail(c, 400, "状态参数无效") return } result := a.db.Model(&model.Breed{}).Where("id = ?", c.Param("id")).Update("enabled", *in.Enabled) if result.Error != nil { fail(c, 500, "更新状态失败") return } if result.RowsAffected == 0 { fail(c, 404, "品种不存在") return } ok(c, gin.H{"id": c.Param("id"), "enabled": *in.Enabled}) } func (a *API) breedDetail(c *gin.Context) { var row model.Breed if !validID(c.Param("id")) || a.db.Preload("References").Where("id = ? AND enabled = true", c.Param("id")).First(&row).Error != nil { fail(c, 404, "品种不存在或已停用") return } ok(c, row) }