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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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>
+3
View File
@@ -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"},
+33
View File
@@ -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`。内容无变化时跳过资料更新。导入不会恢复管理员改名或停用的品种,人工编辑后的展示内容不会被刷新覆盖。
+102
View File
@@ -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)
}
+59
View File
@@ -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
+100
View File
@@ -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
+114
View File
@@ -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"
}
]
+8 -3
View File
@@ -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
}
+4
View File
@@ -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
}
+6 -4
View File
@@ -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) {
+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)
}
@@ -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")
}
}
+1 -1
View File
@@ -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,
+24 -1
View File
@@ -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(&current).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
}
+139
View File
@@ -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})
}
}
+30 -8
View File
@@ -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 {
+54 -2
View File
@@ -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{},
}
}