feat: add multi-source breed encyclopedia and two-hour refresh sessions
This commit is contained in:
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/brand-icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>肉垫计划 · 管理后台</title>
|
||||
<script type="module" crossorigin src="/assets/index-BOnBpy-T.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-7B3h3Yq6.css">
|
||||
<script type="module" crossorigin src="/assets/index-N2FSM0pD.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CkSlqiFn.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -38,6 +38,9 @@ func TestAdminAtRootAndAPIRoutes(t *testing.T) {
|
||||
body string
|
||||
}{
|
||||
{path: "/", status: http.StatusOK, body: "<div id=\"root\"></div>"},
|
||||
{path: "/admin/breeds", status: http.StatusOK, body: "<div id=\"root\"></div>"},
|
||||
{path: "/admin/walks", status: http.StatusOK, body: "<div id=\"root\"></div>"},
|
||||
{path: "/admin/files", status: http.StatusOK, body: "<div id=\"root\"></div>"},
|
||||
{path: script, status: http.StatusOK},
|
||||
{path: "/brand-logo.png", status: http.StatusOK},
|
||||
{path: "/api/ping", status: http.StatusOK, body: "pong"},
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# 犬种目录与百科
|
||||
|
||||
## 数据结构
|
||||
|
||||
- `sundynix_breed`:UUID 主键、中文名、英文名、别名、拼音与首字母、启用状态;起源地、FCI 编号和分组、肩高、体重、寿命、介绍、性格、运动、毛发护理、相处笔记、指数。`field_sources` 使用 PostgreSQL JSONB 保存展示字段的来源机构。
|
||||
- `sundynix_breed_reference`:UUID 主键,`breed_id` 关联犬种;机构、机构内编号、原始页面链接、核对日期、事实字段(JSONB)。每个犬种与机构唯一。参考尺寸和指数按机构分别保存,不合并为统一标准。
|
||||
- `sundynix_pet.breed_id`:可空。选目录品种时关联 UUID;手动填写时仅保留 `breed` 文本。
|
||||
- `editorial_locked`:管理员保存资料后锁定展示内容,重复导入不会覆盖人工修改或清空的字段。来源资料只读,导入仅在其事实内容发生变化时更新。
|
||||
|
||||
## 展示
|
||||
|
||||
管理端 `/admin/breeds` 支持每页 10 条分页、名称/别名/拼音搜索、A–Z 筛选、启停与新增编辑。编辑窗切换“展示资料 / 官方来源”,可以对照各机构标准与评分。展示字段可手动完善,缺失字段留空。
|
||||
|
||||
小程序“知识”提供搜索、中文拼音 A–Z 筛选和详情;详情分为基本信息、性格指数、散步与护理、资料出处,字段注明来源,引用链接可复制。所有文案为简短中文整理,不保留来源长篇文章或图片。品种选择组件复用于新增/编辑档案,也允许手填。
|
||||
|
||||
## 采集与导入
|
||||
|
||||
2026-10-05:CKU 公开列表 348 项,按中文名称去重后 347 项。补充中华田园犬、混种犬、贵宾犬三项实用分类,合计 350 项。
|
||||
|
||||
- CKU:https://www.cku.org.cn/oa-web/api/v1/open/getFciDogAll
|
||||
88 个详情接口有完整资料,整理基本事实与简短性格标签。
|
||||
- FCI:https://www.fci.be/en/nomenclature/
|
||||
使用编号准确关联目录,保存官方名称、原产地和分组;变种保留自己的中文档案,来源标明基础标准范围。分组页面归属国字段用于起源地参考,尺寸不从目录推测。
|
||||
- AKC:https://www.akc.org/dog-breeds/
|
||||
只使用准确英文名或已核对的等价名称匹配,提取结构化尺寸、寿命和指数。尺寸展示统一换算 cm/kg,原始单位保留在来源资料。变种不套用父犬种的 AKC 展示数据;父犬种参考资料明确标注标准范围。
|
||||
- 英国皇家犬业俱乐部:https://www.royalkennelclub.com/breeds-a-z
|
||||
本机直连超时,先从官方页面核对 8 个犬种的运动、护理和寿命信息,记录于 `rkc_facts.json`。
|
||||
|
||||
`data.json` 保存中文目录与已有摘要;`enrichment.json` 保存多来源的事实快照和展示补充。`rkc_facts.json` 是人工核对的事实记录。
|
||||
|
||||
从仓库根目录执行 `python3 pets-be/scripts/collect_breed_facts.py` 可重新生成补充数据。采集脚本仅用 Python 标准库,缓存置于 `/private/tmp/pawprint-breed-sources`;机构连接失败不伪造数据。
|
||||
|
||||
服务器迁移时自动导入;也可在 `pets-be` 目录执行 `go run ./cmd/seedbreeds`。内容无变化时跳过资料更新。导入不会恢复管理员改名或停用的品种,人工编辑后的展示内容不会被刷新覆盖。
|
||||
@@ -0,0 +1,102 @@
|
||||
package breeds
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/mozillazg/go-pinyin"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
//go:embed data.json
|
||||
var data []byte
|
||||
|
||||
// SortFields provides both full pinyin and initials, retaining Latin breed names.
|
||||
func SortFields(name string) (string, string, string) {
|
||||
args := pinyin.NewArgs()
|
||||
args.Fallback = func(r rune, _ pinyin.Args) []string {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
return []string{strings.ToLower(string(r))}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
words := pinyin.LazyPinyin(name, args)
|
||||
// Breed-name phrases with common alternative pronunciations.
|
||||
if strings.HasPrefix(name, "重庆") && len(words) > 0 {
|
||||
words[0] = "chong"
|
||||
}
|
||||
if strings.HasPrefix(name, "长毛") && len(words) > 0 {
|
||||
words[0] = "chang"
|
||||
}
|
||||
full := strings.Join(words, "")
|
||||
var initials strings.Builder
|
||||
for _, word := range words {
|
||||
if word != "" {
|
||||
initials.WriteByte(word[0])
|
||||
}
|
||||
}
|
||||
initial := "#"
|
||||
if len(full) > 0 && full[0] >= 'a' && full[0] <= 'z' {
|
||||
initial = strings.ToUpper(full[:1])
|
||||
}
|
||||
return full, initials.String(), initial
|
||||
}
|
||||
|
||||
func Defaults() ([]model.Breed, error) {
|
||||
var rows []struct {
|
||||
Origin string `json:"origin"`
|
||||
BreedGroup string `json:"breed_group"`
|
||||
FCINumber string `json:"fci_number"`
|
||||
EnglishName string `json:"english_name"`
|
||||
Height string `json:"height"`
|
||||
Weight string `json:"weight"`
|
||||
Summary string `json:"summary"`
|
||||
Temperament string `json:"temperament"`
|
||||
Traits string `json:"traits"`
|
||||
CareNotes string `json:"care_notes"`
|
||||
EncyclopediaUpdatedAt string `json:"encyclopedia_updated_at"`
|
||||
Name string `json:"name"`
|
||||
Aliases string `json:"aliases"`
|
||||
Source string `json:"source"`
|
||||
SourceKey string `json:"source_key"`
|
||||
SourceURL string `json:"source_url"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]model.Breed, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
full, short, initial := SortFields(row.Name)
|
||||
key := row.SourceKey
|
||||
result = append(result, model.Breed{Origin: row.Origin, BreedGroup: row.BreedGroup, FCINumber: row.FCINumber, EnglishName: row.EnglishName, Height: row.Height, Weight: row.Weight, Summary: row.Summary, Temperament: row.Temperament, Traits: row.Traits, CareNotes: row.CareNotes, EncyclopediaUpdatedAt: row.EncyclopediaUpdatedAt, Name: row.Name, Aliases: row.Aliases, Pinyin: full, PinyinInitials: short, Initial: initial, Enabled: true, Source: row.Source, SourceKey: &key, SourceURL: row.SourceURL})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Existing entries (including edits and disabled breeds) are never overwritten.
|
||||
func Seed(db *gorm.DB) (int64, error) {
|
||||
rows, err := Defaults()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("load breeds: %w", err)
|
||||
}
|
||||
result := db.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(rows, 100)
|
||||
if result.Error != nil {
|
||||
return result.RowsAffected, result.Error
|
||||
}
|
||||
// Backfill newly introduced encyclopedia fields exactly once on untouched seed rows.
|
||||
// Future starts never overwrite administrator changes.
|
||||
for _, row := range rows {
|
||||
if row.Summary == "" {
|
||||
continue
|
||||
}
|
||||
if err := db.Model(&model.Breed{}).Where("source_key = ? AND name = ? AND (encyclopedia_updated_at IS NULL OR encyclopedia_updated_at = '') AND updated_at = created_at", row.SourceKey, row.Name).Updates(map[string]any{"origin": row.Origin, "breed_group": row.BreedGroup, "fci_number": row.FCINumber, "english_name": row.EnglishName, "height": row.Height, "weight": row.Weight, "summary": row.Summary, "temperament": row.Temperament, "traits": row.Traits, "care_notes": row.CareNotes, "encyclopedia_updated_at": row.EncyclopediaUpdatedAt}).Error; err != nil {
|
||||
return result.RowsAffected, err
|
||||
}
|
||||
}
|
||||
return result.RowsAffected, enrich(db)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package breeds
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCatalogAndPinyin(t *testing.T) {
|
||||
rows, err := Defaults()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 350 {
|
||||
t.Fatalf("catalog count %d", len(rows))
|
||||
}
|
||||
names, sources := map[string]bool{}, map[string]bool{}
|
||||
for _, row := range rows {
|
||||
if names[row.Name] || row.SourceKey == nil || sources[*row.SourceKey] || row.Pinyin == "" || row.Initial == "" {
|
||||
t.Fatalf("invalid catalog row: %s", row.Name)
|
||||
}
|
||||
names[row.Name] = true
|
||||
sources[*row.SourceKey] = true
|
||||
}
|
||||
for name, want := range map[string]string{"柯基": "K", "重庆犬": "C", "长毛吉娃娃": "C", "中华田园犬": "Z", "Border Collie": "B"} {
|
||||
full, short, initial := SortFields(name)
|
||||
if initial != want || full == "" || short == "" {
|
||||
t.Fatalf("%s: %s/%s/%s", name, full, short, initial)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficialSources(t *testing.T) {
|
||||
rows, err := Enrichments()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
counts := map[string]int{}
|
||||
for _, row := range rows {
|
||||
seen := map[string]bool{}
|
||||
for _, ref := range row.References {
|
||||
if seen[ref.Provider] || len(ref.Facts) == 0 || ref.URL == "" || ref.RetrievedAt == "" {
|
||||
t.Fatalf("invalid reference for %s", row.Name)
|
||||
}
|
||||
seen[ref.Provider] = true
|
||||
counts[ref.Provider]++
|
||||
if ref.Provider == "fci" && "cku:"+ref.ExternalID != strings.TrimRight(row.SourceKey, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
|
||||
t.Fatalf("wrong FCI mapping %s/%s", row.SourceKey, ref.ExternalID)
|
||||
}
|
||||
}
|
||||
for key, provider := range row.FieldSources {
|
||||
if provider != "editorial" && !seen[provider] {
|
||||
t.Fatalf("%s %s has missing source %s", row.Name, key, provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(rows) != 350 || counts["fci"] != 347 || counts["akc"] < 160 || counts["rkc"] != 8 {
|
||||
t.Fatalf("incomplete import: %v", counts)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
||||
package breeds
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
//go:embed enrichment.json
|
||||
var enrichmentData []byte
|
||||
|
||||
type Enrichment struct {
|
||||
SourceKey string `json:"source_key"`
|
||||
Name string `json:"name"`
|
||||
Fields map[string]string `json:"fields"`
|
||||
FieldSources map[string]string `json:"field_sources"`
|
||||
References []model.BreedReference `json:"references"`
|
||||
}
|
||||
|
||||
func Enrichments() ([]Enrichment, error) {
|
||||
var rows []Enrichment
|
||||
err := json.Unmarshal(enrichmentData, &rows)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// Reference facts remain available even when an editor locks the display content.
|
||||
func enrich(db *gorm.DB) error {
|
||||
rows, err := Enrichments()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load breed sources: %w", err)
|
||||
}
|
||||
defaults, err := Defaults()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
baseline := map[string]model.Breed{}
|
||||
for _, breed := range defaults {
|
||||
baseline[*breed.SourceKey] = breed
|
||||
}
|
||||
for _, item := range rows {
|
||||
var breed model.Breed
|
||||
if err := db.Where("source_key = ?", item.SourceKey).First(&breed).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, source := range item.References {
|
||||
source.BreedID = breed.ID
|
||||
// Source snapshots are read-only in admin; update only when factual metadata changes.
|
||||
if err := db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "breed_id"}, {Name: "provider"}}, DoUpdates: clause.AssignmentColumns([]string{"external_id", "url", "retrieved_at", "facts"}), Where: clause.Where{Exprs: []clause.Expression{clause.Expr{SQL: "sundynix_breed_reference.facts IS DISTINCT FROM EXCLUDED.facts OR sundynix_breed_reference.url IS DISTINCT FROM EXCLUDED.url OR sundynix_breed_reference.retrieved_at IS DISTINCT FROM EXCLUDED.retrieved_at"}}}}).Create(&source).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if breed.EditorialLocked || breed.Name != item.Name {
|
||||
continue
|
||||
}
|
||||
original := baseline[item.SourceKey]
|
||||
updates := map[string]any{"english_name": original.EnglishName, "origin": original.Origin, "breed_group": original.BreedGroup, "fci_number": original.FCINumber, "height": original.Height, "weight": original.Weight, "summary": original.Summary, "temperament": original.Temperament, "traits": original.Traits, "care_notes": original.CareNotes, "life_span": "", "exercise": "", "grooming": ""}
|
||||
for field, value := range item.Fields {
|
||||
switch field {
|
||||
case "english_name", "origin", "breed_group", "fci_number", "height", "weight", "life_span", "exercise", "grooming", "temperament", "traits", "summary", "care_notes":
|
||||
updates[field] = value
|
||||
default:
|
||||
return fmt.Errorf("unsupported breed field: %s", field)
|
||||
}
|
||||
}
|
||||
origins, err := json.Marshal(item.FieldSources)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updates["field_sources"] = string(origins)
|
||||
latest := original.EncyclopediaUpdatedAt
|
||||
for _, ref := range item.References {
|
||||
if ref.RetrievedAt > latest {
|
||||
latest = ref.RetrievedAt
|
||||
}
|
||||
}
|
||||
updates["encyclopedia_updated_at"] = latest
|
||||
// Skip unchanged columns so ordinary starts do not rewrite every breed.
|
||||
current := map[string]string{"english_name": breed.EnglishName, "origin": breed.Origin, "breed_group": breed.BreedGroup, "fci_number": breed.FCINumber, "height": breed.Height, "weight": breed.Weight, "life_span": breed.LifeSpan, "exercise": breed.Exercise, "grooming": breed.Grooming, "temperament": breed.Temperament, "traits": breed.Traits, "summary": breed.Summary, "care_notes": breed.CareNotes, "encyclopedia_updated_at": breed.EncyclopediaUpdatedAt}
|
||||
for key, value := range updates {
|
||||
if key != "field_sources" && current[key] == value {
|
||||
delete(updates, key)
|
||||
}
|
||||
}
|
||||
if reflect.DeepEqual(breed.FieldSources, item.FieldSources) {
|
||||
delete(updates, "field_sources")
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
continue
|
||||
}
|
||||
// UpdateColumns avoids advancing UpdatedAt on each application start.
|
||||
if err := db.Model(&breed).Where("editorial_locked = false AND name = ?", item.Name).UpdateColumns(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
[
|
||||
{
|
||||
"source_key": "cku:39",
|
||||
"external_id": "welsh-corgi-pembroke",
|
||||
"url": "https://www.royalkennelclub.com/search/breeds-a-to-z/breeds/pastoral/welsh-corgi-pembroke/",
|
||||
"facts": {
|
||||
"exercise": "每日运动量参考:不超过 1 小时",
|
||||
"grooming": "每周梳理超过一次",
|
||||
"life_span": "通常超过 12 年",
|
||||
"size": "小型",
|
||||
"coat_length": "短毛",
|
||||
"sheds": "会掉毛"
|
||||
},
|
||||
"retrieved_at": "2026-10-05"
|
||||
},
|
||||
{
|
||||
"source_key": "cku:38",
|
||||
"external_id": "welsh-corgi-cardigan",
|
||||
"url": "https://www.royalkennelclub.com/search/breeds-a-to-z/breeds/pastoral/welsh-corgi-cardigan/",
|
||||
"facts": {
|
||||
"exercise": "每日运动量参考:不超过 1 小时",
|
||||
"grooming": "每周梳理超过一次",
|
||||
"life_span": "通常超过 12 年",
|
||||
"size": "小型",
|
||||
"coat_length": "短毛",
|
||||
"sheds": "会掉毛"
|
||||
},
|
||||
"retrieved_at": "2026-10-05"
|
||||
},
|
||||
{
|
||||
"source_key": "cku:86",
|
||||
"external_id": "yorkshire-terrier",
|
||||
"url": "https://www.royalkennelclub.com/search/breeds-a-to-z/breeds/toy/yorkshire-terrier/",
|
||||
"facts": {
|
||||
"exercise": "每日运动量参考:不超过 30 分钟",
|
||||
"grooming": "每天梳理",
|
||||
"life_span": "通常超过 12 年",
|
||||
"size": "小型",
|
||||
"coat_length": "长毛",
|
||||
"sheds": "掉毛较少"
|
||||
},
|
||||
"retrieved_at": "2026-10-05"
|
||||
},
|
||||
{
|
||||
"source_key": "cku:162",
|
||||
"external_id": "whippet",
|
||||
"url": "https://www.royalkennelclub.com/search/breeds-a-to-z/breeds/hound/whippet/",
|
||||
"facts": {
|
||||
"exercise": "每日运动量参考:不超过 1 小时",
|
||||
"grooming": "每周梳理一次",
|
||||
"life_span": "通常超过 12 年",
|
||||
"size": "小型",
|
||||
"coat_length": "短毛",
|
||||
"sheds": "会掉毛"
|
||||
},
|
||||
"retrieved_at": "2026-10-05"
|
||||
},
|
||||
{
|
||||
"source_key": "cku:209",
|
||||
"external_id": "tibetan-terrier",
|
||||
"url": "https://www.royalkennelclub.com/search/breeds-a-to-z/breeds/utility/tibetan-terrier/",
|
||||
"facts": {
|
||||
"exercise": "每日运动量参考:不超过 1 小时",
|
||||
"grooming": "每天梳理",
|
||||
"life_span": "通常超过 12 年",
|
||||
"size": "中型",
|
||||
"coat_length": "长毛",
|
||||
"sheds": "掉毛较少"
|
||||
},
|
||||
"retrieved_at": "2026-10-05"
|
||||
},
|
||||
{
|
||||
"source_key": "cku:231",
|
||||
"external_id": "tibetan-spaniel",
|
||||
"url": "https://www.royalkennelclub.com/search/breeds-a-to-z/breeds/utility/tibetan-spaniel/",
|
||||
"facts": {
|
||||
"exercise": "每日运动量参考:不超过 1 小时",
|
||||
"grooming": "每周梳理超过一次",
|
||||
"life_span": "通常超过 12 年",
|
||||
"size": "小型",
|
||||
"coat_length": "中等毛长",
|
||||
"sheds": "会掉毛"
|
||||
},
|
||||
"retrieved_at": "2026-10-05"
|
||||
},
|
||||
{
|
||||
"source_key": "cku:85",
|
||||
"external_id": "west-highland-white-terrier",
|
||||
"url": "https://www.royalkennelclub.com/search/breeds-a-to-z/breeds/terrier/west-highland-white-terrier/",
|
||||
"facts": {
|
||||
"exercise": "每日运动量参考:不超过 1 小时",
|
||||
"grooming": "每周梳理超过一次",
|
||||
"life_span": "通常超过 12 年",
|
||||
"size": "小型",
|
||||
"coat_length": "中等毛长",
|
||||
"sheds": "会掉毛"
|
||||
},
|
||||
"retrieved_at": "2026-10-05"
|
||||
},
|
||||
{
|
||||
"source_key": "cku:347",
|
||||
"external_id": "white-swiss-shepherd-dog-imp",
|
||||
"url": "https://www.royalkennelclub.com/search/breeds-a-to-z/breeds/pastoral/white-swiss-shepherd-dog-imp/",
|
||||
"facts": {
|
||||
"exercise": "每日运动量参考:超过 2 小时",
|
||||
"grooming": "每周梳理超过一次",
|
||||
"life_span": "通常超过 10 年",
|
||||
"size": "大型",
|
||||
"coat_length": "中长毛",
|
||||
"sheds": "会掉毛"
|
||||
},
|
||||
"retrieved_at": "2026-10-05"
|
||||
}
|
||||
]
|
||||
@@ -52,8 +52,9 @@ type MinIOConfig struct {
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
Secret string `mapstructure:"secret"`
|
||||
ExpireHours int `mapstructure:"expire_hours"`
|
||||
Secret string `mapstructure:"secret"`
|
||||
ExpireHours int `mapstructure:"expire_hours"`
|
||||
RefreshHours int `mapstructure:"refresh_hours"`
|
||||
}
|
||||
|
||||
type WeChatConfig struct {
|
||||
@@ -87,7 +88,8 @@ func Load() (*Config, error) {
|
||||
v.SetDefault("minio.bucket", "sundynix-pet")
|
||||
v.SetDefault("minio.use_ssl", true)
|
||||
v.SetDefault("jwt.secret", "")
|
||||
v.SetDefault("jwt.expire_hours", 168)
|
||||
v.SetDefault("jwt.expire_hours", 2)
|
||||
v.SetDefault("jwt.refresh_hours", 2)
|
||||
v.SetDefault("wechat.app_id", "")
|
||||
v.SetDefault("wechat.app_secret", "")
|
||||
v.SetDefault("auth.dev_login", false)
|
||||
@@ -120,5 +122,8 @@ func Load() (*Config, error) {
|
||||
if cfg.Server.Mode == "release" && cfg.Auth.DevLogin {
|
||||
return nil, errors.New("dev login must be disabled in release mode")
|
||||
}
|
||||
// Access tokens have the same fixed two-hour lifetime for both clients.
|
||||
cfg.JWT.ExpireHours = 2
|
||||
cfg.JWT.RefreshHours = 2
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/breeds"
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
@@ -39,5 +40,8 @@ func Migrate(db *gorm.DB) error {
|
||||
if err := db.AutoMigrate(model.All()...); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := breeds.Seed(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_sundynix_walk_one_active_per_user ON sundynix_walk (user_id) WHERE status IN ('active', 'paused')").Error
|
||||
}
|
||||
|
||||
@@ -59,12 +59,13 @@ func (a *API) adminLogin(c *gin.Context) {
|
||||
fail(c, http.StatusUnauthorized, "用户名或密码错误")
|
||||
return
|
||||
}
|
||||
token, err := a.token(admin.ID, "admin")
|
||||
pair, err := a.newSession(admin.ID, "admin")
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, "签发令牌失败")
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"token": token, "admin": gin.H{"id": admin.ID, "username": admin.Username}})
|
||||
pair["admin"] = gin.H{"id": admin.ID, "username": admin.Username}
|
||||
ok(c, pair)
|
||||
}
|
||||
|
||||
func (a *API) devLogin(c *gin.Context) {
|
||||
@@ -203,12 +204,13 @@ func (a *API) loginUser(c *gin.Context, user model.User) {
|
||||
fail(c, http.StatusForbidden, "账号已停用")
|
||||
return
|
||||
}
|
||||
token, err := a.token(user.ID, "user")
|
||||
pair, err := a.newSession(user.ID, "user")
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, "签发令牌失败")
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"token": token, "user": a.userView(user)})
|
||||
pair["user"] = a.userView(user)
|
||||
ok(c, pair)
|
||||
}
|
||||
|
||||
func (a *API) me(c *gin.Context) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sundynix/pets-be/internal/breeds"
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
"github.com/sundynix/pets-be/internal/database"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
"gorm.io/gorm"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func featureDB(t *testing.T) (*gorm.DB, *config.Config) {
|
||||
t.Helper()
|
||||
if os.Getenv("PETS_ADMIN_INTEGRATION") != "1" {
|
||||
t.Skip("PostgreSQL integration disabled")
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := database.Open(cfg.Postgres, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
t.Cleanup(func() { sqlDB.Close() })
|
||||
tx := db.Begin()
|
||||
if tx.Error != nil {
|
||||
t.Fatal(tx.Error)
|
||||
}
|
||||
t.Cleanup(func() { tx.Rollback() })
|
||||
return tx, cfg
|
||||
}
|
||||
func featureCall(handler gin.HandlerFunc, method, path string, body any, actorID, id string) *httptest.ResponseRecorder {
|
||||
raw, _ := json.Marshal(body)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(method, path, bytes.NewReader(raw))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Set("actor_id", actorID)
|
||||
c.Params = gin.Params{{Key: "id", Value: id}}
|
||||
handler(c)
|
||||
return rec
|
||||
}
|
||||
func pairFrom(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
if rec.Code != 200 && rec.Code != 201 {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
var result struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result.Data
|
||||
}
|
||||
func TestAccessTokenTwoHours(t *testing.T) {
|
||||
api := New(nil, nil, &config.Config{JWT: config.JWTConfig{Secret: "test", ExpireHours: 168}})
|
||||
raw, err := api.token(uuid.NewString(), "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &claims{}
|
||||
_, err = jwt.ParseWithClaims(raw, c, func(*jwt.Token) (any, error) { return []byte("test"), nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.ExpiresAt.Sub(c.IssuedAt.Time) != 2*time.Hour {
|
||||
t.Fatal("access token lifetime is not two hours")
|
||||
}
|
||||
}
|
||||
func TestRefreshRotationPostgres(t *testing.T) {
|
||||
tx, cfg := featureDB(t)
|
||||
a := New(tx, nil, cfg)
|
||||
user := model.User{OpenID: "refresh-test:" + uuid.NewString(), Nickname: "测试家长"}
|
||||
if err := tx.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pair, err := a.newSession(user.ID, "user")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := pair["refresh_token"].(string)
|
||||
var session model.RefreshSession
|
||||
if err := tx.Where("token_hash = ?", refreshHash(old)).First(&session).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if session.TokenHash == old {
|
||||
t.Fatal("raw refresh stored")
|
||||
}
|
||||
call := func(raw, role string) *httptest.ResponseRecorder {
|
||||
return featureCall(a.refreshToken(role), "POST", "/refresh", gin.H{"refresh_token": raw}, "", "")
|
||||
}
|
||||
if rec := call(old, "admin"); rec.Code != 401 {
|
||||
t.Fatal("user credential accepted for admin")
|
||||
}
|
||||
next := pairFrom(t, call(old, "user"))
|
||||
fresh := next["refresh_token"].(string)
|
||||
if fresh == old || next["refresh_expires_at"].(float64) < float64(time.Now().Add(2*time.Hour-time.Second).Unix()) {
|
||||
t.Fatal("rotation or two-hour expiry wrong")
|
||||
}
|
||||
if rec := call(old, "user"); rec.Code != 401 {
|
||||
t.Fatal("old credential reusable")
|
||||
}
|
||||
revoked := featureCall(a.revokeRefresh("user"), "POST", "/logout", gin.H{"refresh_token": fresh}, "", "")
|
||||
if revoked.Code != 200 {
|
||||
t.Fatal(revoked.Body.String())
|
||||
}
|
||||
if rec := call(fresh, "user"); rec.Code != 401 {
|
||||
t.Fatal("logout failed to revoke")
|
||||
}
|
||||
// Logout invalidates the access token tied to the same session too.
|
||||
r := gin.New()
|
||||
r.GET("/protected", a.requireRole("user"), func(c *gin.Context) { ok(c, true) })
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+next["token"].(string))
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
if rec.Code != 401 {
|
||||
t.Fatal("revoked session access accepted")
|
||||
}
|
||||
pair, err = a.newSession(user.ID, "user")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fresh = pair["refresh_token"].(string)
|
||||
if err := tx.Model(&user).Update("disabled", true).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := call(fresh, "user"); rec.Code != 401 {
|
||||
t.Fatal("disabled user refreshed")
|
||||
}
|
||||
if err := tx.Model(&user).Update("disabled", false).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tx.Model(&model.RefreshSession{}).Where("token_hash = ?", refreshHash(fresh)).Update("expires_at", time.Now().Add(-time.Minute))
|
||||
if rec := call(fresh, "user"); rec.Code != 401 {
|
||||
t.Fatal("expired credential refreshed")
|
||||
}
|
||||
admin := model.Admin{Username: "refresh-test:" + uuid.NewString(), PasswordHash: "fixture"}
|
||||
if err := tx.Create(&admin).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pair, err = a.newSession(admin.ID, "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pairFrom(t, call(pair["refresh_token"].(string), "admin"))
|
||||
}
|
||||
func TestBreedCatalogPostgres(t *testing.T) {
|
||||
tx, cfg := featureDB(t)
|
||||
a := New(tx, nil, cfg)
|
||||
if added, err := breeds.Seed(tx); err != nil || added != 0 {
|
||||
t.Fatalf("idempotent seed: %d %v", added, err)
|
||||
}
|
||||
var source model.Breed
|
||||
if err := tx.Where("name = ?", "彭布罗克威尔士柯基").First(&source).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tx.Model(&source).Updates(map[string]any{"enabled": false, "name": "改名柯基测试"})
|
||||
if added, err := breeds.Seed(tx); err != nil || added != 0 {
|
||||
t.Fatalf("seed restored renamed record: %d %v", added, err)
|
||||
}
|
||||
tx.First(&source, "id = ?", source.ID)
|
||||
if source.Enabled || source.Name != "改名柯基测试" {
|
||||
t.Fatal("seed overwrote edits")
|
||||
}
|
||||
rec := featureCall(a.listBreeds, "GET", "/breeds?q=bianmu", nil, "", "")
|
||||
if rec.Code != 200 {
|
||||
t.Fatal(rec.Body.String())
|
||||
}
|
||||
// Short initials and Chinese aliases must both work.
|
||||
for _, q := range []string{"边牧", "bjm", "bianjing"} {
|
||||
rec = featureCall(a.listBreeds, "GET", "/breeds?q="+q, nil, "", "")
|
||||
if rec.Code != 200 || !bytes.Contains(rec.Body.Bytes(), []byte("边境牧羊犬")) {
|
||||
t.Fatalf("search %s: %s", q, rec.Body)
|
||||
}
|
||||
}
|
||||
name := "测试品种" + uuid.NewString()
|
||||
body := gin.H{"name": name, "aliases": "testbreed", "enabled": false}
|
||||
created := pairFrom(t, featureCall(a.createBreed, "POST", "/breeds", body, "", ""))
|
||||
id := created["id"].(string)
|
||||
if created["enabled"].(bool) {
|
||||
t.Fatal("disabled creation ignored")
|
||||
}
|
||||
pairFrom(t, featureCall(a.setBreedEnabled, "PUT", "/breeds", gin.H{"enabled": true}, "", id))
|
||||
body["enabled"] = true
|
||||
body["name"] = name + "更新"
|
||||
pairFrom(t, featureCall(a.updateBreed, "PUT", "/breeds", body, "", id))
|
||||
// Duplicate request runs in a savepoint because PostgreSQL aborts a transaction on unique violations.
|
||||
tx.SavePoint("duplicate_breed")
|
||||
rec = featureCall(a.createBreed, "POST", "/breeds", body, "", "")
|
||||
if rec.Code != 409 {
|
||||
t.Fatalf("duplicate status %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
tx.RollbackTo("duplicate_breed")
|
||||
user := model.User{OpenID: "breed-test:" + uuid.NewString(), Nickname: "测试家长"}
|
||||
tx.Create(&user)
|
||||
selected, valid := a.buildPet(testPetContext(user.ID, ""), petInput{Name: "测试毛孩", Breed: "forged", BreedID: &id})
|
||||
if !valid || selected.Breed != body["name"] {
|
||||
t.Fatal("selected name not resolved by ID")
|
||||
}
|
||||
custom, valid := a.buildPet(testPetContext(user.ID, ""), petInput{Name: "测试毛孩", Breed: "未收录地方犬"})
|
||||
if !valid || custom.BreedID != nil || custom.Breed != "未收录地方犬" {
|
||||
t.Fatal("manual breed unavailable")
|
||||
}
|
||||
selected.UserID = user.ID
|
||||
tx.Create(&selected)
|
||||
tx.Model(&model.Breed{}).Where("id = ?", id).Update("enabled", false)
|
||||
_, valid = a.buildPet(testPetContext(user.ID, ""), petInput{Name: "新宠物", BreedID: &id})
|
||||
if valid {
|
||||
t.Fatal("disabled breed newly selected")
|
||||
}
|
||||
_, valid = a.buildPet(testPetContext(user.ID, selected.ID), petInput{Name: "旧宠物", BreedID: &id})
|
||||
if !valid {
|
||||
t.Fatal("existing disabled breed cannot be retained")
|
||||
}
|
||||
}
|
||||
func testPetContext(actorID, id string) *gin.Context {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Set("actor_id", actorID)
|
||||
c.Params = gin.Params{{Key: "id", Value: id}}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestBreedSourcesAndEditorialProtection(t *testing.T) {
|
||||
tx, cfg := featureDB(t)
|
||||
a := New(tx, nil, cfg)
|
||||
var breed model.Breed
|
||||
if err := tx.Where("name = ?", "彭布罗克威尔士柯基").First(&breed).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail := pairFrom(t, featureCall(a.breedDetail, "GET", "/breeds/"+breed.ID, nil, "", breed.ID))
|
||||
if len(detail["references"].([]any)) != 4 {
|
||||
t.Fatal("Corgi references missing")
|
||||
}
|
||||
if detail["field_sources"].(map[string]any)["exercise"] != "rkc" {
|
||||
t.Fatal("exercise attribution wrong")
|
||||
}
|
||||
body := gin.H{"name": breed.Name, "english_name": breed.EnglishName, "enabled": true, "exercise": "编辑安排的散步活动", "summary": "新的中文介绍", "traits": gin.H{"运动活力": 2}}
|
||||
pairFrom(t, featureCall(a.updateBreed, "PUT", "/breeds/"+breed.ID, body, "", breed.ID))
|
||||
if _, err := breeds.Seed(tx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail = pairFrom(t, featureCall(a.breedDetail, "GET", "/breeds/"+breed.ID, nil, "", breed.ID))
|
||||
if detail["exercise"] != "编辑安排的散步活动" || detail["summary"] != "新的中文介绍" || detail["height"] != "" {
|
||||
t.Fatal("import overwrote manual edits or cleared fields")
|
||||
}
|
||||
if detail["field_sources"].(map[string]any)["exercise"] != "editorial" || len(detail["references"].([]any)) != 4 {
|
||||
t.Fatal("edited provenance or reference uniqueness lost")
|
||||
}
|
||||
body["traits"] = gin.H{"运动活力": 6}
|
||||
if rec := featureCall(a.updateBreed, "PUT", "/breeds/"+breed.ID, body, "", breed.ID); rec.Code != 400 {
|
||||
t.Fatalf("invalid rating accepted: %d", rec.Code)
|
||||
}
|
||||
tx.Model(&model.Breed{}).Where("id = ?", breed.ID).Update("enabled", false)
|
||||
if rec := featureCall(a.breedDetail, "GET", "/breeds/"+breed.ID, nil, "", breed.ID); rec.Code != 404 {
|
||||
t.Fatal("disabled encyclopedia visible")
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func (a *API) petView(pet model.Pet) gin.H {
|
||||
if pet.BirthDate != nil {
|
||||
birthDate = pet.BirthDate.Format("2006-01-02")
|
||||
}
|
||||
return gin.H{"id": pet.ID, "name": pet.Name, "species": pet.Species, "breed": pet.Breed,
|
||||
return gin.H{"id": pet.ID, "name": pet.Name, "species": pet.Species, "breed": pet.Breed, "breed_id": pet.BreedID,
|
||||
"gender": pet.Gender, "birth_date": birthDate, "weight_kg": pet.WeightKg,
|
||||
"avatar_file_id": pet.AvatarFileID, "avatar_url": a.fileURL(pet.AvatarFileID),
|
||||
"coat": pet.Coat, "allergies": pet.Allergies, "care_notes": pet.CareNotes,
|
||||
|
||||
@@ -15,6 +15,7 @@ type petInput struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Species string `json:"species"`
|
||||
Breed string `json:"breed"`
|
||||
BreedID *string `json:"breed_id"`
|
||||
Gender string `json:"gender"`
|
||||
BirthDate string `json:"birth_date"`
|
||||
WeightKg float64 `json:"weight_kg"`
|
||||
@@ -81,6 +82,7 @@ func (a *API) updatePet(c *gin.Context) {
|
||||
}
|
||||
pet.Name, pet.Species, pet.Breed, pet.Gender = updated.Name, updated.Species, updated.Breed, updated.Gender
|
||||
pet.BirthDate, pet.WeightKg, pet.AvatarFileID = updated.BirthDate, updated.WeightKg, updated.AvatarFileID
|
||||
pet.BreedID = updated.BreedID
|
||||
pet.Coat, pet.Allergies, pet.CareNotes = updated.Coat, updated.Allergies, updated.CareNotes
|
||||
if err := a.db.Save(&pet).Error; err != nil {
|
||||
fail(c, http.StatusInternalServerError, "更新宠物失败")
|
||||
@@ -110,6 +112,27 @@ func (a *API) buildPet(c *gin.Context, in petInput) (model.Pet, bool) {
|
||||
return pet, false
|
||||
}
|
||||
}
|
||||
in.Breed = strings.TrimSpace(in.Breed)
|
||||
if len([]rune(in.Breed)) > 128 {
|
||||
fail(c, http.StatusBadRequest, "品种名称过长")
|
||||
return pet, false
|
||||
}
|
||||
if in.BreedID != nil {
|
||||
var breed model.Breed
|
||||
if !validID(*in.BreedID) || a.db.First(&breed, "id = ?", *in.BreedID).Error != nil {
|
||||
fail(c, http.StatusBadRequest, "品种不存在,请重新选择或手动填写")
|
||||
return pet, false
|
||||
}
|
||||
// A disabled breed can be retained on an existing pet, but not newly selected.
|
||||
if !breed.Enabled {
|
||||
var current model.Pet
|
||||
if !validID(c.Param("id")) || a.db.Where("id = ? AND user_id = ? AND breed_id = ?", c.Param("id"), actor(c), *in.BreedID).First(¤t).Error != nil {
|
||||
fail(c, http.StatusBadRequest, "该品种已停用,请手动填写或重新选择")
|
||||
return pet, false
|
||||
}
|
||||
}
|
||||
in.Breed = breed.Name
|
||||
}
|
||||
var birthday *time.Time
|
||||
if in.BirthDate != "" {
|
||||
d, err := parseDay(in.BirthDate)
|
||||
@@ -120,7 +143,7 @@ func (a *API) buildPet(c *gin.Context, in petInput) (model.Pet, bool) {
|
||||
birthday = &d
|
||||
}
|
||||
pet = model.Pet{Name: in.Name, Species: in.Species, Breed: strings.TrimSpace(in.Breed),
|
||||
Gender: in.Gender, BirthDate: birthday, WeightKg: in.WeightKg,
|
||||
BreedID: in.BreedID, Gender: in.Gender, BirthDate: birthday, WeightKg: in.WeightKg,
|
||||
AvatarFileID: in.AvatarFileID, Coat: in.Coat, Allergies: in.Allergies, CareNotes: in.CareNotes}
|
||||
return pet, true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var errRefreshInvalid = errors.New("refresh credential invalid")
|
||||
|
||||
func refreshCredential() (string, error) {
|
||||
data := make([]byte, 32)
|
||||
if _, err := rand.Read(data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(data), nil
|
||||
}
|
||||
func refreshHash(value string) string {
|
||||
hash := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
func (a *API) sessionPair(id, role, sessionID, refresh string, expires time.Time) (gin.H, error) {
|
||||
token, err := a.sessionToken(id, role, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gin.H{"token": token, "refresh_token": refresh, "expires_in": 7200, "expires_at": time.Now().Add(2 * time.Hour).Unix(), "refresh_expires_at": expires.Unix()}, nil
|
||||
}
|
||||
func (a *API) newSession(id, role string) (gin.H, error) {
|
||||
raw, err := refreshCredential()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expires := time.Now().Add(2 * time.Hour)
|
||||
sessionID := uuid.NewString()
|
||||
pair, err := a.sessionPair(id, role, sessionID, raw, expires)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Opportunistic cleanup bounded by indexed expiry, without a background scheduler.
|
||||
if err := a.db.Where("expires_at <= ?", time.Now()).Delete(&model.RefreshSession{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = a.db.Create(&model.RefreshSession{Base: model.Base{ID: sessionID}, ActorID: id, Role: role, TokenHash: refreshHash(raw), ExpiresAt: expires}).Error
|
||||
return pair, err
|
||||
}
|
||||
func readRefresh(c *gin.Context) (string, bool) {
|
||||
var in struct {
|
||||
Token string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
if c.ShouldBindJSON(&in) != nil || len(in.Token) != 43 {
|
||||
fail(c, http.StatusUnauthorized, "登录已过期,请重新登录")
|
||||
return "", false
|
||||
}
|
||||
return in.Token, true
|
||||
}
|
||||
func (a *API) refreshToken(role string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
raw, valid := readRefresh(c)
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
var pair gin.H
|
||||
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||
var session model.RefreshSession
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("token_hash = ? AND role = ?", refreshHash(raw), role).First(&session).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errRefreshInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if session.Revoked || !session.ExpiresAt.After(time.Now()) {
|
||||
return errRefreshInvalid
|
||||
}
|
||||
if role == "user" {
|
||||
var user model.User
|
||||
err = tx.Select("id", "disabled").First(&user, "id = ?", session.ActorID).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) || (err == nil && user.Disabled) {
|
||||
return errRefreshInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
var count int64
|
||||
if err := tx.Model(&model.Admin{}).Where("id = ?", session.ActorID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 1 {
|
||||
return errRefreshInvalid
|
||||
}
|
||||
}
|
||||
next, err := refreshCredential()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nextExpiry := time.Now().Add(2 * time.Hour)
|
||||
pair, err = a.sessionPair(session.ActorID, role, session.ID, next, nextExpiry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Rotate inside the lock. The old credential can only succeed once.
|
||||
return tx.Model(&session).Updates(map[string]any{"token_hash": refreshHash(next), "expires_at": nextExpiry}).Error
|
||||
})
|
||||
if errors.Is(err, errRefreshInvalid) {
|
||||
fail(c, 401, "登录已过期,请重新登录")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
fail(c, 500, "暂时无法刷新登录,请重试")
|
||||
return
|
||||
}
|
||||
ok(c, pair)
|
||||
}
|
||||
}
|
||||
func (a *API) revokeRefresh(role string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
raw, valid := readRefresh(c)
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
if err := a.db.Model(&model.RefreshSession{}).Where("token_hash = ? AND role = ?", refreshHash(raw), role).Update("revoked", true).Error; err != nil {
|
||||
fail(c, 500, "退出登录失败")
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"logged_out": true})
|
||||
}
|
||||
}
|
||||
@@ -37,11 +37,17 @@ func (a *API) Register(r *gin.Engine) {
|
||||
api.POST("/auth/login", a.login)
|
||||
api.POST("/auth/dev", a.devLogin)
|
||||
api.POST("/admin/login", a.adminLogin)
|
||||
api.POST("/auth/refresh", a.refreshToken("user"))
|
||||
api.POST("/admin/refresh", a.refreshToken("admin"))
|
||||
api.POST("/auth/logout", a.revokeRefresh("user"))
|
||||
api.POST("/admin/logout", a.revokeRefresh("admin"))
|
||||
|
||||
user := api.Group("")
|
||||
user.Use(a.requireRole("user"))
|
||||
user.GET("/environment", a.environment)
|
||||
user.GET("/me", a.me)
|
||||
user.GET("/breeds", a.listBreeds)
|
||||
user.GET("/breeds/:id", a.breedDetail)
|
||||
user.GET("/pets", a.listPets)
|
||||
user.POST("/pets", a.createPet)
|
||||
user.GET("/pets/:id", a.getPet)
|
||||
@@ -72,6 +78,10 @@ func (a *API) Register(r *gin.Engine) {
|
||||
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(a.requireRole("admin"))
|
||||
admin.GET("/breeds", a.adminBreeds)
|
||||
admin.POST("/breeds", a.createBreed)
|
||||
admin.PUT("/breeds/:id", a.updateBreed)
|
||||
admin.PUT("/breeds/:id/enabled", a.setBreedEnabled)
|
||||
admin.GET("/stats", a.adminStats)
|
||||
admin.GET("/users", a.adminUsers)
|
||||
admin.GET("/users/:id/pets", a.adminUserPets)
|
||||
@@ -86,18 +96,16 @@ func (a *API) Register(r *gin.Engine) {
|
||||
}
|
||||
|
||||
type claims struct {
|
||||
Role string `json:"role"`
|
||||
Role string `json:"role"`
|
||||
SessionID string `json:"sid,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func (a *API) token(id, role string) (string, error) {
|
||||
expire := a.cfg.JWT.ExpireHours
|
||||
if expire <= 0 {
|
||||
expire = 168
|
||||
}
|
||||
func (a *API) token(id, role string) (string, error) { return a.sessionToken(id, role, "") }
|
||||
func (a *API) sessionToken(id, role, sessionID string) (string, error) {
|
||||
now := time.Now()
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims{Role: role, RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: id, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(expire) * time.Hour)),
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims{Role: role, SessionID: sessionID, RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: uuid.NewString(), Subject: id, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(2 * time.Hour)),
|
||||
}})
|
||||
return t.SignedString([]byte(a.cfg.JWT.Secret))
|
||||
}
|
||||
@@ -127,6 +135,20 @@ func (a *API) requireRole(role string) gin.HandlerFunc {
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
// Reject credentials issued with the old seven-day lifetime.
|
||||
if cl.IssuedAt == nil || cl.ExpiresAt == nil || cl.ExpiresAt.Sub(cl.IssuedAt.Time) > 2*time.Hour {
|
||||
fail(c, http.StatusUnauthorized, "登录已过期")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if cl.SessionID != "" {
|
||||
var count int64
|
||||
if !validID(cl.SessionID) || a.db.Model(&model.RefreshSession{}).Where("id = ? AND actor_id = ? AND role = ? AND revoked = false AND expires_at > ?", cl.SessionID, cl.Subject, role, time.Now()).Count(&count).Error != nil || count != 1 {
|
||||
fail(c, http.StatusUnauthorized, "登录已过期")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
if role == "user" {
|
||||
var user model.User
|
||||
if err := a.db.Select("id", "disabled").First(&user, "id = ?", cl.Subject).Error; err != nil || user.Disabled {
|
||||
|
||||
@@ -47,12 +47,64 @@ type Admin struct {
|
||||
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||
}
|
||||
|
||||
// RefreshSession stores only a hash of the rotating refresh credential.
|
||||
type RefreshSession struct {
|
||||
Base
|
||||
ActorID string `gorm:"type:uuid;index;not null" json:"-"`
|
||||
Role string `gorm:"size:16;index;not null" json:"-"`
|
||||
TokenHash string `gorm:"size:64;uniqueIndex;not null" json:"-"`
|
||||
ExpiresAt time.Time `gorm:"index;not null" json:"-"`
|
||||
Revoked bool `gorm:"not null;default:false" json:"-"`
|
||||
}
|
||||
|
||||
type Breed struct {
|
||||
Base
|
||||
LifeSpan string `gorm:"size:128" json:"life_span"`
|
||||
Exercise string `gorm:"size:255" json:"exercise"`
|
||||
Grooming string `gorm:"size:255" json:"grooming"`
|
||||
EditorialLocked bool `gorm:"not null;default:false" json:"editorial_locked"`
|
||||
FieldSources map[string]string `gorm:"serializer:json;type:jsonb" json:"field_sources"`
|
||||
References []BreedReference `gorm:"foreignKey:BreedID" json:"references,omitempty"`
|
||||
Origin string `gorm:"size:128" json:"origin"`
|
||||
BreedGroup string `gorm:"size:255" json:"breed_group"`
|
||||
FCINumber string `gorm:"size:16" json:"fci_number"`
|
||||
Height string `gorm:"size:128" json:"height"`
|
||||
Weight string `gorm:"size:128" json:"weight"`
|
||||
Summary string `gorm:"type:text" json:"summary"`
|
||||
Temperament string `gorm:"size:255" json:"temperament"`
|
||||
Traits string `gorm:"type:text" json:"traits"`
|
||||
CareNotes string `gorm:"type:text" json:"care_notes"`
|
||||
EncyclopediaUpdatedAt string `gorm:"size:10" json:"encyclopedia_updated_at"`
|
||||
Name string `gorm:"size:128;uniqueIndex;not null" json:"name"`
|
||||
EnglishName string `gorm:"size:128" json:"english_name"`
|
||||
Aliases string `gorm:"size:512" json:"aliases"`
|
||||
Pinyin string `gorm:"size:1024;index;not null" json:"pinyin"`
|
||||
PinyinInitials string `gorm:"size:128" json:"pinyin_initials"`
|
||||
Initial string `gorm:"size:1;index;not null" json:"initial"`
|
||||
Enabled bool `gorm:"not null;default:true" json:"enabled"`
|
||||
Source string `gorm:"size:128" json:"source"`
|
||||
SourceKey *string `gorm:"size:128;uniqueIndex" json:"source_key"`
|
||||
SourceURL string `gorm:"size:512" json:"source_url"`
|
||||
}
|
||||
|
||||
// Facts contain short structured facts, never copied articles or remote images.
|
||||
type BreedReference struct {
|
||||
Base
|
||||
BreedID string `gorm:"type:uuid;uniqueIndex:idx_breed_provider;not null" json:"breed_id"`
|
||||
Provider string `gorm:"size:32;uniqueIndex:idx_breed_provider;not null" json:"provider"`
|
||||
ExternalID string `gorm:"size:128;not null" json:"external_id"`
|
||||
URL string `gorm:"size:512;not null" json:"url"`
|
||||
RetrievedAt string `gorm:"size:10;not null" json:"retrieved_at"`
|
||||
Facts map[string]string `gorm:"serializer:json;type:jsonb;not null" json:"facts"`
|
||||
}
|
||||
|
||||
type Pet struct {
|
||||
Base
|
||||
UserID string `gorm:"type:uuid;index;not null" json:"user_id"`
|
||||
Name string `gorm:"size:64;not null" json:"name"`
|
||||
Species string `gorm:"size:16;not null;default:dog" json:"species"`
|
||||
Breed string `gorm:"size:64" json:"breed"`
|
||||
Breed string `gorm:"size:128" json:"breed"`
|
||||
BreedID *string `gorm:"type:uuid;index" json:"breed_id"`
|
||||
Gender string `gorm:"size:16" json:"gender"`
|
||||
BirthDate *time.Time `gorm:"type:date" json:"birth_date"`
|
||||
WeightKg float64 `gorm:"type:numeric(6,2)" json:"weight_kg"`
|
||||
@@ -140,7 +192,7 @@ type MeetupPet struct {
|
||||
|
||||
func All() []any {
|
||||
return []any{
|
||||
&File{}, &User{}, &Admin{}, &Pet{}, &CareTask{}, &TaskCompletion{},
|
||||
&File{}, &User{}, &Admin{}, &RefreshSession{}, &Breed{}, &BreedReference{}, &Pet{}, &CareTask{}, &TaskCompletion{},
|
||||
&Walk{}, &WalkPet{}, &WalkPoint{}, &Meetup{}, &MeetupParticipant{}, &MeetupPet{},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user