feat: add multi-source breed encyclopedia and two-hour refresh sessions
ci-cd / verify (push) Successful in 1m59s
ci-cd / deploy (push) Skipped

This commit is contained in:
2026-10-05 13:38:24 +08:00
parent 607d709100
commit 9d7cf720a5
73 changed files with 25771 additions and 264 deletions
+219
View File
@@ -0,0 +1,219 @@
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)
}