diff --git a/im/backend/internal/app/app.go b/im/backend/internal/app/app.go index d48218a..d4ae691 100644 --- a/im/backend/internal/app/app.go +++ b/im/backend/internal/app/app.go @@ -247,6 +247,11 @@ func (a *App) userRoutes() []rest.Route { {Method: http.MethodGet, Path: "/api/v1/feed", Handler: auth(a.feed)}, {Method: http.MethodGet, Path: "/api/v1/posts/:id", Handler: auth(a.postDetail)}, {Method: http.MethodGet, Path: "/api/v1/users/:id/posts", Handler: auth(a.userPosts)}, + {Method: http.MethodGet, Path: "/api/v1/pets", Handler: auth(a.myPets)}, + {Method: http.MethodPost, Path: "/api/v1/pets", Handler: auth(a.createPet)}, + {Method: http.MethodGet, Path: "/api/v1/pets/:id", Handler: auth(a.petDetail)}, + {Method: http.MethodPut, Path: "/api/v1/pets/:id", Handler: auth(a.updatePet)}, + {Method: http.MethodDelete, Path: "/api/v1/pets/:id", Handler: auth(a.deletePet)}, {Method: http.MethodPost, Path: "/api/v1/posts", Handler: auth(a.createPost)}, {Method: http.MethodPatch, Path: "/api/v1/posts/:id", Handler: auth(a.updatePost)}, {Method: http.MethodDelete, Path: "/api/v1/posts/:id", Handler: auth(a.deleteOwnPost)}, @@ -323,6 +328,8 @@ func (a *App) adminRoutes() []rest.Route { {Method: http.MethodPost, Path: "/admin/v1/orders/:id/refund", Handler: permit("orders:manage", a.adminRefund)}, {Method: http.MethodGet, Path: "/admin/v1/messages", Handler: permit("messages:view", a.adminMessages)}, {Method: http.MethodPost, Path: "/admin/v1/messages/:id/moderate", Handler: permit("messages:manage", a.adminModerateMessage)}, + {Method: http.MethodGet, Path: "/admin/v1/pets", Handler: permit("users:view", a.adminPets)}, + {Method: http.MethodPost, Path: "/admin/v1/pets/:id/moderate", Handler: permit("users:manage", a.adminModeratePet)}, {Method: http.MethodGet, Path: "/admin/v1/chat-media", Handler: permit("messages:view", a.adminChatMedia)}, {Method: http.MethodPut, Path: "/admin/v1/chat-media/retention", Handler: permit("messages:manage", a.adminUpdateChatMediaRetention)}, {Method: http.MethodPost, Path: "/admin/v1/chat-media/cleanup", Handler: permit("messages:manage", a.adminCleanupDueChatMedia)}, diff --git a/im/backend/internal/app/pets.go b/im/backend/internal/app/pets.go new file mode 100644 index 0000000..4f205aa --- /dev/null +++ b/im/backend/internal/app/pets.go @@ -0,0 +1,467 @@ +package app + +import ( + "context" + "database/sql" + "fmt" + "net/http" + "strings" + "time" +) + +// The pet profile is the shared root of the whole module: a feeding task, an +// adoption listing and a mating listing all point at one of these. Facts about +// the animal — vaccination, neutering, weight — therefore live here once, +// instead of being retyped (and contradicted) in each of those places. + +const ( + petMaxPhotosDefault = 9 + petMaxPerUser = 10 + petMinBirthYears = 40 +) + +var petSpecies = map[string]string{"cat": "猫", "dog": "狗", "other": "其他"} + +type petView struct { + ID int64 `json:"id"` + OwnerUserID int64 `json:"ownerUserId"` + Name string `json:"name"` + Species string `json:"species"` + SpeciesLabel string `json:"speciesLabel"` + Breed string `json:"breed"` + Gender int `json:"gender"` + Birthday string `json:"birthday,omitempty"` + AgeMonths int `json:"ageMonths"` + Neutered bool `json:"neutered"` + WeightG int `json:"weightG"` + ChipNo string `json:"chipNo,omitempty"` + VaccinatedAt string `json:"vaccinatedAt,omitempty"` + DewormedAt string `json:"dewormedAt,omitempty"` + Temperament string `json:"temperament"` + Avatar string `json:"avatar"` + Photos []string `json:"photos"` + Moderation int `json:"moderationStatus"` + CreatedAt string `json:"createdAt"` +} + +type petRequest struct { + Name string `json:"name"` + Species string `json:"species"` + Breed string `json:"breed"` + Gender int `json:"gender"` + Birthday string `json:"birthday"` + Neutered bool `json:"neutered"` + WeightG int `json:"weightG"` + ChipNo string `json:"chipNo"` + VaccinatedAt string `json:"vaccinatedAt"` + DewormedAt string `json:"dewormedAt"` + Temperament string `json:"temperament"` + Avatar string `json:"avatar"` + Photos []string `json:"photos"` + + // Parsed during validation so a bad date is a 400 about that date, not a + // 500 from deep inside the write. + birthday sql.NullTime + vaccinated sql.NullTime + dewormed sql.NullTime +} + +// petDate accepts an empty string as "not recorded". A date the owner cannot +// know yet — a vaccination in the future — is a typo, not a fact. +func petDate(value, label string, allowFuture bool) (sql.NullTime, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return sql.NullTime{}, nil + } + parsed, err := time.Parse("2006-01-02", trimmed) + if err != nil { + return sql.NullTime{}, fmt.Errorf("%s格式应为 2006-01-02", label) + } + if !allowFuture && parsed.After(time.Now()) { + return sql.NullTime{}, fmt.Errorf("%s不能晚于今天", label) + } + if parsed.Before(time.Now().AddDate(-petMinBirthYears, 0, 0)) { + return sql.NullTime{}, fmt.Errorf("%s过于久远,请检查", label) + } + return sql.NullTime{Time: parsed, Valid: true}, nil +} + +func (a *App) validatePet(ctx context.Context, req *petRequest, ownerID int64) error { + req.Name = strings.TrimSpace(req.Name) + req.Breed = strings.TrimSpace(req.Breed) + req.ChipNo = strings.TrimSpace(req.ChipNo) + req.Temperament = strings.TrimSpace(req.Temperament) + req.Species = strings.ToLower(strings.TrimSpace(req.Species)) + if req.Name == "" || len([]rune(req.Name)) > 30 { + return fmt.Errorf("请填写宠物名字,最多 30 个字") + } + if _, ok := petSpecies[req.Species]; !ok { + return fmt.Errorf("请选择宠物类型") + } + if len([]rune(req.Breed)) > 50 || len([]rune(req.Temperament)) > 200 || len(req.ChipNo) > 40 { + return fmt.Errorf("品种、芯片号或性格描述过长") + } + if req.Gender < 0 || req.Gender > 2 { + return fmt.Errorf("性别取值无效") + } + // 200 kg covers every companion animal; a larger number is a unit mistake. + if req.WeightG < 0 || req.WeightG > 200_000 { + return fmt.Errorf("体重应在 0 到 200 公斤之间") + } + limit := a.configInt(ctx, "pet.max_photos", petMaxPhotosDefault) + if len(req.Photos) > limit { + return fmt.Errorf("最多上传 %d 张照片", limit) + } + for _, photo := range req.Photos { + if !validMessageMediaURL(photo, a.config.Environment == "production") { + return fmt.Errorf("照片地址无效") + } + } + if req.Avatar != "" && !validMessageMediaURL(req.Avatar, a.config.Environment == "production") { + return fmt.Errorf("头像地址无效") + } + if req.Avatar == "" && len(req.Photos) > 0 { + req.Avatar = req.Photos[0] + } + var err error + if req.birthday, err = petDate(req.Birthday, "出生日期", false); err != nil { + return err + } + if req.vaccinated, err = petDate(req.VaccinatedAt, "免疫日期", false); err != nil { + return err + } + if req.dewormed, err = petDate(req.DewormedAt, "驱虫日期", false); err != nil { + return err + } + return nil +} + +func (a *App) scanPets(ctx context.Context, rows *sql.Rows) ([]petView, error) { + items := []petView{} + ids := []any{} + placeholders := []string{} + for rows.Next() { + var item petView + var birthday, vaccinated, dewormed sql.NullTime + var created time.Time + var neutered int + if err := rows.Scan(&item.ID, &item.OwnerUserID, &item.Name, &item.Species, &item.Breed, &item.Gender, + &birthday, &neutered, &item.WeightG, &item.ChipNo, &vaccinated, &dewormed, &item.Temperament, + &item.Avatar, &item.Moderation, &created); err != nil { + return nil, err + } + item.SpeciesLabel = petSpecies[item.Species] + item.Neutered = neutered == 1 + if birthday.Valid { + item.Birthday = birthday.Time.Format("2006-01-02") + item.AgeMonths = monthsSince(birthday.Time) + } + if vaccinated.Valid { + item.VaccinatedAt = vaccinated.Time.Format("2006-01-02") + } + if dewormed.Valid { + item.DewormedAt = dewormed.Time.Format("2006-01-02") + } + item.CreatedAt = created.Format(time.RFC3339) + item.Photos = []string{} + items = append(items, item) + ids = append(ids, item.ID) + placeholders = append(placeholders, "?") + } + if err := rows.Err(); err != nil { + return nil, err + } + if len(ids) == 0 { + return items, nil + } + // One extra query for the whole page rather than one per pet. + mediaRows, err := a.db.QueryContext(ctx, `SELECT pet_id,media_url FROM pet_media WHERE pet_id IN (`+strings.Join(placeholders, ",")+`) ORDER BY pet_id,sort_order,id`, ids...) + if err != nil { + return items, nil + } + defer mediaRows.Close() + index := map[int64]int{} + for position := range items { + index[items[position].ID] = position + } + for mediaRows.Next() { + var petID int64 + var url string + if mediaRows.Scan(&petID, &url) != nil { + continue + } + if position, ok := index[petID]; ok { + items[position].Photos = append(items[position].Photos, url) + } + } + return items, nil +} + +const petColumns = `id,owner_user_id,name,species,breed,gender,birthday,neutered,weight_g,chip_no,vaccinated_at,dewormed_at,temperament,avatar_url,moderation_status,created_at` + +func (a *App) myPets(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT `+petColumns+` FROM pets WHERE owner_user_id=? AND status=1 AND deleted_at IS NULL ORDER BY id DESC`, current(r).ID) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询宠物失败") + return + } + defer rows.Close() + items, err := a.scanPets(r.Context(), rows) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询宠物失败") + return + } + reply(w, map[string]any{"items": items, "limit": a.configInt(r.Context(), "pet.max_per_user", petMaxPerUser)}) +} + +func (a *App) petDetail(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "宠物编号无效") + return + } + // A profile that failed moderation stays visible to its owner, so the + // rejection reason has somewhere to be read. + rows, queryErr := a.db.QueryContext(r.Context(), `SELECT `+petColumns+` FROM pets + WHERE id=? AND status=1 AND deleted_at IS NULL AND (owner_user_id=? OR moderation_status=1)`, id, current(r).ID) + if queryErr != nil { + fail(w, http.StatusInternalServerError, 50001, "查询宠物失败") + return + } + defer rows.Close() + items, scanErr := a.scanPets(r.Context(), rows) + if scanErr != nil || len(items) == 0 { + fail(w, http.StatusNotFound, 30001, "宠物不存在") + return + } + item := items[0] + view := map[string]any{"pet": item} + if item.OwnerUserID == current(r).ID { + var note string + _ = a.db.QueryRowContext(r.Context(), `SELECT moderation_note FROM pets WHERE id=?`, id).Scan(¬e) + view["moderationNote"] = note + } + reply(w, view) +} + +func (a *App) createPet(w http.ResponseWriter, r *http.Request) { + var req petRequest + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "宠物档案格式错误") + return + } + if err := a.validatePet(r.Context(), &req, current(r).ID); err != nil { + fail(w, http.StatusBadRequest, 20001, err.Error()) + return + } + var owned int + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM pets WHERE owner_user_id=? AND status=1 AND deleted_at IS NULL`, current(r).ID).Scan(&owned) + if limit := a.configInt(r.Context(), "pet.max_per_user", petMaxPerUser); limit > 0 && owned >= limit { + fail(w, http.StatusBadRequest, 20001, fmt.Sprintf("最多建立 %d 只宠物档案", limit)) + return + } + id, err := a.writePet(r, 0, req) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, err.Error()) + return + } + reply(w, map[string]any{"id": id}) +} + +func (a *App) updatePet(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "宠物编号无效") + return + } + var owner int64 + if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id FROM pets WHERE id=? AND status=1 AND deleted_at IS NULL`, id).Scan(&owner) != nil || owner != current(r).ID { + fail(w, http.StatusNotFound, 30001, "宠物不存在") + return + } + var req petRequest + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "宠物档案格式错误") + return + } + if err := a.validatePet(r.Context(), &req, current(r).ID); err != nil { + fail(w, http.StatusBadRequest, 20001, err.Error()) + return + } + if _, err := a.writePet(r, id, req); err != nil { + fail(w, http.StatusInternalServerError, 50001, err.Error()) + return + } + reply(w, map[string]bool{"success": true}) +} + +// writePet creates or replaces one profile together with its photos, so a +// half-written record — a pet whose photos belong to the previous edit — cannot +// exist even if the request dies midway. +func (a *App) writePet(r *http.Request, id int64, req petRequest) (int64, error) { + birthday, vaccinated, dewormed := req.birthday, req.vaccinated, req.dewormed + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + return 0, fmt.Errorf("保存宠物档案失败") + } + defer func() { _ = tx.Rollback() }() + neutered := 0 + if req.Neutered { + neutered = 1 + } + if id == 0 { + result, execErr := tx.ExecContext(r.Context(), `INSERT INTO pets + (owner_user_id,name,species,breed,gender,birthday,neutered,weight_g,chip_no,vaccinated_at,dewormed_at,temperament,avatar_url) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, + current(r).ID, req.Name, req.Species, req.Breed, req.Gender, birthday, neutered, req.WeightG, + req.ChipNo, vaccinated, dewormed, req.Temperament, req.Avatar) + if execErr != nil { + return 0, fmt.Errorf("保存宠物档案失败") + } + if id, err = result.LastInsertId(); err != nil { + return 0, fmt.Errorf("保存宠物档案失败") + } + } else if _, execErr := tx.ExecContext(r.Context(), `UPDATE pets SET name=?,species=?,breed=?,gender=?,birthday=?,neutered=?,weight_g=?,chip_no=?,vaccinated_at=?,dewormed_at=?,temperament=?,avatar_url=? WHERE id=?`, + req.Name, req.Species, req.Breed, req.Gender, birthday, neutered, req.WeightG, + req.ChipNo, vaccinated, dewormed, req.Temperament, req.Avatar, id); execErr != nil { + return 0, fmt.Errorf("保存宠物档案失败") + } + if _, err = tx.ExecContext(r.Context(), `DELETE FROM pet_media WHERE pet_id=?`, id); err != nil { + return 0, fmt.Errorf("保存宠物照片失败") + } + for order, photo := range req.Photos { + if _, err = tx.ExecContext(r.Context(), `INSERT INTO pet_media(pet_id,media_url,sort_order) VALUES(?,?,?)`, id, strings.TrimSpace(photo), order); err != nil { + return 0, fmt.Errorf("保存宠物照片失败") + } + } + if err = tx.Commit(); err != nil { + return 0, fmt.Errorf("保存宠物档案失败") + } + return id, nil +} + +func (a *App) deletePet(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "宠物编号无效") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE pets SET status=0,deleted_at=NOW(3) WHERE id=? AND owner_user_id=? AND status=1`, id, current(r).ID) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "删除失败") + return + } + if affected, _ := result.RowsAffected(); affected == 0 { + fail(w, http.StatusNotFound, 30001, "宠物不存在") + return + } + reply(w, map[string]bool{"success": true}) +} + +func monthsSince(from time.Time) int { + now := time.Now() + months := (now.Year()-from.Year())*12 + int(now.Month()) - int(from.Month()) + if now.Day() < from.Day() { + months-- + } + if months < 0 { + return 0 + } + return months +} + +// --- 管理端 --------------------------------------------------------------- + +func (a *App) adminPets(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + where := " WHERE p.status=1 AND p.deleted_at IS NULL" + args := []any{} + if status := strings.TrimSpace(r.URL.Query().Get("moderation")); status != "" { + where += " AND p.moderation_status=?" + args = append(args, status) + } + if keyword := strings.TrimSpace(r.URL.Query().Get("keyword")); keyword != "" { + if len([]rune(keyword)) > 50 { + fail(w, http.StatusBadRequest, 20001, "搜索关键词最多 50 字") + return + } + where += " AND (p.name LIKE ? OR profile.nickname LIKE ? OR u.public_id LIKE ?)" + like := "%" + keyword + "%" + args = append(args, like, like, like) + } + from := ` FROM pets p JOIN users u ON u.id=p.owner_user_id JOIN user_profiles profile ON profile.user_id=p.owner_user_id` + where + var total int + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*)`+from, args...).Scan(&total); err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询宠物失败") + return + } + rows, err := a.db.QueryContext(r.Context(), `SELECT p.id,p.owner_user_id,profile.nickname,u.public_id,p.name,p.species,p.breed,p.gender, + p.neutered,p.vaccinated_at,p.avatar_url,p.moderation_status,p.moderation_note,p.created_at`+from+` ORDER BY p.id DESC LIMIT ? OFFSET ?`, + append(append([]any{}, args...), size, offset)...) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询宠物失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, ownerID int64 + var nickname, publicID, name, species, breed, avatar, note string + var gender, neutered, moderation int + var vaccinated sql.NullTime + var created time.Time + if rows.Scan(&id, &ownerID, &nickname, &publicID, &name, &species, &breed, &gender, &neutered, + &vaccinated, &avatar, &moderation, ¬e, &created) != nil { + continue + } + vaccinatedAt := "" + if vaccinated.Valid { + vaccinatedAt = vaccinated.Time.Format("2006-01-02") + } + items = append(items, map[string]any{ + "id": id, "ownerUserId": ownerID, "ownerNickname": nickname, "ownerPublicId": publicID, + "name": name, "species": species, "speciesLabel": petSpecies[species], "breed": breed, + "gender": gender, "neutered": neutered == 1, "vaccinatedAt": vaccinatedAt, "avatar": avatar, + "moderationStatus": moderation, "moderationNote": note, "createdAt": created.Format(time.RFC3339), + }) + } + reply(w, map[string]any{"items": items, "total": total, "page": page, "size": size}) +} + +func (a *App) adminModeratePet(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "宠物编号无效") + return + } + var req struct { + Status int `json:"status"` + Note string `json:"note"` + } + if decode(r, &req) != nil || req.Status < 0 || req.Status > 2 { + fail(w, http.StatusBadRequest, 20001, "审核状态无效") + return + } + // A rejection the owner cannot understand is a support ticket waiting to + // happen, so the reason is required when saying no. + req.Note = strings.TrimSpace(req.Note) + if req.Status == 2 && req.Note == "" { + fail(w, http.StatusBadRequest, 20001, "驳回时必须填写原因") + return + } + if len([]rune(req.Note)) > 100 { + fail(w, http.StatusBadRequest, 20001, "审核备注最多 100 字") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE pets SET moderation_status=?,moderation_note=? WHERE id=? AND status=1 AND deleted_at IS NULL`, req.Status, req.Note, id) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "保存审核结果失败") + return + } + if affected, _ := result.RowsAffected(); affected == 0 { + fail(w, http.StatusNotFound, 30001, "宠物不存在") + return + } + a.audit(r, "moderate", "pet", id, map[string]any{"status": req.Status, "note": req.Note}) + reply(w, map[string]bool{"success": true}) +} diff --git a/im/backend/internal/app/pets_integration_test.go b/im/backend/internal/app/pets_integration_test.go new file mode 100644 index 0000000..e922c95 --- /dev/null +++ b/im/backend/internal/app/pets_integration_test.go @@ -0,0 +1,215 @@ +package app + +import ( + "encoding/json" + "fmt" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// The profile is the root every other pet feature hangs off, so what it accepts +// decides what a feeding task or an adoption listing can rely on later. +func TestPetProfileMySQL(t *testing.T) { + db := isolatedIMDatabase(t) + a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} + create := func(user int64, body string) (int, string) { + t.Helper() + w := httptest.NewRecorder() + a.createPet(w, imTestRequest(user, "POST", "/api/v1/pets", body)) + return w.Code, w.Body.String() + } + list := func(user int64) []petView { + t.Helper() + var payload struct { + Items []petView `json:"items"` + Limit int `json:"limit"` + } + if err := json.Unmarshal(imTestCall(t, a.myPets, user, "GET", "/api/v1/pets", "", 200), &payload); err != nil { + t.Fatal(err) + } + return payload.Items + } + valid := `{"name":"团子","species":"cat","breed":"英短","gender":2,"birthday":"2023-05-01", + "neutered":true,"weightG":4200,"vaccinatedAt":"2026-03-10","dewormedAt":"2026-06-01", + "temperament":"怕生,喜欢躲床底","photos":["https://cdn.example.com/p1.jpg","https://cdn.example.com/p2.jpg"]}` + + t.Run("a complete profile round-trips with its photos in order", func(t *testing.T) { + if status, body := create(1, valid); status != 200 { + t.Fatalf("HTTP %d: %s", status, body) + } + items := list(1) + if len(items) != 1 { + t.Fatalf("items = %d", len(items)) + } + pet := items[0] + if pet.Name != "团子" || pet.SpeciesLabel != "猫" || !pet.Neutered || pet.WeightG != 4200 { + t.Fatalf("pet = %+v", pet) + } + if len(pet.Photos) != 2 || pet.Photos[0] != "https://cdn.example.com/p1.jpg" { + t.Fatalf("photos = %v", pet.Photos) + } + // The first photo becomes the avatar when none was chosen. + if pet.Avatar != "https://cdn.example.com/p1.jpg" { + t.Fatalf("avatar = %q", pet.Avatar) + } + if pet.AgeMonths < 30 { + t.Fatalf("ageMonths = %d,应当由生日推算", pet.AgeMonths) + } + }) + + t.Run("dates that cannot be true are refused with a reason", func(t *testing.T) { + tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02") + for _, test := range []struct{ body, want string }{ + {fmt.Sprintf(`{"name":"未来猫","species":"cat","vaccinatedAt":%q}`, tomorrow), "免疫日期不能晚于今天"}, + {fmt.Sprintf(`{"name":"未来猫","species":"cat","birthday":%q}`, tomorrow), "出生日期不能晚于今天"}, + {`{"name":"格式错","species":"cat","birthday":"2023/05/01"}`, "出生日期格式应为"}, + {`{"name":"太老了","species":"cat","birthday":"1900-01-01"}`, "过于久远"}, + } { + status, body := create(1, test.body) + if status != 400 || !strings.Contains(body, test.want) { + t.Fatalf("%s → HTTP %d %s", test.body, status, body) + } + } + }) + + t.Run("the rest of the form is validated too", func(t *testing.T) { + for _, test := range []struct{ body, want string }{ + {`{"name":"","species":"cat"}`, "请填写宠物名字"}, + {`{"name":"鹦鹉","species":"bird"}`, "请选择宠物类型"}, + {`{"name":"胖胖","species":"dog","weightG":300000}`, "体重应在"}, + {`{"name":"图多","species":"cat","photos":["https://a/1.jpg","https://a/2.jpg","https://a/3.jpg","https://a/4.jpg","https://a/5.jpg","https://a/6.jpg","https://a/7.jpg","https://a/8.jpg","https://a/9.jpg","https://a/10.jpg"]}`, "最多上传"}, + {`{"name":"坏图","species":"cat","photos":["javascript:alert(1)"]}`, "照片地址无效"}, + } { + status, body := create(1, test.body) + if status != 400 || !strings.Contains(body, test.want) { + t.Fatalf("%s → HTTP %d %s", test.body, status, body) + } + } + }) + + t.Run("editing replaces the photos instead of accumulating them", func(t *testing.T) { + pet := list(1)[0] + w := httptest.NewRecorder() + a.updatePet(w, imTestRequest(1, "PUT", fmt.Sprintf("/api/v1/pets/%d", pet.ID), + `{"name":"团子","species":"cat","photos":["https://cdn.example.com/new.jpg"]}`)) + if w.Code != 200 { + t.Fatalf("HTTP %d: %s", w.Code, w.Body.String()) + } + updated := list(1)[0] + if len(updated.Photos) != 1 || updated.Photos[0] != "https://cdn.example.com/new.jpg" { + t.Fatalf("photos = %v", updated.Photos) + } + }) + + t.Run("a profile belongs to its owner alone", func(t *testing.T) { + pet := list(1)[0] + w := httptest.NewRecorder() + a.updatePet(w, imTestRequest(2, "PUT", fmt.Sprintf("/api/v1/pets/%d", pet.ID), valid)) + if w.Code != 404 { + t.Fatalf("别人不该改得动: HTTP %d", w.Code) + } + del := httptest.NewRecorder() + a.deletePet(del, imTestRequest(2, "DELETE", fmt.Sprintf("/api/v1/pets/%d", pet.ID), "")) + if del.Code != 404 { + t.Fatalf("别人不该删得掉: HTTP %d", del.Code) + } + }) + + t.Run("the per-account limit is enforced", func(t *testing.T) { + if _, err := db.Exec(`UPDATE system_configs SET config_value='2' WHERE config_key='pet.max_per_user'`); err != nil { + t.Fatal(err) + } + create(1, `{"name":"第二只","species":"dog"}`) + status, body := create(1, `{"name":"第三只","species":"dog"}`) + if status != 400 || !strings.Contains(body, "最多建立 2 只") { + t.Fatalf("HTTP %d: %s", status, body) + } + if _, err := db.Exec(`UPDATE system_configs SET config_value='10' WHERE config_key='pet.max_per_user'`); err != nil { + t.Fatal(err) + } + }) + + t.Run("a deleted profile disappears from the list but keeps its row", func(t *testing.T) { + before := list(1) + w := httptest.NewRecorder() + a.deletePet(w, imTestRequest(1, "DELETE", fmt.Sprintf("/api/v1/pets/%d", before[0].ID), "")) + if w.Code != 200 { + t.Fatalf("HTTP %d: %s", w.Code, w.Body.String()) + } + if len(list(1)) != len(before)-1 { + t.Fatal("删除后列表数量未变") + } + var kept int + if err := db.QueryRow(`SELECT COUNT(*) FROM pets WHERE id=?`, before[0].ID).Scan(&kept); err != nil || kept != 1 { + t.Fatalf("软删除应保留记录: %d %v", kept, err) + } + }) +} + +func TestAdminPetModerationMySQL(t *testing.T) { + db := isolatedIMDatabase(t) + a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} + w := httptest.NewRecorder() + a.createPet(w, imTestRequest(1, "POST", "/api/v1/pets", `{"name":"待审核","species":"dog","breed":"柯基"}`)) + if w.Code != 200 { + t.Fatalf("HTTP %d: %s", w.Code, w.Body.String()) + } + var created struct { + ID int64 `json:"id"` + } + var envelope struct { + Data json.RawMessage `json:"data"` + } + _ = json.Unmarshal(w.Body.Bytes(), &envelope) + _ = json.Unmarshal(envelope.Data, &created) + + t.Run("the list carries the owner so a moderator knows whose pet this is", func(t *testing.T) { + var payload struct { + Items []map[string]any `json:"items"` + Total int `json:"total"` + } + if err := json.Unmarshal(imTestCall(t, a.adminPets, 1, "GET", "/admin/v1/pets?keyword=待审核", "", 200), &payload); err != nil { + t.Fatal(err) + } + if payload.Total != 1 || len(payload.Items) != 1 { + t.Fatalf("payload = %+v", payload) + } + if payload.Items[0]["ownerNickname"] == "" || payload.Items[0]["speciesLabel"] != "狗" { + t.Fatalf("item = %v", payload.Items[0]) + } + }) + + t.Run("rejecting without a reason is refused", func(t *testing.T) { + reject := httptest.NewRecorder() + a.adminModeratePet(reject, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pets/%d/moderate", created.ID), `{"status":2}`)) + if reject.Code != 400 || !strings.Contains(reject.Body.String(), "必须填写原因") { + t.Fatalf("HTTP %d: %s", reject.Code, reject.Body.String()) + } + }) + + t.Run("a rejected profile stays visible to its owner with the reason", func(t *testing.T) { + reject := httptest.NewRecorder() + a.adminModeratePet(reject, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pets/%d/moderate", created.ID), `{"status":2,"note":"照片看不清宠物"}`)) + if reject.Code != 200 { + t.Fatalf("HTTP %d: %s", reject.Code, reject.Body.String()) + } + var owner struct { + Pet petView `json:"pet"` + ModerationNote string `json:"moderationNote"` + } + if err := json.Unmarshal(imTestCall(t, a.petDetail, 1, "GET", fmt.Sprintf("/api/v1/pets/%d", created.ID), "", 200), &owner); err != nil { + t.Fatal(err) + } + if owner.Pet.Moderation != 2 || owner.ModerationNote != "照片看不清宠物" { + t.Fatalf("owner view = %+v", owner) + } + // Everyone else stops seeing it until it passes. + stranger := httptest.NewRecorder() + a.petDetail(stranger, imTestRequest(2, "GET", fmt.Sprintf("/api/v1/pets/%d", created.ID), "")) + if stranger.Code != 404 { + t.Fatalf("被驳回的档案不该对外可见: HTTP %d", stranger.Code) + } + }) +} diff --git a/im/backend/migrations/036_pets.sql b/im/backend/migrations/036_pets.sql new file mode 100644 index 0000000..afcb383 --- /dev/null +++ b/im/backend/migrations/036_pets.sql @@ -0,0 +1,45 @@ +SET NAMES utf8mb4; + +-- 宠物档案。代喂任务、送养帖、配种信息都挂在这张表上,所以免疫与绝育状态 +-- 放在档案里而不是各自的业务表里——同一只宠物的这些事实只应有一份。 +CREATE TABLE IF NOT EXISTS pets ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + owner_user_id BIGINT UNSIGNED NOT NULL, + name VARCHAR(30) NOT NULL, + species VARCHAR(20) NOT NULL COMMENT 'cat / dog / other', + breed VARCHAR(50) NOT NULL DEFAULT '', + gender TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 未知 1 公 2 母', + birthday DATE NULL, + neutered TINYINT(1) NOT NULL DEFAULT 0, + weight_g INT UNSIGNED NOT NULL DEFAULT 0, + chip_no VARCHAR(40) NOT NULL DEFAULT '', + vaccinated_at DATE NULL COMMENT '最近一次免疫日期', + dewormed_at DATE NULL COMMENT '最近一次驱虫日期', + temperament VARCHAR(200) NOT NULL DEFAULT '', + avatar_url VARCHAR(500) NOT NULL DEFAULT '', + status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '0 已删除 1 正常', + moderation_status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '0 待审核 1 通过 2 驳回', + moderation_note VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (id), + KEY idx_pets_owner (owner_user_id, status, id), + KEY idx_pets_moderation (moderation_status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS pet_media ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + pet_id BIGINT UNSIGNED NOT NULL, + media_url VARCHAR(500) NOT NULL, + sort_order INT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_pet_media_pet (pet_id, sort_order, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES + ('pet.enabled','true','boolean','宠物板块总开关,关闭后客户端不显示宠物入口'), + ('pet.max_per_user','10','integer','单个账号可建立的宠物档案数量上限'), + ('pet.max_photos','9','integer','单只宠物的照片数量上限') +ON DUPLICATE KEY UPDATE description=VALUES(description);