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
+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 {