Files
sundynix-pets/pets-be/internal/httpapi/breeds.go
T
Blizzard 9d7cf720a5
ci-cd / verify (push) Successful in 1m59s
ci-cd / deploy (push) Skipped
feat: add multi-source breed encyclopedia and two-hour refresh sessions
2026-10-05 13:38:24 +08:00

220 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}