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
+17 -1
View File
@@ -15,7 +15,7 @@
## 本地启动
1. `cp configs/config.example.yaml configs/config.yaml`,设置 PostgreSQL、MinIO、JWT 和微信参数。配置文件不进入 Git。
1. `cp configs/config.example.yaml configs/config.yaml`,设置 PostgreSQL、MinIO、JWT 和微信参数。
2. `cd web/admin && npm ci && npm run build`。
3. 若数据库还不存在,在 `pets-be` 目录执行一次 `go run ./cmd/dbinit`。
4. 执行 `go run ./cmd/server`。
@@ -33,3 +33,19 @@ GORM 使用 `sundynix_` 表前缀、单数 snake_case 表名和字段名,例
## 部署
`../deploy/docker-compose.yml` 使用外部 PostgreSQL 和 MinIO;把 `../deploy/.env.example` 复制为 `.env` 后设置凭据。`pet.sundynix.cn` 用于业务 API;`s3.sundynix.cn` 用于文件上传和图片直链。URL 由 MinIO endpoint、bucket 和 object key 拼接,在上传时写入文件表,格式为 `https://s3.sundynix.cn/sundynix-pet/<object_key>`。`res.sundynix.cn/browser/...` 是 MinIO 控制台页面,不能作为图片直链。
## 管理端路由与犬种百科
菜单使用独立路由:`/admin/overview`、`/admin/users`、`/admin/pets`、`/admin/breeds`、`/admin/walks`、`/admin/meetups`、`/admin/tasks`、`/admin/files`;登录页 `/admin/login`。根路径自动进入工作台或登录页;Go 的 SPA 回退支持刷新和直达链接。业务接口仍在 `/api`,无需额外 API 前缀。
犬种目录自动导入 350 项(CKU 去重 347 项,另补充三种实用分类),按名称拼音 A–Z 排序。选品种使用 `breed_id`,手动填写保留 `breed` 名称并清空 `breed_id`。后台提供新增、编辑、启用、停用、搜索及每页十条分页。停用品种不影响已有宠物档案。
CKU 当前返回完整详情的 88 个犬种已整理基本资料、简短改写介绍和来源链接;另三项补充分类提供原创介绍。资料缺失时显示待补充,后台可以编辑百科字段。图片与长篇犬种标准未转载。
## 登录刷新
管理端和小程序的访问 token、refresh token 都为 **2 小时**。请求前发现不足一分钟即刷新;401 也会尝试刷新并仅重试一次。刷新凭证每次轮换,数据库只存 SHA-256 摘要;两个角色的凭证不能混用,停用账号、过期及退出会话都不能继续刷新。后台退出会撤销会话和其访问 token。
刷新成功后,两种凭证重新计时两小时;超过两小时未使用则重新登录。小程序自动重新微信登录,管理端回到登录页并保留原目标路由。并发接口共用刷新请求,支持 Web Locks 的浏览器还会跨标签页串行刷新;网络失败保留凭证以便重试。
接口:`POST /api/auth/refresh`、`POST /api/admin/refresh`,请求体为 `{ "refresh_token": "..." }`。登录与刷新响应包含 `token`、`refresh_token`、`expires_in: 7200`、`expires_at`、`refresh_expires_at`(Unix 秒)。旧七天 token 会要求重新登录。时长按业务约束固定两小时,旧环境变量不会把期限拉长。
+36
View File
@@ -0,0 +1,36 @@
// seedbreeds imports the embedded CKU breed catalog without overwriting administrator edits.
package main
import (
"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"
"log"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
}
db, err := database.Open(cfg.Postgres, true)
if err != nil {
log.Fatal(err)
}
// Only the schema needed by these features, leaving unrelated application records intact.
if err := db.AutoMigrate(&model.Breed{}, &model.BreedReference{}, &model.Pet{}, &model.RefreshSession{}); err != nil {
log.Fatal(err)
}
added, err := breeds.Seed(db)
if err != nil {
log.Fatal(err)
}
var total int64
if err := db.Model(&model.Breed{}).Count(&total).Error; err != nil {
log.Fatal(err)
}
var sources int64
db.Model(&model.BreedReference{}).Count(&sources)
log.Printf("breed catalog imported: added=%d total=%d references=%d", added, total, sources)
}
+2 -1
View File
@@ -19,7 +19,8 @@ minio:
jwt:
secret: "" # 用 PETS_JWT_SECRET 注入
expire_hours: 168
expire_hours: 2
refresh_hours: 2
wechat:
app_id: ""
+2
View File
@@ -18,6 +18,8 @@ minio:
use_ssl: true
jwt:
expire_hours: 2
refresh_hours: 2
secret: kFdKDNCXpyJO2W+htdxobhHNzUIYuwcaZV94aJbIGHQ= # 生产用 PETS_JWT_SECRET 覆盖
wechat:
+1
View File
@@ -7,6 +7,7 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/minio/minio-go/v7 v7.2.1
github.com/mozillazg/go-pinyin v0.21.0
github.com/spf13/viper v1.21.0
golang.org/x/crypto v0.53.0
gorm.io/driver/postgres v1.6.0
+2
View File
@@ -86,6 +86,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mozillazg/go-pinyin v0.21.0 h1:Wo8/NT45z7P3er/9YSLHA3/kjZzbLz5hR7i+jGeIGao=
github.com/mozillazg/go-pinyin v0.21.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
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{},
}
}
+204
View File
@@ -0,0 +1,204 @@
"""Collect official factual metadata. Run from repo root; no third-party packages.
HTML is cached outside the repository. No source prose or photos are persisted.
Mappings use FCI numbers and exact English names, never fuzzy breed matching.
"""
import argparse
from datetime import date
import concurrent.futures
import hashlib
import html
from html.parser import HTMLParser
import json
from pathlib import Path
import re
import unicodedata
import urllib.request
from urllib.parse import urljoin
ROOT = Path(__file__).resolve().parents[1] / 'internal/breeds'
CACHE = Path('/private/tmp/pawprint-breed-sources')
CACHE.mkdir(exist_ok=True)
DATE = date.today().isoformat()
class Node:
def __init__(self, tag='', attrs=None):
self.tag, self.attrs, self.children = tag, dict(attrs or []), []
def text(self):
return ''.join(c if isinstance(c, str) else c.text() for c in self.children)
def all(self):
yield self
for c in self.children:
if isinstance(c, Node):
yield from c.all()
class DOM(HTMLParser):
def __init__(self, text):
super().__init__(convert_charrefs=True)
self.root = Node(); self.stack = [self.root]; self.feed(text)
def handle_starttag(self, tag, attrs):
n = Node(tag, attrs); self.stack[-1].children.append(n)
if tag not in ('br','img','hr','meta','link','input','source','wbr','area','embed'):
self.stack.append(n)
def handle_startendtag(self, tag, attrs):
self.stack[-1].children.append(Node(tag,attrs))
def handle_endtag(self, tag):
for i in range(len(self.stack)-1,0,-1):
if self.stack[i].tag == tag:
self.stack = self.stack[:i]; return
def handle_data(self, data):
self.stack[-1].children.append(data)
def clean(value):
return re.sub(r'\s+', ' ', html.unescape(re.sub('<[^>]*>',' ',str(value or '')))).strip()
def norm(value):
return re.sub('[^a-z0-9]', '', unicodedata.normalize('NFKD', value).encode('ascii','ignore').decode().lower())
def fetch(url):
file = CACHE / (hashlib.sha256(url.encode()).hexdigest()+'.html')
if file.exists() and date.fromtimestamp(file.stat().st_mtime).isoformat()==DATE: return file.read_text()
req = urllib.request.Request(url, headers={'User-Agent':'PawprintPlan/1.0 (breed facts)'})
with urllib.request.urlopen(req, timeout=20) as r: text = r.read().decode('utf-8','replace')
file.write_text(text); return text
def references(provider, key, url, facts):
return {'provider':provider,'external_id':str(key),'url':url,'retrieved_at':DATE,
'facts':{k:clean(v) for k,v in facts.items() if v is not None and clean(v)}}
GROUPS=['牧羊犬与牧牛犬组','宾莎犬、雪纳瑞、獒犬与瑞士山地牧牛犬组','梗犬组','腊肠犬组','尖嘴犬与原始犬种组','嗅觉猎犬与相关犬种组','指示猎犬组','寻回犬、搜寻犬与水猎犬组','伴侣犬与玩具犬组','灵缇(视觉猎犬)组']
COUNTRIES={'AUSTRALIA':'澳大利亚','BELGIUM':'比利时','CROATIA':'克罗地亚','CZECHOSLOVAKIA':'捷克斯洛伐克','FRANCE':'法国','GERMANY':'德国','GREAT BRITAIN':'英国','HUNGARY':'匈牙利','ITALY':'意大利','POLAND':'波兰','PORTUGAL':'葡萄牙','ROMANIA':'罗马尼亚','RUSSIA':'俄罗斯','SLOVAKIA':'斯洛伐克','SPAIN':'西班牙','SWITZERLAND':'瑞士','THE NETHERLANDS':'荷兰','NETHERLANDS':'荷兰','AUSTRIA':'奥地利','DENMARK':'丹麦','FINLAND':'芬兰','IRELAND':'爱尔兰','NORWAY':'挪威','SWEDEN':'瑞典','USA':'美国','UNITED STATES OF AMERICA':'美国','CANADA':'加拿大','JAPAN':'日本','CHINA':'中国','KOREA':'韩国','ISRAEL':'以色列','MEXICO':'墨西哥','BRAZIL':'巴西','ARGENTINA':'阿根廷','AFGHANISTAN':'阿富汗','MIDDLE EAST':'中东','TURKEY':'土耳其','THAILAND':'泰国','SOUTH AFRICA':'南非','MOROCCO':'摩洛哥','MALI':'马里','ICELAND':'冰岛','GREENLAND':'格陵兰','MALTA':'马耳他','BOSNIA AND HERZEGOVINA':'波斯尼亚和黑塞哥维那','SERBIA':'塞尔维亚','MONTENEGRO':'黑山','GREECE':'希腊','URUGUAY':'乌拉圭','PERU':'秘鲁','INDONESIA':'印度尼西亚','LITHUANIA':'立陶宛','ESTONIA':'爱沙尼亚','BELGIUM, FRANCE':'比利时、法国','CENTRAL AFRICA':'非洲中部','CUBA':'古巴','CZECH REPUBLIC':'捷克','GERMANY, SWITZERLAND':'德国、瑞士','KOREA (REPUBLIC OF)':'韩国','NORTH MACEDONIA, SERBIA':'北马其顿、塞尔维亚','RUSSIAN FEDERATION':'俄罗斯','SLOVENIA':'斯洛文尼亚','TAIWAN':'中国台湾','TIBET (CHINA)':'中国西藏'}
def fci_group(link):
number, url=link
nodes=list(DOM(fetch(url)).root.all()); country=''; records={}
for n in nodes:
node_id=n.attrs.get('id','')
if re.search(r'_PaysLabel_\d+$',node_id): country=re.sub(r'^\d+\.\s*','',clean(n.text()))
if n.tag=='a' and 'RaceHyperLink' in node_id:
match=re.search(r'\((\d+)\)',n.text())
if not match: continue
key=match.group(1); title=clean(n.text()); en=re.findall(r'\(([^\d][^)]*)\)',title)
name = re.sub(r'-\d+\.html$', '', n.attrs['href'].split('/')[-1]).replace('-', ' ')
facts={'english_name':name.title(),'origin':COUNTRIES.get(country,country.title()),'breed_group':GROUPS[number-1],'fci_number':key}
records[key]=references('fci',key,urljoin(url,n.attrs['href']),facts)
return records
def akc_page(item):
name,url=item
nodes=list(DOM(fetch(url)).root.all()); props=None
for n in nodes:
if n.attrs.get('data-js-component')=='breedPage': props=json.loads(n.attrs['data-js-props']);break
if not props: return None
data=props['settings']['breed_data']; slug=props['settings']['current_breed']
basic=data.get('basics',{}).get(slug,{})
standard=data.get('standards',{}).get(slug,{})
traits=data.get('traits',{}).get(slug,{})
facts={'english_name':name,'life_span':basic.get('life_expectancy'),'height':standard.get('height_display'),'weight':standard.get('weight_display')}
scores={}
labels={'energy_level':'运动活力','trainability_level':'训练接受度','shedding_level':'掉毛程度','barking_level':'吠叫倾向','coat_grooming_frequency':'梳毛需求','affectionate_with_family':'亲近家人'}
for key, label in labels.items():
value=traits.get('traits',{}).get(key,{}).get('score')
if str(value) in ['1','2','3','4','5']:scores[label]=int(value)
if scores:facts['traits']=json.dumps(scores,ensure_ascii=False,separators=(',',':'))
words={'alert':'对环境敏锐','affectionate':'喜欢亲近家人','smart':'学习反应快','intelligent':'学习反应快','friendly':'亲人友善','active':'喜欢活动','energetic':'精力较充沛','playful':'喜欢玩耍','loyal':'重视与家人的联系','independent':'有自主性','gentle':'性情较温和','confident':'比较自信','courageous':'胆量较足','curious':'好奇心较强','calm':'较为沉着','outgoing':'乐于互动','devoted':'重视与家人的联系','lively':'喜欢活动','watchful':'对环境敏锐'}
tags=list(dict.fromkeys(words[w.strip().lower()] for w in traits.get('temperament','').split('/') if w.strip().lower() in words))
if tags:facts['temperament']='、'.join(tags)
return references('akc',slug,url,facts)
def localized(key, value):
value=clean(value).replace('(male)','(公犬)').replace('(female)','(母犬)').replace('up to','不超过').replace('years','年').replace('year','年')
unit, factor, label = ('inches',2.54,'cm') if key=='height' else ('pounds',0.45359237,'kg')
def convert(match):
low=f"{float(match.group(1))*factor:.1f}".rstrip('0').rstrip('.')
high=("–"+f"{float(match.group(2))*factor:.1f}".rstrip('0').rstrip('.')) if match.group(2) else ''
return low+high+' '+label
return re.sub(r'(\d+(?:\.\d+)?)(?:\s*[-–]\s*(\d+(?:\.\d+)?))?\s*'+unit,convert,value)
def main():
parser=argparse.ArgumentParser();parser.parse_args()
rows=json.loads((ROOT/'data.json').read_text())
home=list(DOM(fetch('https://www.fci.be/en/nomenclature/')).root.all())
links=[]
for n in home:
if n.tag=='a' and 'GroupesRepeater_GroupeHyperLink' in n.attrs.get('id',''):
links.append((int(clean(n.text()).split()[-1]),urljoin('https://www.fci.be',n.attrs['href'])))
fci={}
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
for result in pool.map(fci_group,links): fci.update(result)
print('FCI catalog:',len(fci),flush=True)
akc={}
for n in DOM(fetch('https://www.akc.org/dog-breeds/')).root.all():
if n.tag=='option' and re.fullmatch(r'https://www.akc.org/dog-breeds/[a-z0-9-]+/',n.attrs.get('value','')):
akc[norm(clean(n.text()))]=(clean(n.text()),n.attrs['value'])
matches={}; entries=[]
for row in rows:
refs=[];fields={};field_sources={}
existing={k:row.get(k,'') for k in ['english_name','origin','breed_group','fci_number','height','weight','temperament','traits'] if row.get(k)}
if row['source_key'].startswith('cku:'):
refs.append(references('cku',row['source_key'].split(':')[1],row['source_url'],{'name':row['name'],'aliases':row['aliases'],**existing}))
for k in existing:field_sources[k]='cku'
match=re.match(r'cku:(\d+)',row['source_key']); source=fci.get(match.group(1)) if match else None
if source:
source=json.loads(json.dumps(source))
if re.search(r'[A-Z]$',row['source_key']):source['facts']['scope']='犬种基础标准(包含多个变种)'
refs.append(source)
for k,v in source['facts'].items():
if k not in ['scope'] and not (k=='english_name' and re.search(r'[A-Z]$',row['source_key'])) and not row.get(k): fields[k]=v;field_sources[k]='fci'
candidates=[row.get('english_name',''),fields.get('english_name','')]
equivalents={'cku:39':'Pembroke Welsh Corgi','cku:38':'Cardigan Welsh Corgi','cku:167':'Cocker Spaniel'}
if row['source_key'] in equivalents:candidates.insert(0,equivalents[row['source_key']])
# Varieties are deliberately not collapsed into their parent breed.
for candidate in candidates:
if norm(candidate) in akc:
item=akc[norm(candidate)];matches[item]=None;break
entries.append({'source_key':row['source_key'],'name':row['name'],'fields':fields,'field_sources':field_sources,'references':refs})
print('Exact AKC matches:',len(matches),flush=True)
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
futures={pool.submit(akc_page,item):item for item in matches}
done=0
for job in concurrent.futures.as_completed(futures):
item=futures[job]
try:matches[item]=job.result()
except Exception as e: print('AKC unavailable:',item[0],type(e).__name__,flush=True)
done+=1
if done%20==0:print('AKC progress:',done,'/',len(matches),flush=True)
by_name={norm(name):ref for (name,_),ref in matches.items() if ref}
for row,entry in zip(rows,entries):
names=[row.get('english_name',''),entry['fields'].get('english_name','')]
if row['source_key'] in equivalents:names.insert(0,equivalents[row['source_key']])
ref=next((by_name[norm(n)] for n in names if norm(n) in by_name),None)
if not ref:continue
ref=json.loads(json.dumps(ref))
if re.search(r'[A-Z]$',row['source_key']):
ref['facts']['scope']='父犬种资料,变种差异以对应标准为准'
entry['references'].append(ref);continue
entry['references'].append(ref)
for k,v in ref['facts'].items():
if k in ['height','weight','life_span'] or (not row.get(k) and not entry['fields'].get(k)):entry['fields'][k]=localized(k,v);entry['field_sources'][k]='akc'
# Lifestyle ratings have an explicit AKC source, keeping CKU ratings in the reference.
if ref['facts'].get('traits'):entry['fields']['traits']=ref['facts']['traits'];entry['field_sources']['traits']='akc'
rkc={r['source_key']:r for r in json.loads((ROOT/'rkc_facts.json').read_text())}
for row,entry in zip(rows,entries):
if row['source_key'] in rkc:
r=rkc[row['source_key']]
rkc_ref=references('rkc',r['external_id'],r['url'],r['facts']);rkc_ref['retrieved_at']=r['retrieved_at'];entry['references'].append(rkc_ref)
for k in ['exercise','grooming','life_span']:
if k=='life_span' and entry['fields'].get(k):continue
entry['fields'][k]=r['facts'][k];entry['field_sources'][k]='rkc'
if not entry['fields'].get('summary') and not row.get('summary'):
facts={**row,**entry['fields']};bits=[]
if facts.get('origin'):bits.append('这一犬种起源于'+facts['origin'])
if facts.get('breed_group'):bits.append('在 FCI 体系中归入'+facts['breed_group'])
if facts.get('temperament'):bits.append('常见性格倾向包括'+facts['temperament'])
if bits:entry['fields']['summary']='。'.join(bits)+'。';entry['field_sources']['summary']='editorial'
if row.get('summary'):entry['field_sources']['summary']='editorial'
if row.get('care_notes'):entry['field_sources']['care_notes']='editorial'
if entry['references']:
scores={}
try:scores=json.loads(entry['fields'].get('traits') or row.get('traits') or '{}')
except ValueError:pass
if not entry['fields'].get('exercise') and scores.get('运动活力'):
entry['fields']['exercise']='运动活力指数 '+str(scores['运动活力'])+'/5,结合年龄与体力安排散步和互动';entry['field_sources']['exercise']='editorial'
if not entry['fields'].get('grooming') and scores.get('梳毛需求'):
entry['fields']['grooming']='梳毛需求指数 '+str(scores['梳毛需求'])+'/5,按被毛情况安排梳理';entry['field_sources']['grooming']='editorial'
(ROOT/'enrichment.json').write_text(json.dumps(entries,ensure_ascii=False,indent=2)+'\n')
print('Saved',len(entries),'breeds,',sum(len(e['references']) for e in entries),'references',flush=True)
if __name__=='__main__':main()
+58
View File
@@ -17,6 +17,7 @@
"lucide-react": "^1.23.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.4",
"recharts": "^3.10.1",
"tailwind-merge": "^3.6.0"
},
@@ -1600,6 +1601,19 @@
"node": ">= 6"
}
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -3076,6 +3090,44 @@
}
}
},
"node_modules/react-router": {
"version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
"integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
"integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.4"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@@ -3282,6 +3334,12 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+1
View File
@@ -19,6 +19,7 @@
"lucide-react": "^1.23.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.4",
"recharts": "^3.10.1",
"tailwind-merge": "^3.6.0"
},
+28 -10
View File
@@ -1,5 +1,8 @@
import { NavLink, Navigate, useNavigate, useLocation, useParams } from "react-router-dom";
import Breeds from "@/components/admin/Breeds";
import { useEffect, useState, type FormEvent } from "react";
import {
Dog,
CalendarDays,
CheckCheck,
Files,
@@ -37,17 +40,21 @@ const icons = {
overview: LayoutDashboard,
users: Users,
pets: PawPrint,
breeds: Dog,
walks: Route,
meetups: CalendarDays,
tasks: CheckCheck,
files: Files,
};
type Tab = Section | "overview" | "breeds";
export default function App() {
const routerNavigate = useNavigate(), location = useLocation(), { section } = useParams();
const tab: Tab = section && section in labels ? section as Tab : "overview";
useEffect(() => { setDetail(null); setConfirm(null); setError(""); setNotice(""); setBusy(false); }, [location.pathname]);
const [token, setAuth] = useState(getToken()),
[username, setUsername] = useState("sundynix"),
[password, setPassword] = useState("");
const [tab, setTab] = useState<Section | "overview">("overview"),
[revision, refresh] = useState(0),
const [revision, refresh] = useState(0),
[stats, setStats] = useState<Stats | null>(null);
const [error, setError] = useState(""),
[busy, setBusy] = useState(false),
@@ -66,8 +73,10 @@ export default function App() {
setConfirm(null);
setError("登录已过期,请重新登录");
};
const sync = () => setAuth(getToken());
window.addEventListener("storage", sync);
window.addEventListener("admin-auth-expired", expired);
return () => window.removeEventListener("admin-auth-expired", expired);
return () => { window.removeEventListener("admin-auth-expired", expired); window.removeEventListener("storage", sync); };
}, []);
useEffect(() => {
if (!token || tab !== "overview") return;
@@ -94,7 +103,7 @@ export default function App() {
setError("");
try {
const value = await api.login(username.trim(), password);
setToken(value.token);
setToken(value);
setAuth(value.token);
setPassword("");
} catch (e) {
@@ -104,6 +113,7 @@ export default function App() {
}
}
function logout() {
void api.logout().catch(() => {});
clearToken();
setAuth("");
setPassword("");
@@ -113,9 +123,9 @@ export default function App() {
setError("");
setNotice("");
}
function navigate(section: Section | "overview") {
function navigate(section: Tab) {
setBusy(false);
setTab(section);
routerNavigate(`/admin/${section}`);
setError("");
setNotice("");
}
@@ -148,6 +158,12 @@ export default function App() {
? "启用用户"
: "停用用户"
: "取消约遛活动";
if (section !== "login" && !(section && Object.hasOwn(labels, section))) return <Navigate to="/admin/overview" replace />;
if (!token && section !== "login") return <Navigate to="/admin/login" replace state={{ from: location.pathname }} />;
if (token && section === "login") {
const from = location.state?.from;
return <Navigate to={typeof from === "string" && /^\/admin\/(overview|users|pets|breeds|walks|meetups|tasks|files)$/.test(from) ? from : "/admin/overview"} replace />;
}
if (!token)
return (
<main className="login-page">
@@ -231,20 +247,20 @@ export default function App() {
</div>
<p className="side-caption">陪伴工作台</p>
<nav aria-label="管理导航">
{(Object.keys(labels) as (Section | "overview")[]).map((key) => {
{(Object.keys(labels) as Tab[]).map((key) => {
const Icon = icons[key];
return (
<button
<NavLink
to={`/admin/${key}`}
key={key}
className={`nav-item ${tab === key ? "active" : ""}`}
aria-current={tab === key ? "page" : undefined}
title={labels[key]}
onClick={() => navigate(key)}
>
<Icon size={19} />
<span>{labels[key]}</span>
{tab === key && <span className="nav-dot" />}
</button>
</NavLink>
);
})}
</nav>
@@ -318,6 +334,8 @@ export default function App() {
)}
{tab === "overview" ? (
<Overview stats={stats} navigate={navigate} />
) : tab === "breeds" ? (
<Breeds revision={revision} />
) : (
<Records
key={tab}
@@ -0,0 +1,660 @@
import { useEffect, useState, type FormEvent } from "react";
import {
ChevronLeft,
ChevronRight,
LoaderCircle,
Plus,
Search,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/components/ui/table";
import { api, type Breed, type BreedInput, type BreedPage } from "@/lib/api";
const empty: BreedInput = {
name: "",
english_name: "",
aliases: "",
enabled: true,
origin: "",
breed_group: "",
height: "",
weight: "",
summary: "",
temperament: "",
care_notes: "",
fci_number: "",
life_span: "",
exercise: "",
grooming: "",
traits: {},
};
const providerNames: Record<string, string> = {
cku: "CKU",
fci: "FCI",
akc: "AKC",
rkc: "英国皇家犬业俱乐部",
editorial: "编辑整理",
};
const factLabels: Record<string, string> = {
name: "中文名",
english_name: "英文名",
aliases: "别名",
origin: "起源地",
breed_group: "分组",
fci_number: "FCI 编号",
height: "肩高",
weight: "体重",
life_span: "寿命参考",
exercise: "运动参考",
grooming: "梳理参考",
temperament: "性格倾向",
traits: "指数",
size: "体型",
coat_length: "毛长",
sheds: "掉毛情况",
scope: "标准范围",
};
function parseTraits(value: string): Record<string, number> {
try {
return JSON.parse(value) || {};
} catch {
return {};
}
}
export default function Breeds({ revision }: { revision: number }) {
const [page, setPage] = useState(1),
[query, setQuery] = useState(""),
[draft, setDraft] = useState(""),
[initial, setInitial] = useState(""),
[status, setStatus] = useState("");
const [data, setData] = useState<BreedPage | null>(null),
[busy, setBusy] = useState(true),
[retry, setRetry] = useState(0),
[error, setError] = useState(""),
[notice, setNotice] = useState("");
const [sourceView, setSourceView] = useState(false);
const [editing, setEditing] = useState<Breed | "new" | null>(null),
[form, setForm] = useState<BreedInput>(empty),
[confirm, setConfirm] = useState<Breed | null>(null),
[saving, setSaving] = useState(false),
[writeError, setWriteError] = useState("");
useEffect(() => {
const controller = new AbortController();
setBusy(true);
setError("");
api
.breeds(
{ page, page_size: 10, q: query, status, initial },
controller.signal,
)
.then((result) => {
if (controller.signal.aborted) return;
const last = Math.max(1, Math.ceil(result.total / 10));
if (page > last) {
setPage(last);
return;
}
setData(result);
})
.catch((e) => {
if (!controller.signal.aborted) setError(e.message);
})
.finally(() => {
if (!controller.signal.aborted) setBusy(false);
});
return () => controller.abort();
}, [page, query, initial, status, revision, retry]);
function edit(row: Breed | "new") {
setEditing(row);
setSourceView(false);
setForm(
row === "new"
? { ...empty }
: {
name: row.name,
english_name: row.english_name,
aliases: row.aliases,
enabled: row.enabled,
origin: row.origin || "",
breed_group: row.breed_group || "",
height: row.height || "",
weight: row.weight || "",
summary: row.summary || "",
temperament: row.temperament || "",
care_notes: row.care_notes || "",
fci_number: row.fci_number || "",
life_span: row.life_span || "",
exercise: row.exercise || "",
grooming: row.grooming || "",
traits: parseTraits(row.traits),
},
);
setWriteError("");
}
async function save(e: FormEvent) {
e.preventDefault();
if (saving || !editing) return;
setSaving(true);
setWriteError("");
try {
await api.saveBreed(editing === "new" ? "" : editing.id, {
...form,
name: form.name.trim(),
});
setEditing(null);
setNotice("品种资料已保存");
setRetry((v) => v + 1);
} catch (e) {
setWriteError((e as Error).message);
} finally {
setSaving(false);
}
}
async function toggle() {
if (!confirm || saving) return;
setSaving(true);
setWriteError("");
try {
await api.setBreedEnabled(confirm.id, !confirm.enabled);
setNotice(
confirm.enabled ? "品种已停用,已有宠物档案保持原资料" : "品种已启用",
);
setConfirm(null);
setRetry((v) => v + 1);
} catch (e) {
setWriteError((e as Error).message);
} finally {
setSaving(false);
}
}
const pages = Math.max(1, Math.ceil((data?.total || 0) / 10));
return (
<section className="panel records">
<div className="records-heading">
<div>
<h2>狗狗品种</h2>
<p>
{data ? `共 ${data.total} 个品种` : "读取品种中"} · 按中文名称拼音
A–Z 排序
</p>
</div>
<Button onClick={() => edit("new")}>
<Plus size={16} />
新增品种
</Button>
</div>
{notice && (
<div className="notice" role="status">
{notice}
<Button variant="ghost" size="sm" onClick={() => setNotice("")}>
关闭
</Button>
</div>
)}
<div className="toolbar">
<form
className="search-form"
onSubmit={(e) => {
e.preventDefault();
setPage(1);
setQuery(draft.trim());
setRetry((v) => v + 1);
}}
>
<Search size={17} />
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
maxLength={128}
placeholder="搜索品种、别名或拼音"
aria-label="搜索品种、别名或拼音"
/>
<Button variant="outline" disabled={busy}>
搜索
</Button>
</form>
<select
value={status}
aria-label="品种状态"
onChange={(e) => {
setStatus(e.target.value);
setPage(1);
}}
>
<option value="">全部状态</option>
<option value="enabled">已启用</option>
<option value="disabled">已停用</option>
</select>
</div>
<div className="breed-alphabet" aria-label="拼音首字母筛选">
{["", ..."ABCDEFGHIJKLMNOPQRSTUVWXYZ", "#"].map((letter) => (
<button
key={letter}
aria-pressed={initial === letter}
className={initial === letter ? "active" : ""}
onClick={() => {
setInitial(letter);
setPage(1);
}}
>
{letter || "全部"}
</button>
))}
</div>
{error ? (
<div className="empty error-text" role="alert">
<p>{error}</p>
<Button variant="outline" onClick={() => setRetry((v) => v + 1)}>
重新加载
</Button>
</div>
) : busy ? (
<div className="empty" role="status">
<LoaderCircle className="animate-spin" />
<p>正在加载品种</p>
</div>
) : !data?.items.length ? (
<div className="empty">
<p>没有符合条件的品种,可以新增补充。</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
{["字母", "品种", "别名", "来源", "状态", "操作"].map((label) => (
<TableHead key={label}>{label}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((row) => (
<TableRow key={row.id}>
<TableCell>
<span className="per-page">{row.initial}</span>
</TableCell>
<TableCell>
<strong>{row.name}</strong>
<small>{row.english_name || row.pinyin}</small>
<small>{row.summary ? "已整理百科" : "百科待补充"}</small>
</TableCell>
<TableCell className="breed-aliases">
{row.aliases || "—"}
</TableCell>
<TableCell>
{row.references?.length ? (
<div className="breed-source-badges">
{row.references.map((ref) => (
<a
key={ref.id}
href={ref.url}
target="_blank"
rel="noreferrer"
className="per-page"
>
{providerNames[ref.provider] || ref.provider}
</a>
))}
</div>
) : row.source_url ? (
<a
href={row.source_url}
target="_blank"
rel="noreferrer"
className="text-action"
>
{row.source}
</a>
) : (
row.source
)}
</TableCell>
<TableCell>
<span className="per-page">
{row.enabled ? "已启用" : "已停用"}
</span>
</TableCell>
<TableCell>
<div className="row-actions">
<Button size="sm" variant="ghost" onClick={() => edit(row)}>
查看 / 编辑
</Button>
<Button
size="sm"
variant="outline"
disabled={saving}
onClick={() => {
setConfirm(row);
setWriteError("");
}}
>
{row.enabled ? "停用" : "启用"}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<footer className="pagination">
<span>
{data?.total
? `第 ${(page - 1) * 10 + 1}–${Math.min(page * 10, data.total)} 条,共 ${data.total} 条`
: "共 0 条"}{" "}
· 每页 10 条
</span>
<div>
<Button
variant="outline"
size="icon"
aria-label="上一页"
disabled={busy || page <= 1}
onClick={() => setPage(page - 1)}
>
<ChevronLeft size={16} />
</Button>
<span>
第 {page} / {pages} 页
</span>
<Button
variant="outline"
size="icon"
aria-label="下一页"
disabled={busy || page >= pages}
onClick={() => setPage(page + 1)}
>
<ChevronRight size={16} />
</Button>
</div>
</footer>
<Dialog
open={!!editing}
onOpenChange={(open) => {
if (!open && !saving) setEditing(null);
}}
>
<DialogContent className="breed-dialog" aria-describedby="breed-help">
<DialogHeader>
<DialogTitle>
{editing === "new" ? "新增品种" : "编辑品种"}
</DialogTitle>
</DialogHeader>
<p id="breed-help" className="muted">
拼音和 A–Z 分组自动生成。别名用于搜索,例如“边牧”。
</p>
<div className="breed-editor-tabs">
<Button
type="button"
variant={!sourceView ? "default" : "outline"}
onClick={() => setSourceView(false)}
>
展示资料
</Button>
<Button
type="button"
variant={sourceView ? "default" : "outline"}
disabled={editing === "new"}
onClick={() => setSourceView(true)}
>
官方来源 ·{" "}
{editing && editing !== "new"
? editing.references?.length || 0
: 0}
</Button>
</div>
{sourceView && editing && editing !== "new" ? (
<div className="breed-source-list">
<p className="muted">
各机构的分类、标准与指数单独保留。展示资料由编辑整理,保存后后续导入会保留人工修改。
</p>
{editing.references?.length ? (
editing.references.map((ref) => (
<article key={ref.id} className="breed-source-card">
<header>
<strong>
{providerNames[ref.provider] || ref.provider}
</strong>
<a
href={ref.url}
target="_blank"
rel="noreferrer"
className="text-action"
>
查看原始资料
</a>
</header>
<small>
采集于 {ref.retrieved_at} · {ref.external_id}
</small>
<dl>
{Object.entries(ref.facts).map(([key, value]) => (
<div key={key}>
<dt>{factLabels[key] || key}</dt>
<dd>
{key === "traits"
? Object.entries(parseTraits(value))
.map(
([label, score]) => `${label} ${score}/5`,
)
.join(" · ")
: value}
</dd>
</div>
))}
</dl>
</article>
))
) : (
<p className="muted">这是手动录入的犬种,暂未关联官方资料。</p>
)}
</div>
) : (
<form onSubmit={save} className="breed-form">
<label>
中文名称 *
<Input
value={form.name}
required
maxLength={128}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</label>
<label>
英文名称(选填)
<Input
value={form.english_name}
maxLength={128}
onChange={(e) =>
setForm({ ...form, english_name: e.target.value })
}
/>
</label>
<label>
别名(选填,多个用逗号分隔)
<Input
value={form.aliases}
maxLength={512}
onChange={(e) =>
setForm({ ...form, aliases: e.target.value })
}
/>
</label>
<div className="breed-fact-fields">
{(
[
["origin", "起源地"],
["breed_group", "FCI 分组"],
["fci_number", "FCI 编号"],
["life_span", "寿命参考"],
["height", "参考肩高(含单位)"],
["weight", "参考体重(含单位)"],
["exercise", "运动参考"],
["grooming", "毛发护理"],
] as const
).map(([key, label]) => (
<label key={key}>
{label}
<Input
value={form[key]}
maxLength={
["breed_group", "exercise", "grooming"].includes(key)
? 255
: key === "fci_number"
? 16
: 128
}
onChange={(e) =>
setForm({ ...form, [key]: e.target.value })
}
/>
</label>
))}
</div>
<label>
简短介绍
<textarea
value={form.summary}
maxLength={2000}
rows={3}
onChange={(e) =>
setForm({ ...form, summary: e.target.value })
}
/>
</label>
<label>
性格倾向
<Input
value={form.temperament}
maxLength={255}
onChange={(e) =>
setForm({ ...form, temperament: e.target.value })
}
/>
</label>
<div className="breed-fact-fields">
{[
"运动需求",
"运动活力",
"训练接受度",
"掉毛程度",
"吠叫倾向",
"梳毛需求",
"亲近家人",
].map((label) => (
<label key={label}>
{label}
<select
value={form.traits?.[label] || ""}
onChange={(e) => {
const traits = { ...form.traits };
if (e.target.value)
traits[label] = Number(e.target.value);
else delete traits[label];
setForm({ ...form, traits });
}}
>
<option value="">暂无指数</option>
{[1, 2, 3, 4, 5].map((score) => (
<option key={score} value={score}>
{score} / 5
</option>
))}
</select>
</label>
))}
</div>
<label>
相处与照顾笔记
<textarea
value={form.care_notes}
maxLength={2000}
rows={3}
onChange={(e) =>
setForm({ ...form, care_notes: e.target.value })
}
/>
</label>
<label className="breed-enabled">
<input
type="checkbox"
checked={form.enabled}
onChange={(e) =>
setForm({ ...form, enabled: e.target.checked })
}
/>
在小程序品种列表中显示
</label>
{writeError && (
<p className="error-banner" role="alert">
{writeError}
</p>
)}
<div className="dialog-actions">
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() => setEditing(null)}
>
取消
</Button>
<Button disabled={saving}>
{saving && (
<LoaderCircle size={16} className="animate-spin" />
)}
保存品种
</Button>
</div>
</form>
)}
</DialogContent>
</Dialog>
<Dialog
open={!!confirm}
onOpenChange={(open) => {
if (!open && !saving) setConfirm(null);
}}
>
<DialogContent aria-describedby="breed-toggle-help">
<DialogHeader>
<DialogTitle>{confirm?.enabled ? "停用" : "启用"}品种</DialogTitle>
</DialogHeader>
<p id="breed-toggle-help">
确认{confirm?.enabled ? "停用" : "启用"}「{confirm?.name}
」?停用后小程序不再提供此选项,已有宠物档案保留。
</p>
{writeError && (
<p className="error-banner" role="alert">
{writeError}
</p>
)}
<div className="dialog-actions">
<Button
variant="outline"
disabled={saving}
onClick={() => setConfirm(null)}
>
取消
</Button>
<Button disabled={saving} onClick={() => void toggle()}>
确认
</Button>
</div>
</DialogContent>
</Dialog>
</section>
);
}
@@ -1,8 +1,9 @@
import type { Section } from "@/lib/api";
export const labels: Record<Section | "overview", string> = {
export const labels: Record<Section | "overview" | "breeds", string> = {
overview: "工作台",
users: "用户管理",
pets: "宠物档案",
breeds: "狗狗品种",
walks: "遛狗记录",
meetups: "约遛活动",
tasks: "日常任务",
+25
View File
@@ -1090,3 +1090,28 @@ td strong {
padding-right: 20px;
}
}
.nav-item { text-decoration: none; }
.breed-alphabet { display:flex; flex-wrap:wrap; gap:6px; padding:0 24px 24px; }
.breed-alphabet button { min-width:30px; height:30px; border-radius:9px; border:1px solid #eeded1; padding:0 8px; color:#927766; }
.breed-alphabet button.active { background:#ffeadc; border-color:#dfa080; color:#b85837; }
.breed-aliases { max-width:240px; white-space:normal; }
.breed-form { display:grid; gap:18px; }
.breed-form label { display:grid; gap:8px; }
.breed-form .breed-enabled { display:flex; align-items:center; gap:10px; }
.breed-dialog {max-height:85vh;overflow-y:auto;}
.breed-fact-fields {display:grid;grid-template-columns:1fr 1fr;gap:16px;}
.breed-form textarea {width:100%;border:1px solid #eadacb;border-radius:12px;padding:12px;resize:vertical;}
.breed-source-badges{display:flex;flex-wrap:wrap;gap:6px;max-width:280px}
.breed-source-badges a{text-decoration:none}
.breed-editor-tabs{display:flex;gap:10px;margin-bottom:8px}
.breed-source-list{display:grid;gap:16px}
.breed-source-card{border:1px solid #ead9cc;background:#fffbf7;padding:18px;border-radius:16px}
.breed-source-card header{display:flex;align-items:center;justify-content:space-between;gap:12px}
.breed-source-card small{display:block;margin-top:8px;color:#a28a78}
.breed-source-card dl{display:grid;gap:12px;margin-top:18px}
.breed-source-card dl>div{display:grid;grid-template-columns:90px 1fr;gap:12px}
.breed-source-card dt{color:#a28a78;font-size:13px}
.breed-source-card dd{margin:0;font-size:14px;overflow-wrap:anywhere}
.breed-form select{width:100%;min-height:40px;border:1px solid #ead9cc;border-radius:10px;padding:8px;background:white}
+201 -10
View File
@@ -1,6 +1,11 @@
import axios from "axios";
export type Section =
"users" | "pets" | "walks" | "meetups" | "tasks" | "files";
| "users"
| "pets"
| "walks"
| "meetups"
| "tasks"
| "files";
export interface Row {
id: string;
nickname?: string;
@@ -52,6 +57,64 @@ export interface Row {
bucket?: string;
object_key?: string;
}
export interface BreedReference {
id: string;
provider: string;
external_id: string;
url: string;
retrieved_at: string;
facts: Record<string, string>;
}
export interface Breed {
id: string;
name: string;
english_name: string;
aliases: string;
fci_number: string;
life_span: string;
exercise: string;
grooming: string;
traits: string;
references?: BreedReference[];
field_sources?: Record<string, string>;
editorial_locked: boolean;
initial: string;
pinyin: string;
enabled: boolean;
source: string;
source_url: string;
origin: string;
breed_group: string;
height: string;
weight: string;
summary: string;
temperament: string;
care_notes: string;
}
export type BreedInput = Pick<
Breed,
| "name"
| "english_name"
| "aliases"
| "enabled"
| "origin"
| "breed_group"
| "height"
| "weight"
| "summary"
| "temperament"
| "care_notes"
| "fci_number"
| "life_span"
| "exercise"
| "grooming"
> & { traits?: Record<string, number> };
export interface BreedPage {
items: Breed[];
total: number;
page: number;
page_size: number;
}
export interface Point {
latitude: number;
longitude: number;
@@ -79,13 +142,101 @@ export interface Stats {
storage_bytes: number;
daily_walks: { date: string; count: number }[];
}
const key = "pets_admin_token";
export interface Session {
token: string;
refresh_token: string;
expires_at: number;
refresh_expires_at: number;
}
const key = "pets_admin_token",
refreshKey = "pets_admin_refresh_token",
expiryKey = "pets_admin_expires_at";
export const getToken = () => localStorage.getItem(key) || "";
export const setToken = (value: string) => localStorage.setItem(key, value);
export const clearToken = () => localStorage.removeItem(key);
export const setToken = (value: Session) => {
localStorage.setItem(refreshKey, value.refresh_token);
localStorage.setItem(expiryKey, String(value.expires_at));
localStorage.setItem(key, value.token);
};
export const clearToken = () => {
localStorage.removeItem(key);
localStorage.removeItem(refreshKey);
localStorage.removeItem(expiryKey);
};
const raw = axios.create({ baseURL: "/api", timeout: 20000 });
const http = axios.create({ baseURL: "/api", timeout: 20000 });
http.interceptors.request.use((config) => {
const token = getToken();
let refreshing: Promise<string> | null = null;
function expireSession(usedRefresh: string) {
if (localStorage.getItem(refreshKey) !== usedRefresh) return;
clearToken();
window.dispatchEvent(new Event("admin-auth-expired"));
}
async function performRefresh(): Promise<string> {
const used = localStorage.getItem(refreshKey) || "";
if (!used) {
clearToken();
window.dispatchEvent(new Event("admin-auth-expired"));
throw new Error("登录已过期,请重新登录");
}
try {
const response = await raw.post("/admin/refresh", { refresh_token: used });
if (response.data?.code !== 0 || !response.data.data?.refresh_token)
throw new Error(response.data?.message || "刷新登录失败");
// Logout while the request is in flight must not resurrect the session.
if (localStorage.getItem(refreshKey) !== used) {
if (getToken()) return getToken();
throw new Error("已退出登录");
}
setToken(response.data.data);
return response.data.data.token;
} catch (error) {
if (
axios.isAxiosError(error) &&
[401, 403].includes(error.response?.status || 0)
) {
if (
localStorage.getItem(refreshKey) &&
localStorage.getItem(refreshKey) !== used
)
return getToken();
expireSession(used);
}
throw new Error(
axios.isAxiosError(error)
? error.response?.data?.message || "暂时无法刷新登录,请重试"
: (error as Error).message,
);
}
}
function refreshSession(usedAccess?: string): Promise<string> {
if (refreshing) return refreshing;
const work = () => {
if (usedAccess && getToken() !== usedAccess && getToken())
return Promise.resolve(getToken());
const expires = Number(localStorage.getItem(expiryKey));
if (!usedAccess && expires > Date.now() / 1000 + 60 && getToken())
return Promise.resolve(getToken());
return performRefresh();
};
// Serialize browser tabs as well as requests in this tab when Web Locks are available.
refreshing = (
navigator.locks
? navigator.locks.request("pets-admin-refresh", work)
: work()
).finally(() => {
refreshing = null;
});
return refreshing;
}
http.interceptors.request.use(async (config) => {
let token = getToken();
const expires = Number(localStorage.getItem(expiryKey));
if (
token &&
config.url !== "/admin/login" &&
expires &&
expires <= Date.now() / 1000 + 60
)
token = await refreshSession();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
@@ -95,9 +246,27 @@ http.interceptors.response.use(
if (body?.code === 0) return body.data;
throw new Error(body?.message || "请求失败");
},
(error) => {
async (error) => {
if (axios.isCancel(error)) return Promise.reject(error);
if (error.response?.status === 401) {
const config = error.config as
| (import("axios").InternalAxiosRequestConfig & { retried?: boolean })
| undefined;
if (
error.response?.status === 401 &&
config &&
config.url !== "/admin/login" &&
!config.retried
) {
config.retried = true;
const usedAccess = String(config.headers.Authorization || "").replace(
/^Bearer /,
"",
);
const next = await refreshSession(usedAccess);
config.headers.Authorization = `Bearer ${next}`;
return http.request(config);
}
if (error.response?.status === 401 && config?.url !== "/admin/login") {
clearToken();
window.dispatchEvent(new Event("admin-auth-expired"));
}
@@ -106,14 +275,36 @@ http.interceptors.response.use(
error.response?.data?.message ||
(error.code === "ECONNABORTED"
? "请求超时,请重试"
: "无法连接服务器,请稍后重试"),
: error.message || "无法连接服务器,请稍后重试"),
),
);
},
);
export const api = {
logout: () => {
const refresh_token = localStorage.getItem(refreshKey);
return refresh_token
? raw.post("/admin/logout", { refresh_token })
: Promise.resolve();
},
breeds: (
params: {
page: number;
page_size: number;
q: string;
status: string;
initial: string;
},
signal?: AbortSignal,
) => http.get<unknown, BreedPage>("/admin/breeds", { params, signal }),
saveBreed: (id: string, data: BreedInput) =>
id
? http.put(`/admin/breeds/${id}`, data)
: http.post("/admin/breeds", data),
setBreedEnabled: (id: string, enabled: boolean) =>
http.put(`/admin/breeds/${id}/enabled`, { enabled }),
login: (username: string, password: string) =>
http.post<unknown, { token: string }>("/admin/login", {
http.post<unknown, Session>("/admin/login", {
username,
password,
}),
+7 -1
View File
@@ -2,9 +2,15 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<BrowserRouter><Routes>
<Route path="/" element={<Navigate to="/admin/overview" replace />} />
<Route path="/admin" element={<Navigate to="/admin/overview" replace />} />
<Route path="/admin/:section" element={<App />} />
<Route path="*" element={<Navigate to="/admin/overview" replace />} />
</Routes></BrowserRouter>
</StrictMode>,
)