diff --git a/im/backend/internal/app/app.go b/im/backend/internal/app/app.go index d4ae691..f851731 100644 --- a/im/backend/internal/app/app.go +++ b/im/backend/internal/app/app.go @@ -117,6 +117,7 @@ func (a *App) Run() { go a.runAIReplyWorker(workerContext) go a.runAIMaintenance(workerContext) go a.runChatMediaCleanupWorker(workerContext) + a.startFeedSettlementWorker(workerContext) server := rest.MustNewServer(rest.RestConf{ Host: a.config.Host, Port: a.config.Port, @@ -247,6 +248,40 @@ 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/pet/adoptions", Handler: auth(a.adoptions)}, + {Method: http.MethodPost, Path: "/api/v1/pet/adoptions", Handler: auth(a.createAdoption)}, + {Method: http.MethodGet, Path: "/api/v1/pet/adoptions/:id", Handler: auth(a.adoptionDetail)}, + {Method: http.MethodPost, Path: "/api/v1/pet/adoptions/:id/apply", Handler: auth(a.applyAdoption)}, + {Method: http.MethodPost, Path: "/api/v1/pet/adoptions/:id/decide", Handler: auth(a.decideAdoption)}, + {Method: http.MethodPost, Path: "/api/v1/pet/adoptions/:id/close", Handler: auth(a.closeAdoption)}, + {Method: http.MethodGet, Path: "/api/v1/pet/followups", Handler: auth(a.myAdoptionFollowups)}, + {Method: http.MethodPost, Path: "/api/v1/pet/followups/:id", Handler: auth(a.submitAdoptionFollowup)}, + {Method: http.MethodGet, Path: "/api/v1/pet/mating", Handler: auth(a.matingListings)}, + {Method: http.MethodPost, Path: "/api/v1/pet/mating", Handler: auth(a.createMatingListing)}, + {Method: http.MethodPost, Path: "/api/v1/pet/mating/:id/close", Handler: auth(a.closeMatingListing)}, + {Method: http.MethodGet, Path: "/api/v1/pet/addresses", Handler: auth(a.petAddresses)}, + {Method: http.MethodPost, Path: "/api/v1/pet/addresses", Handler: auth(a.createPetAddress)}, + {Method: http.MethodGet, Path: "/api/v1/pet/sitter", Handler: auth(a.sitterProfile)}, + {Method: http.MethodPost, Path: "/api/v1/pet/sitter", Handler: auth(a.applySitter)}, + {Method: http.MethodGet, Path: "/api/v1/pet/sitters/:id/reviews", Handler: auth(a.sitterReviews)}, + {Method: http.MethodGet, Path: "/api/v1/pet/feed-tasks", Handler: auth(a.feedTasks)}, + {Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks", Handler: auth(a.createFeedTask)}, + {Method: http.MethodGet, Path: "/api/v1/pet/feed-tasks/:id", Handler: auth(a.feedTaskDetail)}, + {Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks/:id/pay", Handler: auth(a.escrowFeedTask)}, + {Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks/:id/apply", Handler: auth(a.applyFeedTask)}, + {Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks/:id/assign", Handler: auth(a.assignFeedTask)}, + {Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks/:id/confirm", Handler: auth(a.confirmFeedTask)}, + {Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks/:id/cancel", Handler: auth(a.cancelFeedTask)}, + {Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks/:id/dispute", Handler: auth(a.raiseFeedDispute)}, + {Method: http.MethodPost, Path: "/api/v1/pet/feed-tasks/:id/review", Handler: auth(a.reviewFeedTask)}, + {Method: http.MethodPost, Path: "/api/v1/pet/feed-visits/:id/checkin", Handler: auth(a.checkinFeedVisit)}, + {Method: http.MethodPost, Path: "/api/v1/pet/feed-visits/:id/finish", Handler: auth(a.finishFeedVisit)}, + {Method: http.MethodGet, Path: "/api/v1/wallet", Handler: auth(a.walletSummary)}, + {Method: http.MethodGet, Path: "/api/v1/wallet/transactions", Handler: auth(a.walletTransactions)}, + {Method: http.MethodGet, Path: "/api/v1/wallet/payout-accounts", Handler: auth(a.payoutAccounts)}, + {Method: http.MethodPost, Path: "/api/v1/wallet/payout-accounts", Handler: auth(a.addPayoutAccount)}, + {Method: http.MethodGet, Path: "/api/v1/wallet/withdrawals", Handler: auth(a.myWithdrawals)}, + {Method: http.MethodPost, Path: "/api/v1/wallet/withdrawals", Handler: auth(a.createWithdrawal)}, {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)}, @@ -329,7 +364,17 @@ func (a *App) adminRoutes() []rest.Route { {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.MethodGet, Path: "/admin/v1/pet/listings", Handler: permit("users:view", a.adminPetListings)}, + {Method: http.MethodPost, Path: "/admin/v1/pet/listings/:id/moderate", Handler: permit("users:manage", a.adminModeratePetListing)}, {Method: http.MethodPost, Path: "/admin/v1/pets/:id/moderate", Handler: permit("users:manage", a.adminModeratePet)}, + {Method: http.MethodGet, Path: "/admin/v1/pet/sitters", Handler: permit("users:view", a.adminSitters)}, + {Method: http.MethodPost, Path: "/admin/v1/pet/sitters/:id/review", Handler: permit("users:manage", a.adminReviewSitter)}, + {Method: http.MethodGet, Path: "/admin/v1/pet/feed-tasks", Handler: permit("users:view", a.adminFeedTasks)}, + {Method: http.MethodGet, Path: "/admin/v1/pet/disputes", Handler: permit("users:view", a.adminFeedDisputes)}, + {Method: http.MethodPost, Path: "/admin/v1/pet/disputes/:id/handle", Handler: permit("users:manage", a.adminHandleFeedDispute)}, + {Method: http.MethodGet, Path: "/admin/v1/wallet/withdrawals", Handler: permit("users:view", a.adminWithdrawals)}, + {Method: http.MethodPost, Path: "/admin/v1/wallet/withdrawals/:id/handle", Handler: permit("users:manage", a.adminHandleWithdrawal)}, + {Method: http.MethodGet, Path: "/admin/v1/wallet/audit", Handler: permit("users:view", a.adminWalletAudit)}, {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/pet_admin_listings.go b/im/backend/internal/app/pet_admin_listings.go new file mode 100644 index 0000000..9181bc8 --- /dev/null +++ b/im/backend/internal/app/pet_admin_listings.go @@ -0,0 +1,137 @@ +package app + +import ( + "database/sql" + "fmt" + "net/http" + "strings" + "time" +) + +// Adoption and mating listings are reviewed the same way and by the same +// person, so they share one queue and one decision endpoint. The table they +// live in is the only difference, and it is validated rather than interpolated +// blindly. +var petListingTables = map[string]string{"adoption": "pet_adoptions", "mating": "pet_mating_listings"} + +func (a *App) adminPetListings(w http.ResponseWriter, r *http.Request) { + kind := strings.TrimSpace(r.URL.Query().Get("kind")) + if kind == "" { + kind = "adoption" + } + table, ok := petListingTables[kind] + if !ok { + fail(w, http.StatusBadRequest, 20001, "类型无效") + return + } + page, size, offset := pagination(r) + where := " WHERE l.deleted_at IS NULL" + args := []any{} + if status := strings.TrimSpace(r.URL.Query().Get("moderation")); status != "" { + where += " AND l.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 owner.nickname LIKE ? OR u.public_id LIKE ?)" + like := "%" + keyword + "%" + args = append(args, like, like, like) + } + from := fmt.Sprintf(` FROM %s l JOIN pets p ON p.id=l.pet_id JOIN users u ON u.id=l.owner_user_id + JOIN user_profiles owner ON owner.user_id=l.owner_user_id%s`, table, 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 + } + // The two tables differ by one column each; selecting them separately keeps + // the scan honest instead of padding with NULLs. + extra := "l.reason,l.requirements,l.review_reason,l.application_count" + if kind == "mating" { + extra = "l.expectation,'',l.vaccine_media,0" + } + rows, err := a.db.QueryContext(r.Context(), `SELECT l.id,l.owner_user_id,owner.nickname,u.public_id,l.city_name,l.status,l.moderation_status,l.moderation_note,l.created_at, + p.id,p.name,p.species,p.breed,p.avatar_url,p.vaccinated_at,p.neutered,`+extra+from+` ORDER BY l.moderation_status=0 DESC,l.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, petID int64 + var nickname, publicID, cityName, note, petName, species, breed, avatar string + var body, requirements, flag string + var status, moderation, neutered, applications int + var vaccinated sql.NullTime + var created time.Time + if rows.Scan(&id, &ownerID, &nickname, &publicID, &cityName, &status, &moderation, ¬e, &created, + &petID, &petName, &species, &breed, &avatar, &vaccinated, &neutered, &body, &requirements, &flag, &applications) != nil { + continue + } + vaccinatedAt := "" + if vaccinated.Valid { + vaccinatedAt = vaccinated.Time.Format("2006-01-02") + } + items = append(items, map[string]any{ + "id": id, "kind": kind, "ownerUserId": ownerID, "ownerNickname": nickname, "ownerPublicId": publicID, + "cityName": cityName, "status": status, "moderationStatus": moderation, "moderationNote": note, + "petId": petID, "petName": petName, "species": species, "speciesLabel": petSpecies[species], + "breed": breed, "avatar": avatar, "vaccinatedAt": vaccinatedAt, "neutered": neutered == 1, + "body": body, "requirements": requirements, "flag": flag, "applicationCount": applications, + "createdAt": created.Format(time.RFC3339), + }) + } + reply(w, map[string]any{"items": items, "total": total, "page": page, "size": size}) +} + +func (a *App) adminModeratePetListing(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "编号无效") + return + } + var req struct { + Kind string `json:"kind"` + 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 + } + table, ok := petListingTables[strings.TrimSpace(req.Kind)] + if !ok { + fail(w, http.StatusBadRequest, 20001, "类型无效") + return + } + 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 + } + var owner int64 + if a.db.QueryRowContext(r.Context(), fmt.Sprintf(`SELECT owner_user_id FROM %s WHERE id=? AND deleted_at IS NULL`, table), id).Scan(&owner) != nil { + fail(w, http.StatusNotFound, 30001, "记录不存在") + return + } + if _, err = a.db.ExecContext(r.Context(), fmt.Sprintf(`UPDATE %s SET moderation_status=?,moderation_note=? WHERE id=?`, table), req.Status, req.Note, id); err != nil { + fail(w, http.StatusInternalServerError, 50001, "保存审核结果失败") + return + } + title, body := "发布已通过", "你的信息已经通过审核,现在可以被其他人看到了" + if req.Status == 2 { + title, body = "发布未通过", req.Note + } + a.notifyUser(r.Context(), owner, "system", title, body, req.Kind, id) + a.audit(r, "moderate", "pet_"+req.Kind, id, map[string]any{"status": req.Status, "note": req.Note}) + reply(w, map[string]bool{"success": true}) +} diff --git a/im/backend/internal/app/pet_adoption.go b/im/backend/internal/app/pet_adoption.go new file mode 100644 index 0000000..972e5c2 --- /dev/null +++ b/im/backend/internal/app/pet_adoption.go @@ -0,0 +1,537 @@ +package app + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +// Adoption is an information service, not a marketplace: there is no price +// field anywhere in it, and anything in the free text that smells like one +// sends the listing to a human instead of straight onto the shelf. + +type adoptionView struct { + ID int64 `json:"id"` + PetID int64 `json:"petId"` + OwnerUserID int64 `json:"ownerUserId"` + OwnerName string `json:"ownerName"` + PetName string `json:"petName"` + Species string `json:"species"` + SpeciesLabel string `json:"speciesLabel"` + Breed string `json:"breed"` + Gender int `json:"gender"` + AgeMonths int `json:"ageMonths"` + Neutered bool `json:"neutered"` + VaccinatedAt string `json:"vaccinatedAt,omitempty"` + Avatar string `json:"avatar"` + Photos []string `json:"photos"` + CityName string `json:"cityName"` + Reason string `json:"reason"` + Requirements string `json:"requirements"` + Status int `json:"status"` + Moderation int `json:"moderationStatus"` + Applications int `json:"applicationCount"` + Mine bool `json:"mine"` + Applied bool `json:"applied"` + CreatedAt string `json:"createdAt"` +} + +// The questionnaire is the only real filter a giver has. Keeping the questions +// server-side means every listing asks the same things and the answers stay +// comparable. +var adoptionQuestions = []struct { + Key string `json:"key"` + Label string `json:"label"` +}{ + {"housing", "居住情况(自有/租住,房东是否允许养宠)"}, + {"members", "家人或室友是否都同意"}, + {"pets", "家里是否已有其他宠物"}, + {"experience", "过往养宠经验"}, + {"followup", "是否接受送出后的回访"}, +} + +func (a *App) adoptionReviewReason(ctx context.Context, ownerID int64, text string) string { + words := strings.Split(a.configPlain(ctx, "pet.adoption_review_words", ""), ",") + lowered := strings.ToLower(text) + for _, word := range words { + word = strings.TrimSpace(word) + if word != "" && strings.Contains(lowered, strings.ToLower(word)) { + return "文案命中敏感词:" + word + } + } + // Someone giving away a litter every month is a breeder, not an owner in + // trouble. The window and threshold are both configurable. + days := a.configInt(ctx, "pet.adoption_batch_days", 90) + limit := a.configInt(ctx, "pet.adoption_batch_limit", 3) + if days > 0 && limit > 0 { + var recent int + _ = a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM pet_adoptions WHERE owner_user_id=? AND deleted_at IS NULL AND created_at>DATE_SUB(NOW(3),INTERVAL ? DAY)`, ownerID, days).Scan(&recent) + if recent >= limit { + return fmt.Sprintf("%d 天内已发布 %d 条送养", days, recent) + } + } + return "" +} + +func (a *App) createAdoption(w http.ResponseWriter, r *http.Request) { + if !a.configBool(r.Context(), "pet.adoption_enabled", true) { + fail(w, http.StatusForbidden, 30002, "送养功能暂未开放") + return + } + var req struct { + PetID int64 `json:"petId"` + CityCode string `json:"cityCode"` + CityName string `json:"cityName"` + Reason string `json:"reason"` + Requirements string `json:"requirements"` + } + if decode(r, &req) != nil || req.PetID == 0 { + fail(w, http.StatusBadRequest, 20001, "送养信息格式错误") + return + } + req.Reason = strings.TrimSpace(req.Reason) + req.Requirements = strings.TrimSpace(req.Requirements) + req.CityName = strings.TrimSpace(req.CityName) + if len([]rune(req.Reason)) < 10 || len([]rune(req.Reason)) > 500 { + fail(w, http.StatusBadRequest, 20001, "请说明送养原因,10 到 500 字") + return + } + if len([]rune(req.Requirements)) > 500 || len([]rune(req.CityName)) > 50 { + fail(w, http.StatusBadRequest, 20001, "内容过长") + return + } + // The listing inherits its facts from the profile, so the profile has to be + // complete first: photos to look at, and a vaccination record to trust. + var vaccinated sql.NullTime + var photos int + if err := a.db.QueryRowContext(r.Context(), `SELECT p.vaccinated_at,(SELECT COUNT(*) FROM pet_media m WHERE m.pet_id=p.id) + FROM pets p WHERE p.id=? AND p.owner_user_id=? AND p.status=1 AND p.deleted_at IS NULL`, req.PetID, current(r).ID).Scan(&vaccinated, &photos); err != nil { + fail(w, http.StatusNotFound, 30001, "宠物不存在") + return + } + if minPhotos := a.configInt(r.Context(), "pet.adoption_min_photos", 3); photos < minPhotos { + fail(w, http.StatusBadRequest, 20001, fmt.Sprintf("发布送养前请至少上传 %d 张宠物照片", minPhotos)) + return + } + if !vaccinated.Valid { + fail(w, http.StatusBadRequest, 20001, "请先在宠物档案中填写免疫日期") + return + } + var existing int + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM pet_adoptions WHERE pet_id=? AND status=1 AND deleted_at IS NULL`, req.PetID).Scan(&existing) + if existing > 0 { + fail(w, http.StatusBadRequest, 20001, "这只宠物已经在送养中") + return + } + reviewReason := a.adoptionReviewReason(r.Context(), current(r).ID, req.Reason+" "+req.Requirements) + result, err := a.db.ExecContext(r.Context(), `INSERT INTO pet_adoptions(pet_id,owner_user_id,city_code,city_name,reason,requirements,review_reason) + VALUES(?,?,?,?,?,?,?)`, req.PetID, current(r).ID, strings.TrimSpace(req.CityCode), req.CityName, req.Reason, req.Requirements, reviewReason) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "发布失败") + return + } + id, _ := result.LastInsertId() + reply(w, map[string]any{"id": id, "pendingReview": true, "reviewReason": reviewReason}) +} + +const adoptionColumns = `a.id,a.pet_id,a.owner_user_id,owner.nickname,a.city_name,a.reason,a.requirements,a.status,a.moderation_status,a.application_count,a.created_at, + p.name,p.species,p.breed,p.gender,p.birthday,p.neutered,p.vaccinated_at,p.avatar_url` + +func (a *App) scanAdoptions(ctx context.Context, rows *sql.Rows, viewer int64) []adoptionView { + items := []adoptionView{} + ids := []any{} + placeholders := []string{} + for rows.Next() { + var item adoptionView + var birthday, vaccinated sql.NullTime + var created time.Time + var neutered int + if rows.Scan(&item.ID, &item.PetID, &item.OwnerUserID, &item.OwnerName, &item.CityName, &item.Reason, + &item.Requirements, &item.Status, &item.Moderation, &item.Applications, &created, + &item.PetName, &item.Species, &item.Breed, &item.Gender, &birthday, &neutered, &vaccinated, &item.Avatar) != nil { + continue + } + item.SpeciesLabel = petSpecies[item.Species] + item.Neutered = neutered == 1 + if birthday.Valid { + item.AgeMonths = monthsSince(birthday.Time) + } + if vaccinated.Valid { + item.VaccinatedAt = vaccinated.Time.Format("2006-01-02") + } + item.CreatedAt = created.Format(time.RFC3339) + item.Mine = item.OwnerUserID == viewer + item.Photos = []string{} + items = append(items, item) + ids = append(ids, item.PetID) + placeholders = append(placeholders, "?") + } + if len(ids) == 0 { + return items + } + 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 { + defer mediaRows.Close() + for mediaRows.Next() { + var petID int64 + var url string + if mediaRows.Scan(&petID, &url) != nil { + continue + } + for index := range items { + if items[index].PetID == petID { + items[index].Photos = append(items[index].Photos, url) + } + } + } + } + // One query tells the reader which listings they already applied to, so the + // list can show "已申请" without a request per card. + appliedRows, err := a.db.QueryContext(ctx, `SELECT adoption_id FROM pet_adoption_applications WHERE applicant_user_id=? AND status IN (0,1)`, viewer) + if err == nil { + defer appliedRows.Close() + applied := map[int64]bool{} + for appliedRows.Next() { + var id int64 + if appliedRows.Scan(&id) == nil { + applied[id] = true + } + } + for index := range items { + items[index].Applied = applied[items[index].ID] + } + } + return items +} + +func (a *App) adoptions(w http.ResponseWriter, r *http.Request) { + _, size, offset := pageOptions(r) + where := " WHERE a.deleted_at IS NULL" + args := []any{} + if strings.TrimSpace(r.URL.Query().Get("mine")) == "1" { + where += " AND a.owner_user_id=?" + args = append(args, current(r).ID) + } else { + // The shelf only carries approved, still-open listings. + where += " AND a.status=1 AND a.moderation_status=1" + if city := strings.TrimSpace(r.URL.Query().Get("cityCode")); city != "" { + where += " AND a.city_code=?" + args = append(args, city) + } + if species := strings.TrimSpace(r.URL.Query().Get("species")); species != "" { + where += " AND p.species=?" + args = append(args, species) + } + } + query := `SELECT ` + adoptionColumns + ` FROM pet_adoptions a + JOIN pets p ON p.id=a.pet_id JOIN user_profiles owner ON owner.user_id=a.owner_user_id` + where + + ` ORDER BY a.id DESC LIMIT ? OFFSET ?` + rows, err := a.db.QueryContext(r.Context(), query, append(args, size+1, offset)...) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询送养信息失败") + return + } + defer rows.Close() + items := a.scanAdoptions(r.Context(), rows, current(r).ID) + hasMore := len(items) > size + if hasMore { + items = items[:size] + } + reply(w, map[string]any{"items": items, "hasMore": hasMore, "questions": adoptionQuestions}) +} + +func (a *App) adoptionDetail(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "编号无效") + return + } + rows, queryErr := a.db.QueryContext(r.Context(), `SELECT `+adoptionColumns+` FROM pet_adoptions a + JOIN pets p ON p.id=a.pet_id JOIN user_profiles owner ON owner.user_id=a.owner_user_id + WHERE a.id=? AND a.deleted_at IS NULL AND (a.owner_user_id=? OR (a.status<>0 AND a.moderation_status=1))`, id, current(r).ID) + if queryErr != nil { + fail(w, http.StatusInternalServerError, 50001, "查询失败") + return + } + defer rows.Close() + items := a.scanAdoptions(r.Context(), rows, current(r).ID) + if len(items) == 0 { + fail(w, http.StatusNotFound, 30001, "送养信息不存在") + return + } + view := map[string]any{"adoption": items[0], "questions": adoptionQuestions} + // Answers are private to the giver: they contain where a stranger lives. + if items[0].Mine { + applications, _ := a.adoptionApplications(r.Context(), id) + view["applications"] = applications + var note string + _ = a.db.QueryRowContext(r.Context(), `SELECT moderation_note FROM pet_adoptions WHERE id=?`, id).Scan(¬e) + view["moderationNote"] = note + } + reply(w, view) +} + +func (a *App) adoptionApplications(ctx context.Context, adoptionID int64) ([]map[string]any, error) { + rows, err := a.db.QueryContext(ctx, `SELECT ap.id,ap.applicant_user_id,profile.nickname,profile.avatar_url,ap.answers_json,ap.message,ap.status,ap.created_at + FROM pet_adoption_applications ap JOIN user_profiles profile ON profile.user_id=ap.applicant_user_id + WHERE ap.adoption_id=? ORDER BY ap.id DESC`, adoptionID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, userID int64 + var nickname, avatar, answers, message string + var status int + var created time.Time + if rows.Scan(&id, &userID, &nickname, &avatar, &answers, &message, &status, &created) != nil { + continue + } + parsed := map[string]string{} + _ = json.Unmarshal([]byte(answers), &parsed) + items = append(items, map[string]any{ + "id": id, "userId": userID, "nickname": nickname, "avatar": avatarThumbnailURL(avatar), + "answers": parsed, "message": message, "status": status, "createdAt": created.Format(time.RFC3339), + }) + } + return items, nil +} + +func (a *App) applyAdoption(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "编号无效") + return + } + var req struct { + Answers map[string]string `json:"answers"` + Message string `json:"message"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "申请格式错误") + return + } + for _, question := range adoptionQuestions { + if strings.TrimSpace(req.Answers[question.Key]) == "" { + fail(w, http.StatusBadRequest, 20001, "请回答:"+question.Label) + return + } + if len([]rune(req.Answers[question.Key])) > 200 { + fail(w, http.StatusBadRequest, 20001, "每个回答最多 200 字") + return + } + } + var owner int64 + var status, moderation int + if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id,status,moderation_status FROM pet_adoptions WHERE id=? AND deleted_at IS NULL`, id).Scan(&owner, &status, &moderation) != nil { + fail(w, http.StatusNotFound, 30001, "送养信息不存在") + return + } + if owner == current(r).ID { + fail(w, http.StatusBadRequest, 20001, "不能申请领养自己送养的宠物") + return + } + if status != 1 || moderation != 1 { + fail(w, http.StatusBadRequest, 20001, "这条送养信息已经不接受申请") + return + } + answers, _ := json.Marshal(req.Answers) + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "提交失败") + return + } + defer func() { _ = tx.Rollback() }() + if _, err = tx.ExecContext(r.Context(), `INSERT INTO pet_adoption_applications(adoption_id,applicant_user_id,answers_json,message) + VALUES(?,?,?,?)`, id, current(r).ID, string(answers), strings.TrimSpace(req.Message)); err != nil { + fail(w, http.StatusBadRequest, 20001, "你已经申请过这条送养信息") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE pet_adoptions SET application_count=application_count+1 WHERE id=?`, id); err != nil { + fail(w, http.StatusInternalServerError, 50001, "提交失败") + return + } + if tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "提交失败") + return + } + a.notifyUser(r.Context(), owner, "system", "收到领养申请", "有人申请领养你送养的宠物,去看看是否合适", "adoption", id) + reply(w, map[string]bool{"success": true}) +} + +// decideAdoption is where a listing turns into a real handover: choosing one +// applicant closes the listing, declines the rest and schedules the follow-ups. +func (a *App) decideAdoption(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "编号无效") + return + } + var req struct { + ApplicationID int64 `json:"applicationId"` + Action string `json:"action"` + } + if decode(r, &req) != nil || req.ApplicationID == 0 { + fail(w, http.StatusBadRequest, 20001, "参数错误") + return + } + var owner int64 + var status int + if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id,status FROM pet_adoptions WHERE id=? AND deleted_at IS NULL`, id).Scan(&owner, &status) != nil || owner != current(r).ID { + fail(w, http.StatusNotFound, 30001, "送养信息不存在") + return + } + var applicant int64 + if a.db.QueryRowContext(r.Context(), `SELECT applicant_user_id FROM pet_adoption_applications WHERE id=? AND adoption_id=?`, req.ApplicationID, id).Scan(&applicant) != nil { + fail(w, http.StatusNotFound, 30001, "申请不存在") + return + } + if req.Action == "decline" { + if _, err = a.db.ExecContext(r.Context(), `UPDATE pet_adoption_applications SET status=2,decided_at=NOW(3) WHERE id=? AND status=0`, req.ApplicationID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "操作失败") + return + } + a.notifyUser(r.Context(), applicant, "system", "领养申请未通过", "送养人这次选择了其他领养人,可以再看看别的宠物", "adoption", id) + reply(w, map[string]bool{"success": true}) + return + } + if status != 1 { + fail(w, http.StatusBadRequest, 20001, "这条送养信息已经结束") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "操作失败") + return + } + defer func() { _ = tx.Rollback() }() + if _, err = tx.ExecContext(r.Context(), `UPDATE pet_adoption_applications SET status=1,decided_at=NOW(3) WHERE id=?`, req.ApplicationID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "操作失败") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE pet_adoption_applications SET status=2,decided_at=NOW(3) WHERE adoption_id=? AND id<>? AND status=0`, id, req.ApplicationID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "操作失败") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE pet_adoptions SET status=2,adopter_user_id=?,completed_at=NOW(3) WHERE id=?`, applicant, id); err != nil { + fail(w, http.StatusInternalServerError, 50001, "操作失败") + return + } + // Follow-ups are created now, with the handover, so nothing has to remember + // to create them later. + for _, day := range strings.Split(a.configPlain(r.Context(), "pet.adoption_followup_days", "30,90"), ",") { + days, convErr := strconv.Atoi(strings.TrimSpace(day)) + if convErr != nil || days <= 0 { + continue + } + if _, err = tx.ExecContext(r.Context(), `INSERT INTO pet_adoption_followups(adoption_id,adopter_user_id,due_at) VALUES(?,?,DATE_ADD(NOW(3),INTERVAL ? DAY))`, id, applicant, days); err != nil { + fail(w, http.StatusInternalServerError, 50001, "操作失败") + return + } + } + if tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "操作失败") + return + } + a.notifyUser(r.Context(), applicant, "system", "领养申请通过", "送养人选择了你,请尽快联系并当面确认", "adoption", id) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) closeAdoption(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 pet_adoptions SET status=0 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 (a *App) myAdoptionFollowups(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.adoption_id,f.due_at,f.submitted_at,f.content,f.media_json,p.name,p.avatar_url + FROM pet_adoption_followups f JOIN pet_adoptions a ON a.id=f.adoption_id JOIN pets p ON p.id=a.pet_id + WHERE f.adopter_user_id=? ORDER BY f.submitted_at IS NOT NULL,f.due_at`, current(r).ID) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询回访失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, adoptionID int64 + var due time.Time + var submitted sql.NullTime + var content, petName, avatar string + var mediaJSON sql.NullString + if rows.Scan(&id, &adoptionID, &due, &submitted, &content, &mediaJSON, &petName, &avatar) != nil { + continue + } + media := []string{} + if mediaJSON.Valid { + _ = json.Unmarshal([]byte(mediaJSON.String), &media) + } + items = append(items, map[string]any{ + "id": id, "adoptionId": adoptionID, "dueAt": due.Format(time.RFC3339), + "submitted": submitted.Valid, "content": content, "media": media, "petName": petName, "avatar": avatar, + "overdue": !submitted.Valid && time.Now().After(due), + }) + } + reply(w, map[string]any{"items": items}) +} + +func (a *App) submitAdoptionFollowup(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "编号无效") + return + } + var req struct { + Content string `json:"content"` + Media []string `json:"media"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "回访格式错误") + return + } + req.Content = strings.TrimSpace(req.Content) + if req.Content == "" && len(req.Media) == 0 { + fail(w, http.StatusBadRequest, 20001, "写一句近况,或者上传一张照片") + return + } + if len([]rune(req.Content)) > 500 || len(req.Media) > 9 { + fail(w, http.StatusBadRequest, 20001, "内容过长或照片过多") + return + } + for _, item := range req.Media { + if !validMessageMediaURL(item, a.config.Environment == "production") { + fail(w, http.StatusBadRequest, 20001, "照片地址无效") + return + } + } + media, _ := json.Marshal(req.Media) + result, err := a.db.ExecContext(r.Context(), `UPDATE pet_adoption_followups SET content=?,media_json=?,submitted_at=NOW(3) + WHERE id=? AND adopter_user_id=? AND submitted_at IS NULL`, req.Content, string(media), 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}) +} diff --git a/im/backend/internal/app/pet_adoption_integration_test.go b/im/backend/internal/app/pet_adoption_integration_test.go new file mode 100644 index 0000000..e849913 --- /dev/null +++ b/im/backend/internal/app/pet_adoption_integration_test.go @@ -0,0 +1,216 @@ +package app + +import ( + "encoding/json" + "fmt" + "net/http/httptest" + "strings" + "testing" +) + +// petWithPhotos creates a profile complete enough to be given away: photos to +// look at and a vaccination date to trust. +func petWithPhotos(t *testing.T, a *App, owner int64, name string, photos int) int64 { + t.Helper() + urls := make([]string, 0, photos) + for index := 0; index < photos; index++ { + urls = append(urls, fmt.Sprintf("https://cdn.example.com/%s-%d.jpg", name, index)) + } + encoded, _ := json.Marshal(urls) + body := fmt.Sprintf(`{"name":%q,"species":"cat","breed":"狸花","gender":2,"birthday":"2023-01-01","vaccinatedAt":"2026-06-01","photos":%s}`, name, encoded) + w := httptest.NewRecorder() + a.createPet(w, imTestRequest(owner, "POST", "/api/v1/pets", body)) + if w.Code != 200 { + t.Fatalf("建立宠物档案失败: %s", w.Body.String()) + } + var envelope struct { + Data struct { + ID int64 `json:"id"` + } `json:"data"` + } + _ = json.Unmarshal(w.Body.Bytes(), &envelope) + return envelope.Data.ID +} + +// Adoption is an information service with one hard rule: no money. Everything +// below exists to keep it that way and to make the handover accountable. +func TestPetAdoptionMySQL(t *testing.T) { + db := isolatedIMDatabase(t) + a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} + publish := func(owner, petID int64, reason string) (int, string) { + t.Helper() + w := httptest.NewRecorder() + body := fmt.Sprintf(`{"petId":%d,"cityCode":"440300","cityName":"深圳","reason":%q,"requirements":"希望能定期反馈近况"}`, petID, reason) + a.createAdoption(w, imTestRequest(owner, "POST", "/api/v1/pet/adoptions", body)) + return w.Code, w.Body.String() + } + answers := `{"answers":{"housing":"自有住房","members":"家人都同意","pets":"没有","experience":"养过五年猫","followup":"接受"},"message":"想给它一个家"}` + + t.Run("a profile without photos or vaccination cannot be given away", func(t *testing.T) { + bare := petWithPhotos(t, a, 1, "无照片", 0) + if status, body := publish(1, bare, "工作变动无法继续照顾"); status != 400 || !strings.Contains(body, "至少上传") { + t.Fatalf("HTTP %d: %s", status, body) + } + }) + + t.Run("a complete listing waits for review before anyone sees it", func(t *testing.T) { + pet := petWithPhotos(t, a, 1, "团子", 3) + status, body := publish(1, pet, "工作变动,实在没有精力继续照顾") + if status != 200 { + t.Fatalf("HTTP %d: %s", status, body) + } + var shelf struct { + Items []adoptionView `json:"items"` + } + if err := json.Unmarshal(imTestCall(t, a.adoptions, 2, "GET", "/api/v1/pet/adoptions", "", 200), &shelf); err != nil { + t.Fatal(err) + } + if len(shelf.Items) != 0 { + t.Fatal("未审核的送养信息不该出现在列表里") + } + // The giver still sees their own listing, pending or not. + var mine struct { + Items []adoptionView `json:"items"` + } + if err := json.Unmarshal(imTestCall(t, a.adoptions, 1, "GET", "/api/v1/pet/adoptions?mine=1", "", 200), &mine); err != nil { + t.Fatal(err) + } + if len(mine.Items) != 1 || mine.Items[0].Moderation != 0 { + t.Fatalf("mine = %+v", mine.Items) + } + }) + + t.Run("anything that smells like a price is flagged for a human", func(t *testing.T) { + pet := petWithPhotos(t, a, 3, "收费猫", 3) + _, body := publish(3, pet, "因搬家送养,需要象征性收取一点疫苗费用") + if !strings.Contains(body, "疫苗") && !strings.Contains(body, "费用") { + t.Fatalf("命中敏感词时应当说明原因: %s", body) + } + var flagged string + if err := db.QueryRow(`SELECT review_reason FROM pet_adoptions WHERE owner_user_id=3 ORDER BY id DESC LIMIT 1`).Scan(&flagged); err != nil { + t.Fatal(err) + } + if flagged == "" { + t.Fatal("送养文案含收费话术时必须标记转人工") + } + }) + + t.Run("the whole handover: approve, apply, choose, schedule follow-ups", func(t *testing.T) { + var adoptionID int64 + if err := db.QueryRow(`SELECT id FROM pet_adoptions WHERE owner_user_id=1 ORDER BY id DESC LIMIT 1`).Scan(&adoptionID); err != nil { + t.Fatal(err) + } + approve := httptest.NewRecorder() + a.adminModeratePetListing(approve, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/listings/%d/moderate", adoptionID), `{"kind":"adoption","status":1}`)) + if approve.Code != 200 { + t.Fatalf("审核通过失败: %s", approve.Body.String()) + } + + apply := httptest.NewRecorder() + a.applyAdoption(apply, imTestRequest(2, "POST", fmt.Sprintf("/api/v1/pet/adoptions/%d/apply", adoptionID), answers)) + if apply.Code != 200 { + t.Fatalf("申请失败: %s", apply.Body.String()) + } + // Applying twice is impossible, not merely discouraged. + again := httptest.NewRecorder() + a.applyAdoption(again, imTestRequest(2, "POST", fmt.Sprintf("/api/v1/pet/adoptions/%d/apply", adoptionID), answers)) + if again.Code != 400 { + t.Fatalf("重复申请应被拒绝: HTTP %d", again.Code) + } + // The giver sees the answers; a stranger does not. + var detail struct { + Applications []map[string]any `json:"applications"` + } + if err := json.Unmarshal(imTestCall(t, a.adoptionDetail, 1, "GET", fmt.Sprintf("/api/v1/pet/adoptions/%d", adoptionID), "", 200), &detail); err != nil { + t.Fatal(err) + } + if len(detail.Applications) != 1 { + t.Fatalf("送养人应当看到申请: %+v", detail) + } + var stranger struct { + Applications []map[string]any `json:"applications"` + } + if err := json.Unmarshal(imTestCall(t, a.adoptionDetail, 3, "GET", fmt.Sprintf("/api/v1/pet/adoptions/%d", adoptionID), "", 200), &stranger); err != nil { + t.Fatal(err) + } + if len(stranger.Applications) != 0 { + t.Fatal("问卷答案里有别人住在哪,不能给无关的人看") + } + + applicationID := int64(detail.Applications[0]["id"].(float64)) + decide := httptest.NewRecorder() + a.decideAdoption(decide, imTestRequest(1, "POST", fmt.Sprintf("/api/v1/pet/adoptions/%d/decide", adoptionID), + fmt.Sprintf(`{"applicationId":%d,"action":"accept"}`, applicationID))) + if decide.Code != 200 { + t.Fatalf("选定失败: %s", decide.Body.String()) + } + var status int + var adopter int64 + if err := db.QueryRow(`SELECT status,adopter_user_id FROM pet_adoptions WHERE id=?`, adoptionID).Scan(&status, &adopter); err != nil { + t.Fatal(err) + } + if status != 2 || adopter != 2 { + t.Fatalf("status=%d adopter=%d", status, adopter) + } + // Follow-ups are created with the handover, not by something remembering later. + var followups int + if err := db.QueryRow(`SELECT COUNT(*) FROM pet_adoption_followups WHERE adoption_id=? AND adopter_user_id=2`, adoptionID).Scan(&followups); err != nil { + t.Fatal(err) + } + if followups != 2 { + t.Fatalf("回访任务 = %d,应当按 30/90 天各建一条", followups) + } + // A closed listing takes no more applications. + late := httptest.NewRecorder() + a.applyAdoption(late, imTestRequest(3, "POST", fmt.Sprintf("/api/v1/pet/adoptions/%d/apply", adoptionID), answers)) + if late.Code != 400 { + t.Fatalf("已送出的信息不该再接受申请: HTTP %d", late.Code) + } + }) + + t.Run("the adopter reports back and the record shows it", func(t *testing.T) { + var followupID int64 + if err := db.QueryRow(`SELECT id FROM pet_adoption_followups WHERE adopter_user_id=2 ORDER BY due_at LIMIT 1`).Scan(&followupID); err != nil { + t.Fatal(err) + } + submit := httptest.NewRecorder() + a.submitAdoptionFollowup(submit, imTestRequest(2, "POST", fmt.Sprintf("/api/v1/pet/followups/%d", followupID), + `{"content":"已经适应新家,胖了两斤","media":["https://cdn.example.com/followup.jpg"]}`)) + if submit.Code != 200 { + t.Fatalf("HTTP %d: %s", submit.Code, submit.Body.String()) + } + twice := httptest.NewRecorder() + a.submitAdoptionFollowup(twice, imTestRequest(2, "POST", fmt.Sprintf("/api/v1/pet/followups/%d", followupID), `{"content":"再来一次"}`)) + if twice.Code != 404 { + t.Fatalf("同一条回访不该重复提交: HTTP %d", twice.Code) + } + var list struct { + Items []map[string]any `json:"items"` + } + if err := json.Unmarshal(imTestCall(t, a.myAdoptionFollowups, 2, "GET", "/api/v1/pet/followups", "", 200), &list); err != nil { + t.Fatal(err) + } + // 待办排在前面:这个列表是用来提醒人去做事的,不是用来存档的。 + if len(list.Items) != 2 || list.Items[0]["submitted"] != false || list.Items[1]["submitted"] != true { + t.Fatalf("未提交的回访应当排在前面: %+v", list.Items) + } + }) + + t.Run("an incomplete questionnaire is refused with the missing question", func(t *testing.T) { + pet := petWithPhotos(t, a, 3, "第二只", 3) + publish(3, pet, "家里过敏,只能忍痛送养") + var adoptionID int64 + if err := db.QueryRow(`SELECT id FROM pet_adoptions WHERE owner_user_id=3 ORDER BY id DESC LIMIT 1`).Scan(&adoptionID); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`UPDATE pet_adoptions SET moderation_status=1 WHERE id=?`, adoptionID); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + a.applyAdoption(w, imTestRequest(1, "POST", fmt.Sprintf("/api/v1/pet/adoptions/%d/apply", adoptionID), + `{"answers":{"housing":"租住"},"message":""}`)) + if w.Code != 400 || !strings.Contains(w.Body.String(), "家人") { + t.Fatalf("HTTP %d: %s", w.Code, w.Body.String()) + } + }) +} diff --git a/im/backend/internal/app/pet_feed.go b/im/backend/internal/app/pet_feed.go new file mode 100644 index 0000000..43f846f --- /dev/null +++ b/im/backend/internal/app/pet_feed.go @@ -0,0 +1,702 @@ +package app + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "math" + "net/http" + "strings" + "time" +) + +// The paid half of the module. The order of operations matters more than any +// single rule here: money is taken before a sitter is chosen, the address is +// revealed only after one is, the work leaves evidence, and the payout happens +// once — from a ledger that can be audited afterwards. + +const ( + feedVisitSteps = "arrive,feed,water,litter,leave" +) + +type feedTaskView struct { + ID int64 `json:"id"` + OwnerUserID int64 `json:"ownerUserId"` + OwnerName string `json:"ownerName"` + PetID int64 `json:"petId"` + PetName string `json:"petName"` + Species string `json:"species"` + SpeciesLabel string `json:"speciesLabel"` + PetAvatar string `json:"petAvatar"` + CityName string `json:"cityName"` + AreaText string `json:"areaText"` + StartDate string `json:"startDate"` + EndDate string `json:"endDate"` + VisitsPerDay int `json:"visitsPerDay"` + MinMinutes int `json:"minMinutes"` + PriceCent int64 `json:"priceCent"` + GrossCent int64 `json:"grossCent"` + PayoutCent int64 `json:"payoutCent"` + Note string `json:"note"` + Status string `json:"status"` + SitterUserID int64 `json:"sitterUserId"` + SitterName string `json:"sitterName"` + Applications int `json:"applicationsCount"` + TotalVisits int `json:"totalVisits"` + Done int `json:"completedVisits"` + Items []string `json:"items"` + Mine bool `json:"mine"` + IsSitter bool `json:"isSitter"` + CreatedAt string `json:"createdAt"` +} + +var feedItemKinds = map[string]string{ + "feed": "喂粮", "water": "换水", "litter": "铲砂", "walk": "遛狗", "medicine": "喂药", +} + +func (a *App) feedFeeSplit(ctx context.Context, gross int64) (fee int64, payout int64) { + percent := int64(a.configInt(ctx, "pet.feed_platform_fee_percent", 15)) + if percent < 0 || percent > 50 { + percent = 15 + } + fee = gross * percent / 100 + return fee, gross - fee +} + +// --- 地址 ----------------------------------------------------------------- + +func (a *App) createPetAddress(w http.ResponseWriter, r *http.Request) { + var req struct { + CityCode string `json:"cityCode"` + CityName string `json:"cityName"` + AreaText string `json:"areaText"` + Detail string `json:"detail"` + Contact string `json:"contact"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "地址格式错误") + return + } + req.AreaText = strings.TrimSpace(req.AreaText) + req.Detail = strings.TrimSpace(req.Detail) + req.Contact = strings.TrimSpace(req.Contact) + if req.AreaText == "" || req.Detail == "" || req.Contact == "" { + fail(w, http.StatusBadRequest, 20001, "请填写完整的地址与联系方式") + return + } + if len([]rune(req.AreaText)) > 100 || len([]rune(req.Detail)) > 200 || len([]rune(req.Contact)) > 100 { + fail(w, http.StatusBadRequest, 20001, "地址内容过长") + return + } + if req.Latitude == 0 || req.Longitude == 0 { + fail(w, http.StatusBadRequest, 20001, "请先获取定位,打卡需要与地址比对") + return + } + // Door number and phone are encrypted at rest; only the coarse area is ever + // shown to people browsing the hall. + detail, err := a.encryptPhone(req.Detail) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "保存失败") + return + } + contact, err := a.encryptPhone(req.Contact) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "保存失败") + return + } + result, err := a.db.ExecContext(r.Context(), `INSERT INTO pet_addresses(user_id,city_code,city_name,area_text,detail_cipher,contact_cipher,latitude,longitude) + VALUES(?,?,?,?,?,?,?,?)`, current(r).ID, strings.TrimSpace(req.CityCode), strings.TrimSpace(req.CityName), req.AreaText, detail, contact, req.Latitude, req.Longitude) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "保存失败") + return + } + id, _ := result.LastInsertId() + reply(w, map[string]any{"id": id}) +} + +func (a *App) petAddresses(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT id,city_name,area_text FROM pet_addresses WHERE user_id=? ORDER BY id DESC`, current(r).ID) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询地址失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var city, area string + if rows.Scan(&id, &city, &area) == nil { + items = append(items, map[string]any{"id": id, "cityName": city, "areaText": area}) + } + } + reply(w, map[string]any{"items": items}) +} + +// --- 喂养者 --------------------------------------------------------------- + +func (a *App) sitterProfile(w http.ResponseWriter, r *http.Request) { + var cityCode, cityName, intro, note string + var radius, deposit, ratingTotal, ratingCount, completed, status int + err := a.db.QueryRowContext(r.Context(), `SELECT city_code,city_name,radius_m,intro,deposit_cent,rating_total,rating_count,completed_count,status,status_note + FROM pet_sitters WHERE user_id=?`, current(r).ID).Scan(&cityCode, &cityName, &radius, &intro, &deposit, &ratingTotal, &ratingCount, &completed, &status, ¬e) + if err == sql.ErrNoRows { + reply(w, map[string]any{"registered": false, "depositRequiredCent": a.configInt(r.Context(), "pet.sitter_deposit_cent", 20000)}) + return + } + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询失败") + return + } + rating := 0.0 + if ratingCount > 0 { + rating = math.Round(float64(ratingTotal)/float64(ratingCount)*10) / 10 + } + reply(w, map[string]any{ + "registered": true, "cityCode": cityCode, "cityName": cityName, "radiusM": radius, "intro": intro, + "depositCent": deposit, "depositRequiredCent": a.configInt(r.Context(), "pet.sitter_deposit_cent", 20000), + "rating": rating, "ratingCount": ratingCount, "completedCount": completed, + "status": status, "statusNote": note, + }) +} + +func (a *App) applySitter(w http.ResponseWriter, r *http.Request) { + var req struct { + CityCode string `json:"cityCode"` + CityName string `json:"cityName"` + RadiusM int `json:"radiusM"` + Intro string `json:"intro"` + Certs []string `json:"certs"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "资料格式错误") + return + } + req.Intro = strings.TrimSpace(req.Intro) + if len([]rune(req.Intro)) < 10 || len([]rune(req.Intro)) > 500 { + fail(w, http.StatusBadRequest, 20001, "请介绍一下你的养宠经验,10 到 500 字") + return + } + if req.RadiusM < 1000 || req.RadiusM > 50000 { + req.RadiusM = 5000 + } + // Entering someone's home is not something an anonymous account may do. + if _, verified := a.verifiedRealName(r.Context(), current(r).ID); !verified { + fail(w, http.StatusForbidden, 30003, "上门服务需要先完成实名认证") + return + } + certs, _ := json.Marshal(req.Certs) + _, err := a.db.ExecContext(r.Context(), `INSERT INTO pet_sitters(user_id,city_code,city_name,radius_m,intro,certs_json,status) + VALUES(?,?,?,?,?,?,0) ON DUPLICATE KEY UPDATE city_code=VALUES(city_code),city_name=VALUES(city_name), + radius_m=VALUES(radius_m),intro=VALUES(intro),certs_json=VALUES(certs_json),status=IF(status=1,1,0),status_note=''`, + current(r).ID, strings.TrimSpace(req.CityCode), strings.TrimSpace(req.CityName), req.RadiusM, req.Intro, string(certs)) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "提交失败") + return + } + reply(w, map[string]bool{"success": true}) +} + +// --- 任务 ----------------------------------------------------------------- + +func (a *App) createFeedTask(w http.ResponseWriter, r *http.Request) { + if !a.configBool(r.Context(), "pet.feed_enabled", true) { + fail(w, http.StatusForbidden, 30002, "上门代喂暂未开放") + return + } + var req struct { + PetID int64 `json:"petId"` + AddressID int64 `json:"addressId"` + StartDate string `json:"startDate"` + EndDate string `json:"endDate"` + VisitsPerDay int `json:"visitsPerDay"` + MinMinutes int `json:"minMinutes"` + PriceCent int64 `json:"priceCent"` + Note string `json:"note"` + Items []struct { + Kind string `json:"kind"` + Amount string `json:"amount"` + } `json:"items"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "任务格式错误") + return + } + start, err := time.Parse("2006-01-02", strings.TrimSpace(req.StartDate)) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "开始日期格式应为 2006-01-02") + return + } + end, err := time.Parse("2006-01-02", strings.TrimSpace(req.EndDate)) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "结束日期格式应为 2006-01-02") + return + } + if end.Before(start) { + fail(w, http.StatusBadRequest, 20001, "结束日期不能早于开始日期") + return + } + days := int(end.Sub(start).Hours()/24) + 1 + // Beyond a couple of weeks this stops being "feed my cat while I travel" and + // becomes boarding, which is a different product with a different licence. + if maxDays := a.configInt(r.Context(), "pet.feed_max_days", 14); days > maxDays { + fail(w, http.StatusBadRequest, 20001, fmt.Sprintf("单个任务最长 %d 天", maxDays)) + return + } + if req.VisitsPerDay < 1 || req.VisitsPerDay > 4 { + fail(w, http.StatusBadRequest, 20001, "每天上门次数应在 1 到 4 之间") + return + } + if req.MinMinutes < 10 || req.MinMinutes > 180 { + req.MinMinutes = 20 + } + minPrice := int64(a.configInt(r.Context(), "pet.feed_min_price_cent", 2000)) + maxPrice := int64(a.configInt(r.Context(), "pet.feed_max_price_cent", 50000)) + if req.PriceCent < minPrice || req.PriceCent > maxPrice { + fail(w, http.StatusBadRequest, 20001, fmt.Sprintf("单次报酬应在 %.0f 到 %.0f 元之间", float64(minPrice)/100, float64(maxPrice)/100)) + return + } + if len(req.Items) == 0 { + fail(w, http.StatusBadRequest, 20001, "请至少添加一项任务内容") + return + } + for _, item := range req.Items { + if _, ok := feedItemKinds[item.Kind]; !ok { + fail(w, http.StatusBadRequest, 20001, "任务内容类型无效") + return + } + } + var cityCode string + if a.db.QueryRowContext(r.Context(), `SELECT city_code FROM pet_addresses WHERE id=? AND user_id=?`, req.AddressID, current(r).ID).Scan(&cityCode) != nil { + fail(w, http.StatusNotFound, 30001, "服务地址不存在") + return + } + var petName string + if a.db.QueryRowContext(r.Context(), `SELECT name FROM pets WHERE id=? AND owner_user_id=? AND status=1 AND deleted_at IS NULL`, req.PetID, current(r).ID).Scan(&petName) != nil { + fail(w, http.StatusNotFound, 30001, "宠物不存在") + return + } + totalVisits := days * req.VisitsPerDay + gross := req.PriceCent * int64(totalVisits) + fee, payout := a.feedFeeSplit(r.Context(), gross) + + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建失败") + return + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(r.Context(), `INSERT INTO pet_feed_tasks + (owner_user_id,pet_id,address_id,city_code,start_date,end_date,visits_per_day,min_minutes,price_cent,gross_cent,platform_fee_cent,payout_cent,note,total_visits) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + current(r).ID, req.PetID, req.AddressID, cityCode, start, end, req.VisitsPerDay, req.MinMinutes, + req.PriceCent, gross, fee, payout, strings.TrimSpace(req.Note), totalVisits) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建失败") + return + } + taskID, _ := result.LastInsertId() + for _, item := range req.Items { + if _, err = tx.ExecContext(r.Context(), `INSERT INTO pet_feed_task_items(task_id,kind,amount_text) VALUES(?,?,?)`, + taskID, item.Kind, strings.TrimSpace(item.Amount)); err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建失败") + return + } + } + // Every visit is planned up front, so both sides can see the schedule before + // any money moves. + for day := 0; day < days; day++ { + date := start.AddDate(0, 0, day) + for sequence := 1; sequence <= req.VisitsPerDay; sequence++ { + if _, err = tx.ExecContext(r.Context(), `INSERT INTO pet_feed_visits(task_id,visit_date,sequence) VALUES(?,?,?)`, taskID, date, sequence); err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建失败") + return + } + } + } + if tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "创建失败") + return + } + reply(w, map[string]any{"id": taskID, "grossCent": gross, "platformFeeCent": fee, "payoutCent": payout, "totalVisits": totalVisits}) +} + +// escrowFeedTask stands in for the payment callback: the task only becomes +// visible to sitters once the money is held. Wiring it to the real gateway +// replaces the body, not the state machine. +func (a *App) escrowFeedTask(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 pet_feed_tasks SET status='ESCROWED' WHERE id=? AND owner_user_id=? AND status='CREATED'`, id, current(r).ID) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "操作失败") + return + } + if affected, _ := result.RowsAffected(); affected == 0 { + fail(w, http.StatusBadRequest, 20001, "任务状态不允许支付") + return + } + reply(w, map[string]bool{"success": true}) +} + +const feedTaskColumns = `t.id,t.owner_user_id,owner.nickname,t.pet_id,p.name,p.species,p.avatar_url,addr.city_name,addr.area_text, + t.start_date,t.end_date,t.visits_per_day,t.min_minutes,t.price_cent,t.gross_cent,t.payout_cent,t.note,t.status, + COALESCE(t.sitter_user_id,0),COALESCE(sitter.nickname,''),t.applications_count,t.total_visits,t.completed_visits,t.created_at` + +func (a *App) scanFeedTasks(ctx context.Context, rows *sql.Rows, viewer int64) []feedTaskView { + items := []feedTaskView{} + ids := []any{} + placeholders := []string{} + for rows.Next() { + var item feedTaskView + var start, end time.Time + var created time.Time + if rows.Scan(&item.ID, &item.OwnerUserID, &item.OwnerName, &item.PetID, &item.PetName, &item.Species, &item.PetAvatar, + &item.CityName, &item.AreaText, &start, &end, &item.VisitsPerDay, &item.MinMinutes, &item.PriceCent, + &item.GrossCent, &item.PayoutCent, &item.Note, &item.Status, &item.SitterUserID, &item.SitterName, + &item.Applications, &item.TotalVisits, &item.Done, &created) != nil { + continue + } + item.SpeciesLabel = petSpecies[item.Species] + item.StartDate = start.Format("2006-01-02") + item.EndDate = end.Format("2006-01-02") + item.CreatedAt = created.Format(time.RFC3339) + item.Mine = item.OwnerUserID == viewer + item.IsSitter = item.SitterUserID == viewer + item.Items = []string{} + items = append(items, item) + ids = append(ids, item.ID) + placeholders = append(placeholders, "?") + } + if len(ids) == 0 { + return items + } + itemRows, err := a.db.QueryContext(ctx, `SELECT task_id,kind,amount_text FROM pet_feed_task_items WHERE task_id IN (`+strings.Join(placeholders, ",")+`) ORDER BY id`, ids...) + if err == nil { + defer itemRows.Close() + for itemRows.Next() { + var taskID int64 + var kind, amount string + if itemRows.Scan(&taskID, &kind, &amount) != nil { + continue + } + label := feedItemKinds[kind] + if amount != "" { + label += " " + amount + } + for index := range items { + if items[index].ID == taskID { + items[index].Items = append(items[index].Items, label) + } + } + } + } + return items +} + +func (a *App) feedTasks(w http.ResponseWriter, r *http.Request) { + _, size, offset := pageOptions(r) + scope := strings.TrimSpace(r.URL.Query().Get("scope")) + where := "" + args := []any{} + switch scope { + case "mine": + where = " WHERE t.owner_user_id=?" + args = append(args, current(r).ID) + case "sitting": + where = " WHERE t.sitter_user_id=?" + args = append(args, current(r).ID) + default: + // The hall only shows funded, unassigned tasks: nobody should spend time + // on a task that has not been paid for. + where = " WHERE t.status='ESCROWED'" + if city := strings.TrimSpace(r.URL.Query().Get("cityCode")); city != "" { + where += " AND t.city_code=?" + args = append(args, city) + } + } + rows, err := a.db.QueryContext(r.Context(), `SELECT `+feedTaskColumns+` FROM pet_feed_tasks t + JOIN pets p ON p.id=t.pet_id JOIN pet_addresses addr ON addr.id=t.address_id + JOIN user_profiles owner ON owner.user_id=t.owner_user_id + LEFT JOIN user_profiles sitter ON sitter.user_id=t.sitter_user_id`+where+` ORDER BY t.id DESC LIMIT ? OFFSET ?`, + append(args, size+1, offset)...) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询任务失败") + return + } + defer rows.Close() + items := a.scanFeedTasks(r.Context(), rows, current(r).ID) + hasMore := len(items) > size + if hasMore { + items = items[:size] + } + reply(w, map[string]any{"items": items, "hasMore": hasMore}) +} + +func (a *App) feedTaskDetail(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "编号无效") + return + } + rows, queryErr := a.db.QueryContext(r.Context(), `SELECT `+feedTaskColumns+` FROM pet_feed_tasks t + JOIN pets p ON p.id=t.pet_id JOIN pet_addresses addr ON addr.id=t.address_id + JOIN user_profiles owner ON owner.user_id=t.owner_user_id + LEFT JOIN user_profiles sitter ON sitter.user_id=t.sitter_user_id WHERE t.id=?`, id) + if queryErr != nil { + fail(w, http.StatusInternalServerError, 50001, "查询失败") + return + } + defer rows.Close() + items := a.scanFeedTasks(r.Context(), rows, current(r).ID) + if len(items) == 0 { + fail(w, http.StatusNotFound, 30001, "任务不存在") + return + } + task := items[0] + view := map[string]any{"task": task} + // The exact address and phone number are released only to the person who has + // to go there, and only once they have been chosen. + if task.IsSitter && (task.Status == "ASSIGNED" || task.Status == "SERVING" || task.Status == "COMPLETED") { + detail, contact := a.decryptAddress(r.Context(), task.ID) + view["address"] = map[string]any{"detail": detail, "contact": contact} + } + if task.Mine { + applications, _ := a.feedApplications(r.Context(), id) + view["applications"] = applications + } + visits, _ := a.feedVisits(r.Context(), id) + view["visits"] = visits + reply(w, view) +} + +func (a *App) decryptAddress(ctx context.Context, taskID int64) (string, string) { + var detailCipher, contactCipher []byte + if a.db.QueryRowContext(ctx, `SELECT addr.detail_cipher,addr.contact_cipher FROM pet_feed_tasks t JOIN pet_addresses addr ON addr.id=t.address_id WHERE t.id=?`, taskID). + Scan(&detailCipher, &contactCipher) != nil { + return "", "" + } + detail, err := a.decryptPhone(detailCipher) + if err != nil { + return "", "" + } + contact, err := a.decryptPhone(contactCipher) + if err != nil { + return detail, "" + } + return detail, contact +} + +func (a *App) feedApplications(ctx context.Context, taskID int64) ([]map[string]any, error) { + rows, err := a.db.QueryContext(ctx, `SELECT ap.id,ap.sitter_user_id,profile.nickname,profile.avatar_url,ap.message,ap.status,ap.created_at, + COALESCE(s.completed_count,0),COALESCE(s.rating_total,0),COALESCE(s.rating_count,0) + FROM pet_feed_applications ap JOIN user_profiles profile ON profile.user_id=ap.sitter_user_id + LEFT JOIN pet_sitters s ON s.user_id=ap.sitter_user_id WHERE ap.task_id=? ORDER BY ap.id DESC`, taskID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, userID int64 + var nickname, avatar, message string + var status, completed, ratingTotal, ratingCount int + var created time.Time + if rows.Scan(&id, &userID, &nickname, &avatar, &message, &status, &created, &completed, &ratingTotal, &ratingCount) != nil { + continue + } + rating := 0.0 + if ratingCount > 0 { + rating = math.Round(float64(ratingTotal)/float64(ratingCount)*10) / 10 + } + items = append(items, map[string]any{ + "id": id, "sitterUserId": userID, "nickname": nickname, "avatar": avatarThumbnailURL(avatar), + "message": message, "status": status, "completedCount": completed, "rating": rating, + "createdAt": created.Format(time.RFC3339), + }) + } + return items, nil +} + +func (a *App) feedVisits(ctx context.Context, taskID int64) ([]map[string]any, error) { + rows, err := a.db.QueryContext(ctx, `SELECT id,visit_date,sequence,started_at,finished_at,status,abnormal FROM pet_feed_visits WHERE task_id=? ORDER BY visit_date,sequence`, taskID) + if err != nil { + return nil, err + } + defer rows.Close() + visits := []map[string]any{} + ids := []any{} + placeholders := []string{} + for rows.Next() { + var id int64 + var date time.Time + var sequence, status, abnormal int + var started, finished sql.NullTime + if rows.Scan(&id, &date, &sequence, &started, &finished, &status, &abnormal) != nil { + continue + } + visit := map[string]any{ + "id": id, "date": date.Format("2006-01-02"), "sequence": sequence, "status": status, + "abnormal": abnormal == 1, "checkins": []map[string]any{}, + } + if started.Valid { + visit["startedAt"] = started.Time.Format(time.RFC3339) + } + if finished.Valid { + visit["finishedAt"] = finished.Time.Format(time.RFC3339) + } + visits = append(visits, visit) + ids = append(ids, id) + placeholders = append(placeholders, "?") + } + if len(ids) == 0 { + return visits, nil + } + checkinRows, err := a.db.QueryContext(ctx, `SELECT visit_id,step,media_url,distance_m,captured_at,verdict FROM pet_feed_checkins WHERE visit_id IN (`+strings.Join(placeholders, ",")+`) ORDER BY id`, ids...) + if err != nil { + return visits, nil + } + defer checkinRows.Close() + for checkinRows.Next() { + var visitID int64 + var step, media, verdict string + var distance sql.NullInt64 + var captured time.Time + if checkinRows.Scan(&visitID, &step, &media, &distance, &captured, &verdict) != nil { + continue + } + for index := range visits { + if visits[index]["id"] == visitID { + list := visits[index]["checkins"].([]map[string]any) + visits[index]["checkins"] = append(list, map[string]any{ + "step": step, "media": media, "distanceM": distance.Int64, + "capturedAt": captured.Format(time.RFC3339), "verdict": verdict, + }) + } + } + } + return visits, nil +} + +func (a *App) applyFeedTask(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "编号无效") + return + } + var req struct { + Message string `json:"message"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "格式错误") + return + } + var status int + var deposit int + if a.db.QueryRowContext(r.Context(), `SELECT status,deposit_cent FROM pet_sitters WHERE user_id=?`, current(r).ID).Scan(&status, &deposit) != nil { + fail(w, http.StatusForbidden, 30003, "请先申请成为喂养者") + return + } + if status != 1 { + fail(w, http.StatusForbidden, 30003, "喂养者资格审核通过后才能接单") + return + } + if required := a.configInt(r.Context(), "pet.sitter_deposit_cent", 20000); deposit < required { + fail(w, http.StatusForbidden, 30003, fmt.Sprintf("请先缴纳 %.0f 元保证金", float64(required)/100)) + return + } + var owner int64 + var taskStatus string + if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id,status FROM pet_feed_tasks WHERE id=?`, id).Scan(&owner, &taskStatus) != nil { + fail(w, http.StatusNotFound, 30001, "任务不存在") + return + } + if owner == current(r).ID { + fail(w, http.StatusBadRequest, 20001, "不能接自己发布的任务") + return + } + if taskStatus != "ESCROWED" { + fail(w, http.StatusBadRequest, 20001, "任务已经不接受报名") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "报名失败") + return + } + defer func() { _ = tx.Rollback() }() + if _, err = tx.ExecContext(r.Context(), `INSERT INTO pet_feed_applications(task_id,sitter_user_id,message) VALUES(?,?,?)`, + id, current(r).ID, strings.TrimSpace(req.Message)); err != nil { + fail(w, http.StatusBadRequest, 20001, "你已经报名过这个任务") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET applications_count=applications_count+1 WHERE id=?`, id); err != nil { + fail(w, http.StatusInternalServerError, 50001, "报名失败") + return + } + if tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "报名失败") + return + } + a.notifyUser(r.Context(), owner, "system", "有人接单了", "有喂养者报名了你的代喂任务,去看看是否合适", "feed_task", id) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) assignFeedTask(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "编号无效") + return + } + var req struct { + ApplicationID int64 `json:"applicationId"` + } + if decode(r, &req) != nil || req.ApplicationID == 0 { + fail(w, http.StatusBadRequest, 20001, "参数错误") + return + } + var owner int64 + var status string + if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id,status FROM pet_feed_tasks WHERE id=?`, id).Scan(&owner, &status) != nil || owner != current(r).ID { + fail(w, http.StatusNotFound, 30001, "任务不存在") + return + } + if status != "ESCROWED" { + fail(w, http.StatusBadRequest, 20001, "任务状态不允许选定") + return + } + var sitter int64 + if a.db.QueryRowContext(r.Context(), `SELECT sitter_user_id FROM pet_feed_applications WHERE id=? AND task_id=?`, req.ApplicationID, id).Scan(&sitter) != nil { + fail(w, http.StatusNotFound, 30001, "报名不存在") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "操作失败") + return + } + defer func() { _ = tx.Rollback() }() + if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_applications SET status=1 WHERE id=?`, req.ApplicationID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "操作失败") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_applications SET status=2 WHERE task_id=? AND id<>? AND status=0`, id, req.ApplicationID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "操作失败") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status='ASSIGNED',sitter_user_id=? WHERE id=? AND status='ESCROWED'`, sitter, id); err != nil { + fail(w, http.StatusInternalServerError, 50001, "操作失败") + return + } + if tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "操作失败") + return + } + a.notifyUser(r.Context(), sitter, "system", "你被选中了", "主人选择了你,任务详情里现在可以看到门牌与联系方式", "feed_task", id) + reply(w, map[string]bool{"success": true}) +} diff --git a/im/backend/internal/app/pet_feed_admin.go b/im/backend/internal/app/pet_feed_admin.go new file mode 100644 index 0000000..c842326 --- /dev/null +++ b/im/backend/internal/app/pet_feed_admin.go @@ -0,0 +1,309 @@ +package app + +import ( + "database/sql" + "net/http" + "strings" + "time" +) + +// Operations side of the paid module: who may take orders, which tasks are +// stuck, and who gets the money when the two sides disagree. + +func (a *App) adminSitters(w http.ResponseWriter, r *http.Request) { + _, size, offset := pagination(r) + where := " WHERE 1=1" + args := []any{} + if status := strings.TrimSpace(r.URL.Query().Get("status")); status != "" { + where += " AND s.status=?" + args = append(args, status) + } + if keyword := strings.TrimSpace(r.URL.Query().Get("keyword")); keyword != "" { + where += " AND (profile.nickname LIKE ? OR s.user_id=?)" + args = append(args, "%"+keyword+"%", keyword) + } + rows, err := a.db.QueryContext(r.Context(), `SELECT s.user_id,profile.nickname,profile.avatar_url,s.city_name,s.radius_m,s.intro,s.certs_json, + s.deposit_cent,s.rating_total,s.rating_count,s.completed_count,s.cancelled_count,s.status,s.status_note,s.created_at + FROM pet_sitters s JOIN user_profiles profile ON profile.user_id=s.user_id`+where+` ORDER BY s.status ASC,s.user_id DESC LIMIT ? OFFSET ?`, + append(args, size, offset)...) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询喂养者失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var userID int64 + var nickname, avatar, city, intro, note string + var certs sql.NullString + var radius, deposit, ratingTotal, ratingCount, completed, cancelled, status int + var created time.Time + if rows.Scan(&userID, &nickname, &avatar, &city, &radius, &intro, &certs, &deposit, + &ratingTotal, &ratingCount, &completed, &cancelled, &status, ¬e, &created) != nil { + continue + } + rating := 0.0 + if ratingCount > 0 { + rating = float64(ratingTotal) / float64(ratingCount) + } + items = append(items, map[string]any{ + "userId": userID, "nickname": nickname, "avatar": avatarThumbnailURL(avatar), "cityName": city, + "radiusM": radius, "intro": intro, "certs": certs.String, "depositCent": deposit, + "rating": rating, "ratingCount": ratingCount, "completedCount": completed, "cancelledCount": cancelled, + "status": status, "statusNote": note, "createdAt": created.Format(time.RFC3339), + }) + } + reply(w, map[string]any{"items": items}) +} + +func (a *App) adminReviewSitter(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"` + DepositCent *int `json:"depositCent"` + } + if decode(r, &req) != nil || req.Status < 1 || req.Status > 3 { + fail(w, http.StatusBadRequest, 20001, "状态无效") + return + } + req.Note = strings.TrimSpace(req.Note) + // Turning someone down without saying why generates a support ticket that + // nobody can answer. + if req.Status != 1 && req.Note == "" { + fail(w, http.StatusBadRequest, 20001, "驳回或停用时请填写原因") + return + } + if req.DepositCent != nil { + if _, err = a.db.ExecContext(r.Context(), `UPDATE pet_sitters SET deposit_cent=? WHERE user_id=?`, *req.DepositCent, id); err != nil { + fail(w, http.StatusInternalServerError, 50001, "操作失败") + return + } + } + result, err := a.db.ExecContext(r.Context(), `UPDATE pet_sitters SET status=?,status_note=? WHERE user_id=?`, 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 + } + title := "喂养者资格已通过" + content := "你现在可以在代喂大厅接单了" + if req.Status != 1 { + title = "喂养者资格未通过" + content = req.Note + } + a.notifyUser(r.Context(), id, "system", title, content, "sitter", id) + a.audit(r, "review_sitter", "pet_sitter", id, map[string]any{"status": req.Status, "note": req.Note}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminFeedTasks(w http.ResponseWriter, r *http.Request) { + _, size, offset := pagination(r) + where := " WHERE 1=1" + args := []any{} + if status := strings.TrimSpace(r.URL.Query().Get("status")); status != "" { + where += " AND t.status=?" + args = append(args, status) + } + if city := strings.TrimSpace(r.URL.Query().Get("cityCode")); city != "" { + where += " AND t.city_code=?" + args = append(args, city) + } + rows, err := a.db.QueryContext(r.Context(), `SELECT t.id,t.owner_user_id,owner.nickname,COALESCE(t.sitter_user_id,0),COALESCE(sitter.nickname,''), + p.name,addr.city_name,t.start_date,t.end_date,t.gross_cent,t.platform_fee_cent,t.payout_cent,t.status, + t.completed_visits,t.total_visits,t.confirm_due_at,t.settled_at,t.created_at + FROM pet_feed_tasks t JOIN pets p ON p.id=t.pet_id JOIN pet_addresses addr ON addr.id=t.address_id + JOIN user_profiles owner ON owner.user_id=t.owner_user_id + LEFT JOIN user_profiles sitter ON sitter.user_id=t.sitter_user_id`+where+` ORDER BY t.id DESC LIMIT ? OFFSET ?`, + append(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, sitterID int64 + var ownerName, sitterName, petName, cityName, status string + var gross, fee, payout int64 + var done, total int + var start, end, created time.Time + var confirmDue, settled sql.NullTime + if rows.Scan(&id, &ownerID, &ownerName, &sitterID, &sitterName, &petName, &cityName, &start, &end, + &gross, &fee, &payout, &status, &done, &total, &confirmDue, &settled, &created) != nil { + continue + } + item := map[string]any{ + "id": id, "ownerUserId": ownerID, "ownerName": ownerName, "sitterUserId": sitterID, "sitterName": sitterName, + "petName": petName, "cityName": cityName, "startDate": start.Format("2006-01-02"), "endDate": end.Format("2006-01-02"), + "grossCent": gross, "platformFeeCent": fee, "payoutCent": payout, "status": status, + "completedVisits": done, "totalVisits": total, "createdAt": created.Format(time.RFC3339), + } + if confirmDue.Valid { + item["confirmDueAt"] = confirmDue.Time.Format(time.RFC3339) + } + if settled.Valid { + item["settledAt"] = settled.Time.Format(time.RFC3339) + } + items = append(items, item) + } + reply(w, map[string]any{"items": items}) +} + +func (a *App) adminFeedDisputes(w http.ResponseWriter, r *http.Request) { + _, size, offset := pagination(r) + where := "" + args := []any{} + if status := strings.TrimSpace(r.URL.Query().Get("status")); status != "" { + where = " WHERE d.status=?" + args = append(args, status) + } + rows, err := a.db.QueryContext(r.Context(), `SELECT d.id,d.task_id,d.raised_by,profile.nickname,d.reason,d.evidence_json,d.status,d.verdict, + d.refund_cent,d.payout_cent,d.handled_note,d.handled_at,d.created_at,t.gross_cent,t.payout_cent,t.status, + t.owner_user_id,COALESCE(t.sitter_user_id,0),t.completed_visits,t.total_visits + FROM pet_feed_disputes d JOIN pet_feed_tasks t ON t.id=d.task_id + JOIN user_profiles profile ON profile.user_id=d.raised_by`+where+` ORDER BY d.status ASC,d.id DESC LIMIT ? OFFSET ?`, + append(args, size, offset)...) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询申诉失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, taskID, raisedBy, ownerID, sitterID int64 + var nickname, reason, verdict, note, taskStatus string + var evidence sql.NullString + var status int + var refund, payout, gross, taskPayout int64 + var done, total int + var handled sql.NullTime + var created time.Time + if rows.Scan(&id, &taskID, &raisedBy, &nickname, &reason, &evidence, &status, &verdict, &refund, &payout, + ¬e, &handled, &created, &gross, &taskPayout, &taskStatus, &ownerID, &sitterID, &done, &total) != nil { + continue + } + item := map[string]any{ + "id": id, "taskId": taskID, "raisedBy": raisedBy, "raisedByName": nickname, "reason": reason, + "evidence": evidence.String, "status": status, "verdict": verdict, "refundCent": refund, "payoutCent": payout, + "handledNote": note, "createdAt": created.Format(time.RFC3339), "grossCent": gross, "taskPayoutCent": taskPayout, + "taskStatus": taskStatus, "ownerUserId": ownerID, "sitterUserId": sitterID, + "completedVisits": done, "totalVisits": total, + } + if handled.Valid { + item["handledAt"] = handled.Time.Format(time.RFC3339) + } + items = append(items, item) + } + reply(w, map[string]any{"items": items}) +} + +// adminHandleFeedDispute is where a human decides who was right. The three +// verdicts cover what actually happens: the sitter did the work (payout), the +// sitter never showed (refund), or some visits happened and some did not +// (split, with the operator naming the amount). +func (a *App) adminHandleFeedDispute(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "编号无效") + return + } + var req struct { + Verdict string `json:"verdict"` + PayoutCent int64 `json:"payoutCent"` + Note string `json:"note"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "格式错误") + return + } + req.Note = strings.TrimSpace(req.Note) + if len([]rune(req.Note)) < 5 { + fail(w, http.StatusBadRequest, 20001, "请写明裁定理由,双方都会看到") + return + } + var taskID, sitter int64 + var status int + var taskPayout, gross int64 + var taskStatus string + if a.db.QueryRowContext(r.Context(), `SELECT d.task_id,d.status,COALESCE(t.sitter_user_id,0),t.payout_cent,t.gross_cent,t.status + FROM pet_feed_disputes d JOIN pet_feed_tasks t ON t.id=d.task_id WHERE d.id=?`, id). + Scan(&taskID, &status, &sitter, &taskPayout, &gross, &taskStatus) != nil { + fail(w, http.StatusNotFound, 30001, "申诉不存在") + return + } + if status != 0 { + fail(w, http.StatusBadRequest, 20001, "这条申诉已经裁定过了") + return + } + + var payout int64 + switch req.Verdict { + case "payout": + payout = taskPayout + case "refund": + payout = 0 + case "split": + payout = req.PayoutCent + if payout <= 0 || payout > taskPayout { + fail(w, http.StatusBadRequest, 20001, "部分支付金额应大于 0 且不超过服务报酬") + return + } + default: + fail(w, http.StatusBadRequest, 20001, "裁定结果无效") + return + } + refund := gross - payout + + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "操作失败") + return + } + defer func() { _ = tx.Rollback() }() + if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_disputes SET status=1,verdict=?,refund_cent=?,payout_cent=?,handled_by=?,handled_note=?,handled_at=NOW(3) + WHERE id=? AND status=0`, req.Verdict, refund, payout, current(r).ID, req.Note, id); err != nil { + fail(w, http.StatusInternalServerError, 50001, "操作失败") + return + } + if payout > 0 { + if err = bookLedger(r.Context(), tx, walletEntry{ + UserID: sitter, BizType: "feed_settle", BizID: taskID, Direction: 1, + AmountCen: payout, Memo: "代喂服务收入(客服裁定)", + }); err != nil && err != errLedgerDuplicate { + fail(w, http.StatusInternalServerError, 50001, "结算失败") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status='SETTLED',settled_at=NOW(3),payout_cent=? WHERE id=?`, payout, taskID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "操作失败") + return + } + } else { + if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status='REFUNDED' WHERE id=?`, taskID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "操作失败") + return + } + } + if tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "操作失败") + return + } + var owner int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT owner_user_id FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&owner) + for _, party := range []int64{owner, sitter} { + if party > 0 { + a.notifyUser(r.Context(), party, "system", "代喂申诉已裁定", req.Note, "feed_task", taskID) + } + } + a.audit(r, "handle_feed_dispute", "pet_feed_dispute", id, map[string]any{ + "verdict": req.Verdict, "payoutCent": payout, "refundCent": refund, "note": req.Note, + }) + reply(w, map[string]any{"success": true, "payoutCent": payout, "refundCent": refund}) +} diff --git a/im/backend/internal/app/pet_feed_integration_test.go b/im/backend/internal/app/pet_feed_integration_test.go new file mode 100644 index 0000000..5ccb0f6 --- /dev/null +++ b/im/backend/internal/app/pet_feed_integration_test.go @@ -0,0 +1,429 @@ +package app + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http/httptest" + "strings" + "testing" +) + +// The feeding module moves real money, so these tests are written around the +// two questions an auditor would ask: can the platform pay twice, and can it +// pay for work nobody proved happened. +func TestPetFeedTaskMySQL(t *testing.T) { + db := isolatedIMDatabase(t) + a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} + // Encryption of the door number and phone is not optional in this flow. + a.config.ConfigEncryptionKey = strings.Repeat("k", 32) + + owner, sitter := int64(1), int64(2) + petID := petWithPhotos(t, a, owner, "咪咪", 3) + + var addressID int64 + t.Run("an address without a location is refused", func(t *testing.T) { + w := httptest.NewRecorder() + a.createPetAddress(w, imTestRequest(owner, "POST", "/api/v1/pet/addresses", + `{"cityName":"深圳","areaText":"南山区科技园","detail":"3 栋 1201","contact":"张先生 13800000000"}`)) + if w.Code != 400 { + t.Fatalf("没有定位的地址应当被拒绝,打卡要靠它比对: HTTP %d %s", w.Code, w.Body.String()) + } + ok := httptest.NewRecorder() + a.createPetAddress(ok, imTestRequest(owner, "POST", "/api/v1/pet/addresses", + `{"cityName":"深圳","areaText":"南山区科技园","detail":"3 栋 1201","contact":"张先生 13800000000","latitude":22.5411,"longitude":113.9345}`)) + if ok.Code != 200 { + t.Fatalf("HTTP %d: %s", ok.Code, ok.Body.String()) + } + var envelope struct { + Data struct { + ID int64 `json:"id"` + } `json:"data"` + } + _ = json.Unmarshal(ok.Body.Bytes(), &envelope) + addressID = envelope.Data.ID + // The plain text must not be readable from the row itself. + var detail []byte + if err := db.QueryRow(`SELECT detail_cipher FROM pet_addresses WHERE id=?`, addressID).Scan(&detail); err != nil { + t.Fatal(err) + } + if strings.Contains(string(detail), "1201") { + t.Fatal("门牌号必须加密存储") + } + }) + + var taskID int64 + t.Run("publishing plans every visit up front", func(t *testing.T) { + body := fmt.Sprintf(`{"petId":%d,"addressId":%d,"startDate":"2026-10-01","endDate":"2026-10-03","visitsPerDay":2, + "minMinutes":20,"priceCent":3000,"note":"猫粮在阳台","items":[{"kind":"feed","amount":"40 克"},{"kind":"water","amount":""}]}`, petID, addressID) + w := httptest.NewRecorder() + a.createFeedTask(w, imTestRequest(owner, "POST", "/api/v1/pet/feed-tasks", body)) + if w.Code != 200 { + t.Fatalf("HTTP %d: %s", w.Code, w.Body.String()) + } + var envelope struct { + Data struct { + ID int64 `json:"id"` + GrossCent int64 `json:"grossCent"` + PayoutCent int64 `json:"payoutCent"` + TotalVisits int `json:"totalVisits"` + } `json:"data"` + } + _ = json.Unmarshal(w.Body.Bytes(), &envelope) + taskID = envelope.Data.ID + if envelope.Data.TotalVisits != 6 || envelope.Data.GrossCent != 18000 { + t.Fatalf("3 天每天 2 次 30 元应当是 6 次 180 元: %+v", envelope.Data) + } + if envelope.Data.PayoutCent != 15300 { + t.Fatalf("默认抽成 15%%,喂养者应得 153 元: %d", envelope.Data.PayoutCent) + } + var visits int + if err := db.QueryRow(`SELECT COUNT(*) FROM pet_feed_visits WHERE task_id=?`, taskID).Scan(&visits); err != nil { + t.Fatal(err) + } + if visits != 6 { + t.Fatalf("上门排期 = %d,应当发布时就排好", visits) + } + }) + + t.Run("an unfunded task is invisible and cannot be taken", func(t *testing.T) { + var hall struct { + Items []feedTaskView `json:"items"` + } + if err := json.Unmarshal(imTestCall(t, a.feedTasks, sitter, "GET", "/api/v1/pet/feed-tasks", "", 200), &hall); err != nil { + t.Fatal(err) + } + if len(hall.Items) != 0 { + t.Fatal("未支付的任务不该出现在大厅里") + } + w := httptest.NewRecorder() + a.applyFeedTask(w, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/apply", taskID), `{"message":"我可以"}`)) + if w.Code != 403 { + t.Fatalf("没有喂养者资格就不该能报名: HTTP %d %s", w.Code, w.Body.String()) + } + }) + + t.Run("a sitter needs a verified identity and a deposit", func(t *testing.T) { + w := httptest.NewRecorder() + a.applySitter(w, imTestRequest(sitter, "POST", "/api/v1/pet/sitter", + `{"cityName":"深圳","radiusM":5000,"intro":"养猫五年,会喂药和处理应激"}`)) + if w.Code != 403 { + t.Fatalf("未实名不该能成为喂养者: HTTP %d %s", w.Code, w.Body.String()) + } + if _, err := db.Exec(`INSERT INTO user_verifications(user_id,verification_type,real_name,status,evidence_json) VALUES(?,'real_name','李四','VERIFIED','[]')`, sitter); err != nil { + t.Fatal(err) + } + again := httptest.NewRecorder() + a.applySitter(again, imTestRequest(sitter, "POST", "/api/v1/pet/sitter", + `{"cityName":"深圳","radiusM":5000,"intro":"养猫五年,会喂药和处理应激"}`)) + if again.Code != 200 { + t.Fatalf("HTTP %d: %s", again.Code, again.Body.String()) + } + // Pending review: still cannot take work. + pay := httptest.NewRecorder() + a.escrowFeedTask(pay, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/pay", taskID), ``)) + if pay.Code != 200 { + t.Fatalf("支付失败: %s", pay.Body.String()) + } + blocked := httptest.NewRecorder() + a.applyFeedTask(blocked, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/apply", taskID), `{"message":"我可以"}`)) + if blocked.Code != 403 { + t.Fatalf("审核未通过就不该能接单: HTTP %d %s", blocked.Code, blocked.Body.String()) + } + review := httptest.NewRecorder() + a.adminReviewSitter(review, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/sitters/%d/review", sitter), `{"status":1,"note":"","depositCent":20000}`)) + if review.Code != 200 { + t.Fatalf("审核失败: %s", review.Body.String()) + } + }) + + var applicationID int64 + t.Run("the address is released only to the chosen sitter", func(t *testing.T) { + apply := httptest.NewRecorder() + a.applyFeedTask(apply, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/apply", taskID), `{"message":"住在附近,走路十分钟"}`)) + if apply.Code != 200 { + t.Fatalf("HTTP %d: %s", apply.Code, apply.Body.String()) + } + // Before being chosen, the sitter sees the street but not the door. + var early struct { + Address map[string]any `json:"address"` + } + if err := json.Unmarshal(imTestCall(t, a.feedTaskDetail, sitter, "GET", fmt.Sprintf("/api/v1/pet/feed-tasks/%d", taskID), "", 200), &early); err != nil { + t.Fatal(err) + } + if early.Address != nil { + t.Fatal("还没被选中就不该看到门牌与联系方式") + } + var detail struct { + Applications []map[string]any `json:"applications"` + } + if err := json.Unmarshal(imTestCall(t, a.feedTaskDetail, owner, "GET", fmt.Sprintf("/api/v1/pet/feed-tasks/%d", taskID), "", 200), &detail); err != nil { + t.Fatal(err) + } + if len(detail.Applications) != 1 { + t.Fatalf("主人应当看到报名: %+v", detail) + } + applicationID = int64(detail.Applications[0]["id"].(float64)) + assign := httptest.NewRecorder() + a.assignFeedTask(assign, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/assign", taskID), + fmt.Sprintf(`{"applicationId":%d}`, applicationID))) + if assign.Code != 200 { + t.Fatalf("HTTP %d: %s", assign.Code, assign.Body.String()) + } + var after struct { + Address struct { + Contact string `json:"contact"` + Detail string `json:"detail"` + } `json:"address"` + } + if err := json.Unmarshal(imTestCall(t, a.feedTaskDetail, sitter, "GET", fmt.Sprintf("/api/v1/pet/feed-tasks/%d", taskID), "", 200), &after); err != nil { + t.Fatal(err) + } + if after.Address.Detail != "3 栋 1201" || !strings.Contains(after.Address.Contact, "13800000000") { + t.Fatalf("选定后喂养者应当看到门牌与联系方式: %+v", after.Address) + } + }) + + t.Run("a visit cannot be closed without its photos", func(t *testing.T) { + visitID := firstVisit(t, db, taskID) + early := httptest.NewRecorder() + a.finishFeedVisit(early, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-visits/%d/finish", visitID), ``)) + if early.Code != 400 { + t.Fatalf("没有打卡照片就不该能提交: HTTP %d %s", early.Code, early.Body.String()) + } + // A check-in from the other side of the city is recorded, and flagged. + far := httptest.NewRecorder() + a.checkinFeedVisit(far, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-visits/%d/checkin", visitID), + `{"step":"arrive","mediaUrl":"https://cdn.example.com/arrive.jpg","latitude":22.7,"longitude":114.2}`)) + if far.Code != 200 { + t.Fatalf("HTTP %d: %s", far.Code, far.Body.String()) + } + var verdict string + if err := db.QueryRow(`SELECT verdict FROM pet_feed_checkins WHERE visit_id=? AND step='arrive'`, visitID).Scan(&verdict); err != nil { + t.Fatal(err) + } + if verdict != "far" { + t.Fatalf("离地址很远的打卡应当被标记,verdict=%s", verdict) + } + duplicate := httptest.NewRecorder() + a.checkinFeedVisit(duplicate, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-visits/%d/checkin", visitID), + `{"step":"arrive","mediaUrl":"https://cdn.example.com/arrive2.jpg","latitude":22.5411,"longitude":113.9345}`)) + if duplicate.Code != 400 { + t.Fatalf("同一个环节不该能打两次卡: HTTP %d", duplicate.Code) + } + }) + + t.Run("the whole service completes and pays exactly once", func(t *testing.T) { + for _, visitID := range allVisits(t, db, taskID) { + for _, step := range []string{"arrive", "feed", "leave"} { + w := httptest.NewRecorder() + a.checkinFeedVisit(w, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-visits/%d/checkin", visitID), + fmt.Sprintf(`{"step":%q,"mediaUrl":"https://cdn.example.com/%s.jpg","latitude":22.5411,"longitude":113.9345}`, step, step))) + // The first visit already has an 'arrive' from the previous case. + if w.Code != 200 && w.Code != 400 { + t.Fatalf("打卡失败: HTTP %d %s", w.Code, w.Body.String()) + } + } + finish := httptest.NewRecorder() + a.finishFeedVisit(finish, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-visits/%d/finish", visitID), ``)) + if finish.Code != 200 { + t.Fatalf("提交上门失败: HTTP %d %s", finish.Code, finish.Body.String()) + } + } + var status string + if err := db.QueryRow(`SELECT status FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&status); err != nil { + t.Fatal(err) + } + if status != "COMPLETED" { + t.Fatalf("所有上门完成后任务应当进入待确认: %s", status) + } + confirm := httptest.NewRecorder() + a.confirmFeedTask(confirm, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/confirm", taskID), ``)) + if confirm.Code != 200 { + t.Fatalf("确认失败: %s", confirm.Body.String()) + } + var available int64 + if err := db.QueryRow(`SELECT available_cent FROM wallet_accounts WHERE user_id=?`, sitter).Scan(&available); err != nil { + t.Fatal(err) + } + if available != 15300 { + t.Fatalf("结算金额 = %d,应当是扣除抽成后的 15300", available) + } + // Settling again — by a retried worker, say — must not pay twice. + if err := a.settleFeedTask(context.Background(), taskID, "重复结算"); err == nil { + t.Fatal("已结算的任务不该能再次结算") + } + a.autoConfirmFeedTasks(context.Background()) + var after int64 + if err := db.QueryRow(`SELECT available_cent FROM wallet_accounts WHERE user_id=?`, sitter).Scan(&after); err != nil { + t.Fatal(err) + } + if after != 15300 { + t.Fatalf("余额被重复入账: %d", after) + } + var entries int + if err := db.QueryRow(`SELECT COUNT(*) FROM wallet_transactions WHERE user_id=? AND biz_type='feed_settle'`, sitter).Scan(&entries); err != nil { + t.Fatal(err) + } + if entries != 1 { + t.Fatalf("结算流水 = %d,应当只有一条", entries) + } + }) + + t.Run("reviews stay hidden until both sides write one", func(t *testing.T) { + byOwner := httptest.NewRecorder() + a.reviewFeedTask(byOwner, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/review", taskID), + `{"punctual":5,"accurate":4,"communication":5,"content":"每次都按时到,照片拍得很清楚"}`)) + if byOwner.Code != 200 { + t.Fatalf("HTTP %d: %s", byOwner.Code, byOwner.Body.String()) + } + var visible struct { + Items []map[string]any `json:"items"` + } + if err := json.Unmarshal(imTestCall(t, a.sitterReviews, owner, "GET", fmt.Sprintf("/api/v1/pet/sitters/%d/reviews", sitter), "", 200), &visible); err != nil { + t.Fatal(err) + } + if len(visible.Items) != 0 { + t.Fatal("只有一方评价时不该公开,否则另一方会照着写") + } + bySitter := httptest.NewRecorder() + a.reviewFeedTask(bySitter, imTestRequest(sitter, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/review", taskID), + `{"punctual":5,"accurate":5,"communication":5,"content":"主人交代得很清楚"}`)) + if bySitter.Code != 200 { + t.Fatalf("HTTP %d: %s", bySitter.Code, bySitter.Body.String()) + } + var opened struct { + Items []map[string]any `json:"items"` + } + if err := json.Unmarshal(imTestCall(t, a.sitterReviews, owner, "GET", fmt.Sprintf("/api/v1/pet/sitters/%d/reviews", sitter), "", 200), &opened); err != nil { + t.Fatal(err) + } + if len(opened.Items) != 1 { + t.Fatalf("双方都评价后应当公开: %+v", opened.Items) + } + }) +} + +// A dispute is the case where the money must not move on a timer. +func TestPetFeedDisputeMySQL(t *testing.T) { + db := isolatedIMDatabase(t) + a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} + a.config.ConfigEncryptionKey = strings.Repeat("k", 32) + owner, sitter := int64(1), int64(2) + taskID := seedAssignedTask(t, a, db, owner, sitter) + + raise := httptest.NewRecorder() + a.raiseFeedDispute(raise, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/dispute", taskID), + `{"reason":"第二天的照片是白天拍的,但约定是晚上八点,猫粮碗也没有动过"}`)) + if raise.Code != 200 { + t.Fatalf("HTTP %d: %s", raise.Code, raise.Body.String()) + } + var status string + var confirmDue any + if err := db.QueryRow(`SELECT status,confirm_due_at FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&status, &confirmDue); err != nil { + t.Fatal(err) + } + if status != "DISPUTED" || confirmDue != nil { + t.Fatalf("申诉必须冻结自动确认: status=%s confirmDue=%v", status, confirmDue) + } + // The timer must not pay out while a human still has to look at it. + if settled := a.autoConfirmFeedTasks(context.Background()); settled != 0 { + t.Fatalf("申诉中的任务被自动结算了 %d 笔", settled) + } + + var disputeID int64 + if err := db.QueryRow(`SELECT id FROM pet_feed_disputes WHERE task_id=?`, taskID).Scan(&disputeID); err != nil { + t.Fatal(err) + } + short := httptest.NewRecorder() + a.adminHandleFeedDispute(short, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/disputes/%d/handle", disputeID), `{"verdict":"refund","note":"好"}`)) + if short.Code != 400 { + t.Fatalf("裁定必须写理由: HTTP %d", short.Code) + } + handle := httptest.NewRecorder() + a.adminHandleFeedDispute(handle, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/disputes/%d/handle", disputeID), + `{"verdict":"split","payoutCent":5000,"note":"前两次有完整照片,第三次证据不足,按两次计算"}`)) + if handle.Code != 200 { + t.Fatalf("HTTP %d: %s", handle.Code, handle.Body.String()) + } + var available int64 + if err := db.QueryRow(`SELECT available_cent FROM wallet_accounts WHERE user_id=?`, sitter).Scan(&available); err != nil { + t.Fatal(err) + } + if available != 5000 { + t.Fatalf("部分支付应当只入账裁定的金额: %d", available) + } + twice := httptest.NewRecorder() + a.adminHandleFeedDispute(twice, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/disputes/%d/handle", disputeID), + `{"verdict":"payout","note":"再判一次看看会不会重复打钱"}`)) + if twice.Code != 400 { + t.Fatalf("同一条申诉不该能裁定两次: HTTP %d", twice.Code) + } +} + +func firstVisit(t *testing.T, db *sql.DB, taskID int64) int64 { + t.Helper() + visits := allVisits(t, db, taskID) + if len(visits) == 0 { + t.Fatal("任务没有排期") + } + return visits[0] +} + +func allVisits(t *testing.T, db *sql.DB, taskID int64) []int64 { + t.Helper() + rows, err := db.Query(`SELECT id FROM pet_feed_visits WHERE task_id=? ORDER BY visit_date,sequence`, taskID) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + visits := []int64{} + for rows.Next() { + var id int64 + if rows.Scan(&id) == nil { + visits = append(visits, id) + } + } + return visits +} + +// seedAssignedTask fast-forwards a task to the state a dispute starts from: paid +// for, assigned, and reported complete. The steps in between have their own +// tests above; repeating them here would only make this one slower to read. +func seedAssignedTask(t *testing.T, a *App, db *sql.DB, owner, sitter int64) int64 { + t.Helper() + petID := petWithPhotos(t, a, owner, "豆豆", 3) + address := httptest.NewRecorder() + a.createPetAddress(address, imTestRequest(owner, "POST", "/api/v1/pet/addresses", + `{"cityName":"深圳","areaText":"福田区","detail":"5 栋 302","contact":"王女士 13900000000","latitude":22.5411,"longitude":113.9345}`)) + if address.Code != 200 { + t.Fatalf("地址创建失败: %s", address.Body.String()) + } + var addressEnvelope struct { + Data struct { + ID int64 `json:"id"` + } `json:"data"` + } + _ = json.Unmarshal(address.Body.Bytes(), &addressEnvelope) + + task := httptest.NewRecorder() + a.createFeedTask(task, imTestRequest(owner, "POST", "/api/v1/pet/feed-tasks", + fmt.Sprintf(`{"petId":%d,"addressId":%d,"startDate":"2026-10-01","endDate":"2026-10-01","visitsPerDay":3, + "minMinutes":20,"priceCent":3000,"note":"","items":[{"kind":"feed","amount":"40 克"}]}`, petID, addressEnvelope.Data.ID))) + if task.Code != 200 { + t.Fatalf("任务创建失败: %s", task.Body.String()) + } + var taskEnvelope struct { + Data struct { + ID int64 `json:"id"` + } `json:"data"` + } + _ = json.Unmarshal(task.Body.Bytes(), &taskEnvelope) + taskID := taskEnvelope.Data.ID + if _, err := db.Exec(`UPDATE pet_feed_tasks SET status='COMPLETED',sitter_user_id=?,completed_visits=total_visits, + confirm_due_at=DATE_SUB(NOW(3),INTERVAL 1 HOUR) WHERE id=?`, sitter, taskID); err != nil { + t.Fatal(err) + } + return taskID +} diff --git a/im/backend/internal/app/pet_feed_service.go b/im/backend/internal/app/pet_feed_service.go new file mode 100644 index 0000000..4114ee5 --- /dev/null +++ b/im/backend/internal/app/pet_feed_service.go @@ -0,0 +1,548 @@ +package app + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +// Performing the task, proving it happened, and paying for it. Split from +// pet_feed.go because this half is where the money is: everything here has to +// survive being called twice. + +// --- 打卡 ----------------------------------------------------------------- + +// checkinFeedVisit records one step of one visit. The verdict is advisory: we +// never block a sitter who is standing in the right kitchen with a bad GPS fix, +// but we do write down what the phone reported, so a later dispute has facts +// instead of two conflicting stories. +func (a *App) checkinFeedVisit(w http.ResponseWriter, r *http.Request) { + visitID, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "编号无效") + return + } + var req struct { + Step string `json:"step"` + MediaURL string `json:"mediaUrl"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + CapturedAt string `json:"capturedAt"` + Note string `json:"note"` + Abnormal bool `json:"abnormal"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "打卡格式错误") + return + } + req.Step = strings.TrimSpace(req.Step) + if !strings.Contains(","+feedVisitSteps+",", ","+req.Step+",") { + fail(w, http.StatusBadRequest, 20001, "打卡环节无效") + return + } + if !validMessageMediaURL(req.MediaURL, a.config.Environment == "production") { + fail(w, http.StatusBadRequest, 20001, "请上传本次打卡的照片") + return + } + var taskID, sitter int64 + var status string + var visitStatus int + var visitDate time.Time + var addressLat, addressLng sql.NullFloat64 + if a.db.QueryRowContext(r.Context(), `SELECT v.task_id,COALESCE(t.sitter_user_id,0),t.status,v.status,v.visit_date,addr.latitude,addr.longitude + FROM pet_feed_visits v JOIN pet_feed_tasks t ON t.id=v.task_id JOIN pet_addresses addr ON addr.id=t.address_id + WHERE v.id=?`, visitID).Scan(&taskID, &sitter, &status, &visitStatus, &visitDate, &addressLat, &addressLng) != nil { + fail(w, http.StatusNotFound, 30001, "服务记录不存在") + return + } + if sitter != current(r).ID { + fail(w, http.StatusForbidden, 30003, "只有接单的喂养者可以打卡") + return + } + if status != "ASSIGNED" && status != "SERVING" { + fail(w, http.StatusBadRequest, 20001, "当前任务状态不能打卡") + return + } + if visitStatus == 2 { + fail(w, http.StatusBadRequest, 20001, "这次上门已经完成") + return + } + + captured := time.Now() + if parsed, parseErr := time.Parse(time.RFC3339, strings.TrimSpace(req.CapturedAt)); parseErr == nil { + captured = parsed + } + verdict := "ok" + var distance sql.NullInt64 + if addressLat.Valid && addressLng.Valid && req.Latitude != 0 && req.Longitude != 0 { + metres := int64(haversine(req.Latitude, req.Longitude, addressLat.Float64, addressLng.Float64) * 1000) + distance = sql.NullInt64{Int64: metres, Valid: true} + if metres > int64(a.configInt(r.Context(), "pet.feed_checkin_max_distance_m", 300)) { + verdict = "far" + } + } + // A photo taken this morning and uploaded tonight is not evidence of tonight's + // visit; flag it rather than reject it, and let the owner see the flag. + delay := time.Since(captured) + if delay < 0 { + delay = -delay + } + if verdict == "ok" && delay > time.Duration(a.configInt(r.Context(), "pet.feed_checkin_max_delay_min", 5))*time.Minute { + verdict = "stale" + } + + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "打卡失败") + return + } + defer func() { _ = tx.Rollback() }() + if _, err = tx.ExecContext(r.Context(), `INSERT INTO pet_feed_checkins(visit_id,task_id,step,media_url,latitude,longitude,distance_m,captured_at,verdict) + VALUES(?,?,?,?,?,?,?,?,?)`, visitID, taskID, req.Step, strings.TrimSpace(req.MediaURL), + nullableFloat(req.Latitude), nullableFloat(req.Longitude), distance, captured, verdict); err != nil { + if isDuplicateKeyError(err) { + fail(w, http.StatusBadRequest, 20001, "这个环节已经打过卡了") + return + } + fail(w, http.StatusInternalServerError, 50001, "打卡失败") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_visits SET status=IF(status=0,1,status),started_at=COALESCE(started_at,?), + abnormal=IF(?,1,abnormal),abnormal_note=IF(?,?,abnormal_note) WHERE id=?`, + captured, req.Abnormal, req.Abnormal, strings.TrimSpace(req.Note), visitID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "打卡失败") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status='SERVING' WHERE id=? AND status='ASSIGNED'`, taskID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "打卡失败") + return + } + if tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "打卡失败") + return + } + if req.Abnormal { + var owner int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT owner_user_id FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&owner) + if owner > 0 { + a.notifyUser(r.Context(), owner, "system", "喂养者报告了异常", "本次上门有异常情况,请尽快查看照片与说明", "feed_task", taskID) + } + } + reply(w, map[string]any{"verdict": verdict, "distanceM": distance.Int64}) +} + +func nullableFloat(value float64) sql.NullFloat64 { + return sql.NullFloat64{Float64: value, Valid: value != 0} +} + +// finishFeedVisit closes one visit once every required step has a photo. The +// last visit moves the whole task to COMPLETED and starts the confirmation +// clock. +func (a *App) finishFeedVisit(w http.ResponseWriter, r *http.Request) { + visitID, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "编号无效") + return + } + var taskID, sitter, owner int64 + var status string + var visitStatus, totalVisits int + if a.db.QueryRowContext(r.Context(), `SELECT v.task_id,COALESCE(t.sitter_user_id,0),t.owner_user_id,t.status,v.status,t.total_visits + FROM pet_feed_visits v JOIN pet_feed_tasks t ON t.id=v.task_id WHERE v.id=?`, visitID). + Scan(&taskID, &sitter, &owner, &status, &visitStatus, &totalVisits) != nil { + fail(w, http.StatusNotFound, 30001, "服务记录不存在") + return + } + if sitter != current(r).ID { + fail(w, http.StatusForbidden, 30003, "只有接单的喂养者可以提交") + return + } + if visitStatus == 2 { + fail(w, http.StatusBadRequest, 20001, "这次上门已经完成") + return + } + var steps int + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM pet_feed_checkins WHERE visit_id=? AND step IN ('arrive','feed','leave')`, visitID).Scan(&steps) + if steps < 3 { + fail(w, http.StatusBadRequest, 20001, "到达、喂食、离开三张照片都要有才能提交") + return + } + + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "提交失败") + return + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(r.Context(), `UPDATE pet_feed_visits SET status=2,finished_at=NOW(3) WHERE id=? AND status<>2`, visitID) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "提交失败") + return + } + if affected, _ := result.RowsAffected(); affected == 0 { + fail(w, http.StatusBadRequest, 20001, "这次上门已经完成") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET completed_visits=completed_visits+1 WHERE id=?`, taskID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "提交失败") + return + } + var done int + if err = tx.QueryRowContext(r.Context(), `SELECT completed_visits FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&done); err != nil { + fail(w, http.StatusInternalServerError, 50001, "提交失败") + return + } + finished := done >= totalVisits + if finished { + hours := a.configInt(r.Context(), "pet.feed_confirm_hours", 24) + if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status='COMPLETED',confirm_due_at=DATE_ADD(NOW(3),INTERVAL ? HOUR) WHERE id=? AND status IN ('ASSIGNED','SERVING')`, + hours, taskID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "提交失败") + return + } + } + if tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "提交失败") + return + } + if finished { + a.notifyUser(r.Context(), owner, "system", "代喂任务已完成", "所有上门都已完成,请查看照片并确认,超时会自动确认", "feed_task", taskID) + } + reply(w, map[string]any{"completedVisits": done, "totalVisits": totalVisits, "taskCompleted": finished}) +} + +// --- 结算 ----------------------------------------------------------------- + +// settleFeedTask is the only path money takes to a sitter. It is written so +// that calling it twice — by the owner and by the auto-confirm worker at the +// same moment — pays exactly once: the row lock plus status guard rejects the +// loser, and the ledger's unique key catches anything the guard misses. +func (a *App) settleFeedTask(ctx context.Context, taskID int64, reason string) error { + tx, err := a.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + var sitter sql.NullInt64 + var payout int64 + var status string + if err = tx.QueryRowContext(ctx, `SELECT sitter_user_id,payout_cent,status FROM pet_feed_tasks WHERE id=? FOR UPDATE`, taskID).Scan(&sitter, &payout, &status); err != nil { + return err + } + if status != "COMPLETED" && status != "ARBITRATED" { + return fmt.Errorf("任务状态不允许结算") + } + if !sitter.Valid || sitter.Int64 == 0 || payout <= 0 { + return fmt.Errorf("任务没有可结算的喂养者") + } + if err = bookLedger(ctx, tx, walletEntry{ + UserID: sitter.Int64, BizType: "feed_settle", BizID: taskID, Direction: 1, + AmountCen: payout, Memo: reason, + }); err != nil && err != errLedgerDuplicate { + return err + } + if _, err = tx.ExecContext(ctx, `UPDATE pet_feed_tasks SET status='SETTLED',settled_at=NOW(3) WHERE id=?`, taskID); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `UPDATE pet_sitters SET completed_count=completed_count+1 WHERE user_id=?`, sitter.Int64); err != nil { + return err + } + return tx.Commit() +} + +func (a *App) confirmFeedTask(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "编号无效") + return + } + var owner, sitter int64 + var status string + var payout int64 + if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id,COALESCE(sitter_user_id,0),status,payout_cent FROM pet_feed_tasks WHERE id=?`, id). + Scan(&owner, &sitter, &status, &payout) != nil { + fail(w, http.StatusNotFound, 30001, "任务不存在") + return + } + if owner != current(r).ID { + fail(w, http.StatusForbidden, 30003, "只有发布任务的人可以确认") + return + } + if status != "COMPLETED" { + fail(w, http.StatusBadRequest, 20001, "任务状态不允许确认") + return + } + if err = a.settleFeedTask(r.Context(), id, "代喂服务收入"); err != nil { + fail(w, http.StatusInternalServerError, 50001, "结算失败") + return + } + a.notifyUser(r.Context(), sitter, "system", "代喂报酬已到账", + fmt.Sprintf("主人已确认服务,%.2f 元已记入你的收益账户", float64(payout)/100), "wallet", id) + reply(w, map[string]bool{"success": true}) +} + +// autoConfirmFeedTasks settles what the owner never got around to confirming. +// Without it a sitter's money would sit hostage to an owner who simply stopped +// opening the app. +func (a *App) autoConfirmFeedTasks(ctx context.Context) int { + rows, err := a.db.QueryContext(ctx, `SELECT id,COALESCE(sitter_user_id,0),payout_cent FROM pet_feed_tasks + WHERE status='COMPLETED' AND confirm_due_at IS NOT NULL AND confirm_due_at<=NOW(3) LIMIT 100`) + if err != nil { + return 0 + } + type pending struct { + id int64 + sitter int64 + payout int64 + } + tasks := []pending{} + for rows.Next() { + var item pending + if rows.Scan(&item.id, &item.sitter, &item.payout) == nil { + tasks = append(tasks, item) + } + } + rows.Close() + settled := 0 + for _, task := range tasks { + if a.settleFeedTask(ctx, task.id, "代喂服务收入(超时自动确认)") == nil { + settled++ + a.notifyUser(ctx, task.sitter, "system", "代喂报酬已到账", + fmt.Sprintf("确认期已过,%.2f 元已自动记入你的收益账户", float64(task.payout)/100), "wallet", task.id) + } + } + return settled +} + +func (a *App) startFeedSettlementWorker(ctx context.Context) { + go func() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + a.autoConfirmFeedTasks(ctx) + } + } + }() +} + +// --- 取消与申诉 ----------------------------------------------------------- + +func (a *App) cancelFeedTask(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "编号无效") + return + } + var owner int64 + var status string + if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id,status FROM pet_feed_tasks WHERE id=?`, id).Scan(&owner, &status) != nil || owner != current(r).ID { + fail(w, http.StatusNotFound, 30001, "任务不存在") + return + } + // Once someone has been sent to your door, cancelling is a negotiation, not + // a button. + if status != "CREATED" && status != "ESCROWED" { + fail(w, http.StatusBadRequest, 20001, "已经选定喂养者的任务请通过申诉处理") + return + } + next := "CANCELLED" + if status == "ESCROWED" { + next = "REFUNDED" + } + if _, err = a.db.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status=? WHERE id=? AND status=?`, next, id, status); err != nil { + fail(w, http.StatusInternalServerError, 50001, "操作失败") + return + } + reply(w, map[string]any{"status": next}) +} + +func (a *App) raiseFeedDispute(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "编号无效") + return + } + var req struct { + Reason string `json:"reason"` + Evidence []string `json:"evidence"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "格式错误") + return + } + req.Reason = strings.TrimSpace(req.Reason) + if len([]rune(req.Reason)) < 10 || len([]rune(req.Reason)) > 500 { + fail(w, http.StatusBadRequest, 20001, "请把情况说清楚,10 到 500 字") + return + } + var owner, sitter int64 + var status string + if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id,COALESCE(sitter_user_id,0),status FROM pet_feed_tasks WHERE id=?`, id).Scan(&owner, &sitter, &status) != nil { + fail(w, http.StatusNotFound, 30001, "任务不存在") + return + } + if current(r).ID != owner && current(r).ID != sitter { + fail(w, http.StatusForbidden, 30003, "只有任务双方可以申诉") + return + } + // After settlement the money is already in someone's account; that is a + // refund conversation with support, not an escrow decision. + if status != "ASSIGNED" && status != "SERVING" && status != "COMPLETED" { + fail(w, http.StatusBadRequest, 20001, "当前状态不能发起申诉") + return + } + evidence, _ := json.Marshal(req.Evidence) + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "提交失败") + return + } + defer func() { _ = tx.Rollback() }() + if _, err = tx.ExecContext(r.Context(), `INSERT INTO pet_feed_disputes(task_id,raised_by,reason,evidence_json) VALUES(?,?,?,?)`, + id, current(r).ID, req.Reason, string(evidence)); err != nil { + if isDuplicateKeyError(err) { + fail(w, http.StatusBadRequest, 20001, "这个任务已经有一条申诉在处理中") + return + } + fail(w, http.StatusInternalServerError, 50001, "提交失败") + return + } + // Freezing the task stops the auto-confirm clock: nothing pays out while a + // human still has to look at it. + if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status='DISPUTED',confirm_due_at=NULL WHERE id=?`, id); err != nil { + fail(w, http.StatusInternalServerError, 50001, "提交失败") + return + } + if tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "提交失败") + return + } + reply(w, map[string]bool{"success": true}) +} + +// --- 评价 ----------------------------------------------------------------- + +func (a *App) reviewFeedTask(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "编号无效") + return + } + var req struct { + Punctual int `json:"punctual"` + Accurate int `json:"accurate"` + Communication int `json:"communication"` + Content string `json:"content"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "格式错误") + return + } + for _, score := range []int{req.Punctual, req.Accurate, req.Communication} { + if score < 1 || score > 5 { + fail(w, http.StatusBadRequest, 20001, "评分应在 1 到 5 之间") + return + } + } + req.Content = strings.TrimSpace(req.Content) + if len([]rune(req.Content)) > 500 { + fail(w, http.StatusBadRequest, 20001, "评价内容过长") + return + } + var owner, sitter int64 + var status string + if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id,COALESCE(sitter_user_id,0),status FROM pet_feed_tasks WHERE id=?`, id).Scan(&owner, &sitter, &status) != nil { + fail(w, http.StatusNotFound, 30001, "任务不存在") + return + } + if status != "COMPLETED" && status != "SETTLED" && status != "ARBITRATED" { + fail(w, http.StatusBadRequest, 20001, "服务结束后才能评价") + return + } + target := sitter + if current(r).ID == sitter { + target = owner + } else if current(r).ID != owner { + fail(w, http.StatusForbidden, 30003, "只有任务双方可以评价") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "提交失败") + return + } + defer func() { _ = tx.Rollback() }() + if _, err = tx.ExecContext(r.Context(), `INSERT INTO pet_feed_reviews(task_id,from_user_id,to_user_id,punctual,accurate,communication,content) + VALUES(?,?,?,?,?,?,?)`, id, current(r).ID, target, req.Punctual, req.Accurate, req.Communication, req.Content); err != nil { + if isDuplicateKeyError(err) { + fail(w, http.StatusBadRequest, 20001, "你已经评价过了") + return + } + fail(w, http.StatusInternalServerError, 50001, "提交失败") + return + } + // Double-blind: neither side sees the other's rating until both have written + // one, so nobody writes a retaliation. + var count int + if err = tx.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM pet_feed_reviews WHERE task_id=?`, id).Scan(&count); err != nil { + fail(w, http.StatusInternalServerError, 50001, "提交失败") + return + } + if count >= 2 { + if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_reviews SET visible_at=NOW(3) WHERE task_id=? AND visible_at IS NULL`, id); err != nil { + fail(w, http.StatusInternalServerError, 50001, "提交失败") + return + } + } + if target == sitter { + average := (req.Punctual + req.Accurate + req.Communication) / 3 + if _, err = tx.ExecContext(r.Context(), `UPDATE pet_sitters SET rating_total=rating_total+?,rating_count=rating_count+1 WHERE user_id=?`, average, sitter); err != nil { + fail(w, http.StatusInternalServerError, 50001, "提交失败") + return + } + } + if tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "提交失败") + return + } + reply(w, map[string]any{"success": true, "bothSubmitted": count >= 2}) +} + +func (a *App) sitterReviews(w http.ResponseWriter, r *http.Request) { + userID, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "编号无效") + return + } + _, size, offset := pageOptions(r) + rows, queryErr := a.db.QueryContext(r.Context(), `SELECT rv.punctual,rv.accurate,rv.communication,rv.content,rv.created_at,profile.nickname,profile.avatar_url + FROM pet_feed_reviews rv JOIN user_profiles profile ON profile.user_id=rv.from_user_id + WHERE rv.to_user_id=? AND rv.visible_at IS NOT NULL ORDER BY rv.id DESC LIMIT ? OFFSET ?`, userID, size, offset) + if queryErr != nil { + fail(w, http.StatusInternalServerError, 50001, "查询评价失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var punctual, accurate, communication int + var content, nickname, avatar string + var created time.Time + if rows.Scan(&punctual, &accurate, &communication, &content, &created, &nickname, &avatar) != nil { + continue + } + items = append(items, map[string]any{ + "punctual": punctual, "accurate": accurate, "communication": communication, "content": content, + "nickname": nickname, "avatar": avatarThumbnailURL(avatar), "createdAt": created.Format(time.RFC3339), + }) + } + reply(w, map[string]any{"items": items}) +} diff --git a/im/backend/internal/app/pet_mating.go b/im/backend/internal/app/pet_mating.go new file mode 100644 index 0000000..bc7ed03 --- /dev/null +++ b/im/backend/internal/app/pet_mating.go @@ -0,0 +1,159 @@ +package app + +import ( + "database/sql" + "fmt" + "net/http" + "strings" + "time" +) + +// Mating is information only: two owners find each other and take it offline. +// The platform takes no fee and gives no guarantee, which is what keeps it out +// of the business of breeding. It ships switched off — see the plan. + +func (a *App) createMatingListing(w http.ResponseWriter, r *http.Request) { + if !a.configBool(r.Context(), "pet.mating_enabled", false) { + fail(w, http.StatusForbidden, 30002, "配种撮合暂未开放") + return + } + var req struct { + PetID int64 `json:"petId"` + CityCode string `json:"cityCode"` + CityName string `json:"cityName"` + Expectation string `json:"expectation"` + VaccineMedia string `json:"vaccineMedia"` + } + if decode(r, &req) != nil || req.PetID == 0 { + fail(w, http.StatusBadRequest, 20001, "配种信息格式错误") + return + } + req.Expectation = strings.TrimSpace(req.Expectation) + if len([]rune(req.Expectation)) > 500 { + fail(w, http.StatusBadRequest, 20001, "内容过长") + return + } + // Proof of vaccination is not optional here: an unvaccinated mating is the + // case that turns into a public-health story. + if !validMessageMediaURL(req.VaccineMedia, a.config.Environment == "production") { + fail(w, http.StatusBadRequest, 20001, "请上传免疫证明照片") + return + } + var birthday sql.NullTime + var neutered int + var species string + if a.db.QueryRowContext(r.Context(), `SELECT birthday,neutered,species FROM pets WHERE id=? AND owner_user_id=? AND status=1 AND deleted_at IS NULL`, + req.PetID, current(r).ID).Scan(&birthday, &neutered, &species) != nil { + fail(w, http.StatusNotFound, 30001, "宠物不存在") + return + } + if neutered == 1 { + fail(w, http.StatusBadRequest, 20001, "已绝育的宠物无法发布配种信息") + return + } + if !birthday.Valid { + fail(w, http.StatusBadRequest, 20001, "请先在档案中填写出生日期") + return + } + if minAge := a.configInt(r.Context(), "pet.mating_min_age_months", 12); monthsSince(birthday.Time) < minAge { + fail(w, http.StatusBadRequest, 20001, fmt.Sprintf("未满 %d 月龄的宠物不能发布配种信息", minAge)) + return + } + var existing int + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM pet_mating_listings WHERE pet_id=? AND status=1 AND deleted_at IS NULL`, req.PetID).Scan(&existing) + if existing > 0 { + fail(w, http.StatusBadRequest, 20001, "这只宠物已经发布过配种信息") + return + } + result, err := a.db.ExecContext(r.Context(), `INSERT INTO pet_mating_listings(pet_id,owner_user_id,city_code,city_name,expectation,vaccine_media) + VALUES(?,?,?,?,?,?)`, req.PetID, current(r).ID, strings.TrimSpace(req.CityCode), strings.TrimSpace(req.CityName), req.Expectation, strings.TrimSpace(req.VaccineMedia)) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "发布失败") + return + } + id, _ := result.LastInsertId() + reply(w, map[string]any{"id": id, "pendingReview": true}) +} + +func (a *App) matingListings(w http.ResponseWriter, r *http.Request) { + if !a.configBool(r.Context(), "pet.mating_enabled", false) { + reply(w, map[string]any{"items": []any{}, "enabled": false}) + return + } + _, size, offset := pageOptions(r) + where := " WHERE m.deleted_at IS NULL" + args := []any{} + if strings.TrimSpace(r.URL.Query().Get("mine")) == "1" { + where += " AND m.owner_user_id=?" + args = append(args, current(r).ID) + } else { + where += " AND m.status=1 AND m.moderation_status=1" + if city := strings.TrimSpace(r.URL.Query().Get("cityCode")); city != "" { + where += " AND m.city_code=?" + args = append(args, city) + } + if species := strings.TrimSpace(r.URL.Query().Get("species")); species != "" { + where += " AND p.species=?" + args = append(args, species) + } + } + rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,m.pet_id,m.owner_user_id,owner.nickname,m.city_name,m.expectation,m.status,m.moderation_status,m.created_at, + p.name,p.species,p.breed,p.gender,p.birthday,p.avatar_url,p.vaccinated_at + FROM pet_mating_listings m JOIN pets p ON p.id=m.pet_id JOIN user_profiles owner ON owner.user_id=m.owner_user_id`+where+ + ` ORDER BY m.id DESC LIMIT ? OFFSET ?`, append(args, size+1, offset)...) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询配种信息失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, petID, ownerID int64 + var ownerName, cityName, expectation, petName, species, breed, avatar string + var status, moderation, gender int + var birthday, vaccinated sql.NullTime + var created time.Time + if rows.Scan(&id, &petID, &ownerID, &ownerName, &cityName, &expectation, &status, &moderation, &created, + &petName, &species, &breed, &gender, &birthday, &avatar, &vaccinated) != nil { + continue + } + ageMonths := 0 + if birthday.Valid { + ageMonths = monthsSince(birthday.Time) + } + vaccinatedAt := "" + if vaccinated.Valid { + vaccinatedAt = vaccinated.Time.Format("2006-01-02") + } + items = append(items, map[string]any{ + "id": id, "petId": petID, "ownerUserId": ownerID, "ownerName": ownerName, "cityName": cityName, + "expectation": expectation, "status": status, "moderationStatus": moderation, + "petName": petName, "species": species, "speciesLabel": petSpecies[species], "breed": breed, + "gender": gender, "ageMonths": ageMonths, "avatar": avatar, "vaccinatedAt": vaccinatedAt, + "mine": ownerID == current(r).ID, "createdAt": created.Format(time.RFC3339), + }) + } + hasMore := len(items) > size + if hasMore { + items = items[:size] + } + reply(w, map[string]any{"items": items, "hasMore": hasMore, "enabled": true}) +} + +func (a *App) closeMatingListing(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 pet_mating_listings SET status=0 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}) +} diff --git a/im/backend/internal/app/wallet.go b/im/backend/internal/app/wallet.go new file mode 100644 index 0000000..daf3aeb --- /dev/null +++ b/im/backend/internal/app/wallet.go @@ -0,0 +1,455 @@ +package app + +import ( + "context" + "database/sql" + "fmt" + "net/http" + "strings" + "time" +) + +// The earnings ledger. Two rules hold everything else up: +// +// 1. Every balance change writes a transaction in the same database +// transaction, so the balance is always the sum of the ledger. +// 2. A business event can only be booked once. The unique key on +// (user, biz_type, biz_id) makes a double settlement impossible rather +// than merely unlikely — a retried job cannot pay twice. +// +// This account holds money owed to the user and can only ever leave through a +// withdrawal. It is deliberately not convertible back into anything spendable +// inside the app. + +type walletEntry struct { + UserID int64 + BizType string + BizID int64 + Direction int + AmountCen int64 + Memo string +} + +var errLedgerDuplicate = fmt.Errorf("这笔账已经记过") + +// bookLedger moves money and records why, inside the caller's transaction. +// Direction 1 credits available balance, -1 debits it. +func bookLedger(ctx context.Context, tx *sql.Tx, entry walletEntry) error { + if entry.AmountCen <= 0 { + return fmt.Errorf("金额必须大于 0") + } + if _, err := tx.ExecContext(ctx, `INSERT IGNORE INTO wallet_accounts(user_id) VALUES(?)`, entry.UserID); err != nil { + return err + } + var available, frozen int64 + if err := tx.QueryRowContext(ctx, `SELECT available_cent,frozen_cent FROM wallet_accounts WHERE user_id=? FOR UPDATE`, entry.UserID).Scan(&available, &frozen); err != nil { + return err + } + delta := entry.AmountCen * int64(entry.Direction) + if available+delta < 0 { + return fmt.Errorf("余额不足") + } + after := available + delta + // The unique key is the guard: a repeated settlement fails here instead of + // silently paying a second time. + if _, err := tx.ExecContext(ctx, `INSERT INTO wallet_transactions(user_id,biz_type,biz_id,direction,amount_cent,balance_after_cent,memo) + VALUES(?,?,?,?,?,?,?)`, entry.UserID, entry.BizType, entry.BizID, entry.Direction, entry.AmountCen, after, entry.Memo); err != nil { + if isDuplicateKeyError(err) { + return errLedgerDuplicate + } + return err + } + income := int64(0) + if entry.Direction > 0 { + income = entry.AmountCen + } + withdrawn := int64(0) + if entry.Direction < 0 { + withdrawn = entry.AmountCen + } + _, err := tx.ExecContext(ctx, `UPDATE wallet_accounts SET available_cent=?,total_income_cent=total_income_cent+?,total_withdraw_cent=total_withdraw_cent+? WHERE user_id=?`, + after, income, withdrawn, entry.UserID) + return err +} + +func isDuplicateKeyError(err error) bool { + return err != nil && strings.Contains(strings.ToLower(err.Error()), "duplicate entry") +} + +func (a *App) walletSummary(w http.ResponseWriter, r *http.Request) { + var available, frozen, income, withdrawn int64 + err := a.db.QueryRowContext(r.Context(), `SELECT available_cent,frozen_cent,total_income_cent,total_withdraw_cent FROM wallet_accounts WHERE user_id=?`, current(r).ID). + Scan(&available, &frozen, &income, &withdrawn) + if err != nil && err != sql.ErrNoRows { + fail(w, http.StatusInternalServerError, 50001, "查询余额失败") + return + } + var pending int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(amount_cent),0) FROM withdrawals WHERE user_id=? AND status IN ('PENDING','APPROVED')`, current(r).ID).Scan(&pending) + reply(w, map[string]any{ + "availableCent": available, "frozenCent": frozen, "totalIncomeCent": income, + "totalWithdrawCent": withdrawn, "pendingWithdrawCent": pending, + "withdrawEnabled": a.configBool(r.Context(), "pet.withdraw_enabled", false), + "minWithdrawCent": a.configInt(r.Context(), "pet.withdraw_min_cent", 1000), + }) +} + +func (a *App) walletTransactions(w http.ResponseWriter, r *http.Request) { + _, size, offset := pageOptions(r) + rows, err := a.db.QueryContext(r.Context(), `SELECT id,biz_type,biz_id,direction,amount_cent,balance_after_cent,memo,created_at + FROM wallet_transactions WHERE user_id=? ORDER BY id DESC LIMIT ? OFFSET ?`, current(r).ID, size+1, offset) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询流水失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, bizID, amount, balance int64 + var bizType, memo string + var direction int + var created time.Time + if rows.Scan(&id, &bizType, &bizID, &direction, &amount, &balance, &memo, &created) != nil { + continue + } + items = append(items, map[string]any{ + "id": id, "bizType": bizType, "bizId": bizID, "direction": direction, + "amountCent": amount, "balanceAfterCent": balance, "memo": memo, + "createdAt": created.Format(time.RFC3339), + }) + } + hasMore := len(items) > size + if hasMore { + items = items[:size] + } + reply(w, map[string]any{"items": items, "hasMore": hasMore}) +} + +// --- 提现账户 ------------------------------------------------------------- + +func (a *App) payoutAccounts(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT id,kind,account_mask,holder_name,bank_name FROM payout_accounts WHERE user_id=? AND status=1 ORDER BY id DESC`, current(r).ID) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询收款账户失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var kind, mask, holder, bank string + if rows.Scan(&id, &kind, &mask, &holder, &bank) != nil { + continue + } + items = append(items, map[string]any{"id": id, "kind": kind, "accountMask": mask, "holderName": holder, "bankName": bank}) + } + reply(w, map[string]any{"items": items}) +} + +func (a *App) addPayoutAccount(w http.ResponseWriter, r *http.Request) { + var req struct { + Kind string `json:"kind"` + Account string `json:"account"` + Holder string `json:"holder"` + BankName string `json:"bankName"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "收款账户格式错误") + return + } + req.Kind = strings.TrimSpace(req.Kind) + req.Account = strings.TrimSpace(req.Account) + req.Holder = strings.TrimSpace(req.Holder) + if req.Kind != "alipay" && req.Kind != "bank" { + fail(w, http.StatusBadRequest, 20001, "请选择收款方式") + return + } + if len(req.Account) < 6 || len(req.Account) > 64 || req.Holder == "" || len([]rune(req.Holder)) > 50 { + fail(w, http.StatusBadRequest, 20001, "请填写完整的收款账号与姓名") + return + } + // The name has to match the verified identity: paying a stranger on someone + // else's behalf is exactly the pattern money laundering rules exist for. + realName, verified := a.verifiedRealName(r.Context(), current(r).ID) + if !verified { + fail(w, http.StatusForbidden, 30003, "请先完成实名认证") + return + } + if realName != "" && realName != req.Holder { + fail(w, http.StatusBadRequest, 20001, "收款人姓名必须与实名认证一致") + return + } + cipher, err := a.encryptPhone(req.Account) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "保存失败") + return + } + result, err := a.db.ExecContext(r.Context(), `INSERT INTO payout_accounts(user_id,kind,account_cipher,account_mask,holder_name,bank_name) VALUES(?,?,?,?,?,?)`, + current(r).ID, req.Kind, cipher, maskAccount(req.Account), req.Holder, strings.TrimSpace(req.BankName)) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "保存失败") + return + } + id, _ := result.LastInsertId() + reply(w, map[string]any{"id": id}) +} + +func maskAccount(account string) string { + runes := []rune(account) + if len(runes) <= 4 { + return "****" + } + if len(runes) <= 8 { + return "****" + string(runes[len(runes)-4:]) + } + return string(runes[:3]) + "****" + string(runes[len(runes)-4:]) +} + +// The payout name must match the verified identity, so both come from the same +// row. status is the string the verification flow writes, not a code. +func (a *App) verifiedRealName(ctx context.Context, userID int64) (string, bool) { + var name sql.NullString + var status string + if a.db.QueryRowContext(ctx, `SELECT real_name,status FROM user_verifications WHERE user_id=? AND verification_type='real_name'`, userID).Scan(&name, &status) != nil { + return "", false + } + return strings.TrimSpace(name.String), strings.EqualFold(status, "VERIFIED") +} + +// --- 提现 ----------------------------------------------------------------- + +func (a *App) createWithdrawal(w http.ResponseWriter, r *http.Request) { + if !a.configBool(r.Context(), "pet.withdraw_enabled", false) { + fail(w, http.StatusForbidden, 30002, "提现通道尚未开放,收益会一直保留在账户里") + return + } + var req struct { + AmountCent int64 `json:"amountCent"` + AccountID int64 `json:"accountId"` + } + if decode(r, &req) != nil || req.AccountID == 0 { + fail(w, http.StatusBadRequest, 20001, "提现参数错误") + return + } + minCent := int64(a.configInt(r.Context(), "pet.withdraw_min_cent", 1000)) + if req.AmountCent < minCent { + fail(w, http.StatusBadRequest, 20001, fmt.Sprintf("单笔提现不少于 %.2f 元", float64(minCent)/100)) + return + } + if _, verified := a.verifiedRealName(r.Context(), current(r).ID); !verified { + fail(w, http.StatusForbidden, 30003, "请先完成实名认证") + return + } + var accountOwner int64 + if a.db.QueryRowContext(r.Context(), `SELECT user_id FROM payout_accounts WHERE id=? AND status=1`, req.AccountID).Scan(&accountOwner) != nil || accountOwner != current(r).ID { + fail(w, http.StatusNotFound, 30001, "收款账户不存在") + return + } + var todayTotal int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(amount_cent),0) FROM withdrawals + WHERE user_id=? AND status<>'REJECTED' AND created_at>=CURDATE()`, current(r).ID).Scan(&todayTotal) + if limit := int64(a.configInt(r.Context(), "pet.withdraw_daily_limit_cent", 500000)); limit > 0 && todayTotal+req.AmountCent > limit { + fail(w, http.StatusBadRequest, 20001, fmt.Sprintf("超过单日提现上限 %.2f 元", float64(limit)/100)) + return + } + fee := req.AmountCent * int64(a.configInt(r.Context(), "pet.withdraw_fee_percent", 0)) / 100 + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "提交失败") + return + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(r.Context(), `INSERT INTO withdrawals(user_id,account_id,amount_cent,fee_cent,payout_cent) VALUES(?,?,?,?,?)`, + current(r).ID, req.AccountID, req.AmountCent, fee, req.AmountCent-fee) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "提交失败") + return + } + withdrawalID, _ := result.LastInsertId() + // The money leaves the available balance now, so it cannot be withdrawn + // twice while the first request is still being reviewed. + if err = bookLedger(r.Context(), tx, walletEntry{ + UserID: current(r).ID, BizType: "withdraw", BizID: withdrawalID, Direction: -1, + AmountCen: req.AmountCent, Memo: "提现申请", + }); err != nil { + if err.Error() == "余额不足" { + fail(w, http.StatusBadRequest, 20001, "可提现余额不足") + return + } + fail(w, http.StatusInternalServerError, 50001, "提交失败") + return + } + if tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "提交失败") + return + } + reply(w, map[string]any{"id": withdrawalID, "status": "PENDING"}) +} + +func (a *App) myWithdrawals(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT w.id,w.amount_cent,w.fee_cent,w.payout_cent,w.status,w.failed_reason,w.created_at,p.account_mask,p.kind + FROM withdrawals w JOIN payout_accounts p ON p.id=w.account_id WHERE w.user_id=? ORDER BY w.id DESC LIMIT 50`, current(r).ID) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询提现记录失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, amount, fee, payout int64 + var status, reason, mask, kind string + var created time.Time + if rows.Scan(&id, &amount, &fee, &payout, &status, &reason, &created, &mask, &kind) != nil { + continue + } + items = append(items, map[string]any{ + "id": id, "amountCent": amount, "feeCent": fee, "payoutCent": payout, "status": status, + "failedReason": reason, "accountMask": mask, "kind": kind, "createdAt": created.Format(time.RFC3339), + }) + } + reply(w, map[string]any{"items": items}) +} + +// --- 管理端 --------------------------------------------------------------- + +func (a *App) adminWithdrawals(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + where := " WHERE 1=1" + args := []any{} + if status := strings.TrimSpace(r.URL.Query().Get("status")); status != "" { + where += " AND w.status=?" + args = append(args, status) + } + var total int + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM withdrawals w`+where, args...).Scan(&total) + rows, err := a.db.QueryContext(r.Context(), `SELECT w.id,w.user_id,profile.nickname,u.public_id,w.amount_cent,w.fee_cent,w.tax_cent,w.payout_cent, + w.status,w.failed_reason,w.created_at,p.kind,p.account_mask,p.holder_name,p.bank_name, + COALESCE(wa.available_cent,0),COALESCE(wa.total_income_cent,0) + FROM withdrawals w JOIN users u ON u.id=w.user_id JOIN user_profiles profile ON profile.user_id=w.user_id + JOIN payout_accounts p ON p.id=w.account_id LEFT JOIN wallet_accounts wa ON wa.user_id=w.user_id`+where+ + ` ORDER BY w.status='PENDING' DESC,w.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, userID, amount, fee, tax, payout, available, income int64 + var nickname, publicID, status, reason, kind, mask, holder, bank string + var created time.Time + if rows.Scan(&id, &userID, &nickname, &publicID, &amount, &fee, &tax, &payout, &status, &reason, &created, + &kind, &mask, &holder, &bank, &available, &income) != nil { + continue + } + items = append(items, map[string]any{ + "id": id, "userId": userID, "nickname": nickname, "publicId": publicID, + "amountCent": amount, "feeCent": fee, "taxCent": tax, "payoutCent": payout, + "status": status, "failedReason": reason, "kind": kind, "accountMask": mask, + "holderName": holder, "bankName": bank, "availableCent": available, "totalIncomeCent": income, + "createdAt": created.Format(time.RFC3339), + }) + } + reply(w, map[string]any{"items": items, "total": total, "page": page, "size": size}) +} + +// adminHandleWithdrawal is deliberately manual. Until a payout channel is +// connected, an operator marks a transfer they made themselves; a rejection +// returns the money to the user's balance through the ledger, never by editing +// the balance directly. +func (a *App) adminHandleWithdrawal(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "编号无效") + return + } + var req struct { + Action string `json:"action"` + Reason string `json:"reason"` + OrderNo string `json:"orderNo"` + TaxCent int64 `json:"taxCent"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "参数错误") + return + } + var userID, amount int64 + var status string + if a.db.QueryRowContext(r.Context(), `SELECT user_id,amount_cent,status FROM withdrawals WHERE id=?`, id).Scan(&userID, &amount, &status) != nil { + fail(w, http.StatusNotFound, 30001, "提现单不存在") + return + } + if status != "PENDING" && status != "APPROVED" { + fail(w, http.StatusBadRequest, 20001, "该提现单已处理") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "操作失败") + return + } + defer func() { _ = tx.Rollback() }() + switch strings.TrimSpace(req.Action) { + case "approve": + _, err = tx.ExecContext(r.Context(), `UPDATE withdrawals SET status='APPROVED',handled_by=?,handled_at=NOW(3) WHERE id=?`, current(r).ID, id) + case "paid": + _, err = tx.ExecContext(r.Context(), `UPDATE withdrawals SET status='PAID',provider_order_no=?,tax_cent=?,payout_cent=amount_cent-fee_cent-?,handled_by=?,handled_at=NOW(3) WHERE id=?`, + strings.TrimSpace(req.OrderNo), req.TaxCent, req.TaxCent, current(r).ID, id) + case "reject", "fail": + reason := strings.TrimSpace(req.Reason) + if reason == "" { + fail(w, http.StatusBadRequest, 20001, "驳回时必须填写原因") + return + } + newStatus := "REJECTED" + if req.Action == "fail" { + newStatus = "FAILED" + } + if _, err = tx.ExecContext(r.Context(), `UPDATE withdrawals SET status=?,failed_reason=?,handled_by=?,handled_at=NOW(3) WHERE id=?`, + newStatus, reason, current(r).ID, id); err == nil { + err = bookLedger(r.Context(), tx, walletEntry{ + UserID: userID, BizType: "withdraw_refund", BizID: id, Direction: 1, + AmountCen: amount, Memo: "提现未成功,金额退回:" + reason, + }) + } + default: + fail(w, http.StatusBadRequest, 20001, "操作无效") + return + } + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "操作失败") + return + } + if tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "操作失败") + return + } + a.audit(r, "withdrawal_"+req.Action, "withdrawal", id, map[string]any{"amountCent": amount, "reason": req.Reason}) + reply(w, map[string]bool{"success": true}) +} + +// adminWalletAudit is the daily check the plan calls for: the balance of every +// account must equal the sum of its ledger. A mismatch is a stop-the-world +// event, so it is surfaced rather than logged. +func (a *App) adminWalletAudit(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT a.user_id,a.available_cent,COALESCE(SUM(t.amount_cent*t.direction),0) + FROM wallet_accounts a LEFT JOIN wallet_transactions t ON t.user_id=a.user_id + GROUP BY a.user_id,a.available_cent HAVING a.available_cent<>COALESCE(SUM(t.amount_cent*t.direction),0)`) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "对账失败") + return + } + defer rows.Close() + mismatches := []map[string]any{} + for rows.Next() { + var userID, balance, ledger int64 + if rows.Scan(&userID, &balance, &ledger) != nil { + continue + } + mismatches = append(mismatches, map[string]any{"userId": userID, "balanceCent": balance, "ledgerCent": ledger}) + } + var accounts, escrowed int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM wallet_accounts`).Scan(&accounts) + _ = a.db.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(gross_cent),0) FROM pet_feed_tasks WHERE status IN ('ESCROWED','ASSIGNED','SERVING','COMPLETED','DISPUTED')`).Scan(&escrowed) + reply(w, map[string]any{"accounts": accounts, "escrowedCent": escrowed, "mismatches": mismatches, "healthy": len(mismatches) == 0}) +} diff --git a/im/backend/migrations/037_pet_adoption_mating.sql b/im/backend/migrations/037_pet_adoption_mating.sql new file mode 100644 index 0000000..305bc4c --- /dev/null +++ b/im/backend/migrations/037_pet_adoption_mating.sql @@ -0,0 +1,88 @@ +SET NAMES utf8mb4; + +-- 送养帖。平台只做信息与撮合,任何金额字段都不存在——不给"变相售卖"留位置。 +CREATE TABLE IF NOT EXISTS pet_adoptions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + pet_id BIGINT UNSIGNED NOT NULL, + owner_user_id BIGINT UNSIGNED NOT NULL, + city_code VARCHAR(20) NOT NULL DEFAULT '', + city_name VARCHAR(50) NOT NULL DEFAULT '', + reason VARCHAR(500) NOT NULL DEFAULT '' COMMENT '送养原因', + requirements VARCHAR(500) NOT NULL DEFAULT '' COMMENT '对领养人的要求', + status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '0 已下架 1 招领中 2 已送出', + moderation_status TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 待审核 1 通过 2 驳回', + moderation_note VARCHAR(255) NOT NULL DEFAULT '', + review_reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '转人工的原因,给审核员看', + application_count INT UNSIGNED NOT NULL DEFAULT 0, + adopter_user_id BIGINT UNSIGNED NULL, + completed_at DATETIME(3) NULL, + 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_adoptions_listing (status, moderation_status, id), + KEY idx_adoptions_owner (owner_user_id, id), + KEY idx_adoptions_city (city_code, status, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 一个人对同一条送养只能申请一次:unique 让重复申请在数据库层面就不可能。 +CREATE TABLE IF NOT EXISTS pet_adoption_applications ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + adoption_id BIGINT UNSIGNED NOT NULL, + applicant_user_id BIGINT UNSIGNED NOT NULL, + answers_json TEXT NOT NULL, + message VARCHAR(500) NOT NULL DEFAULT '', + status TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 待处理 1 已选定 2 已婉拒 3 已撤回', + decided_at DATETIME(3) NULL, + 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), + PRIMARY KEY (id), + UNIQUE KEY uk_adoption_applicant (adoption_id, applicant_user_id), + KEY idx_applications_applicant (applicant_user_id, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 回访:送出之后才是责任的开始,到期提醒领养人交一次近况。 +CREATE TABLE IF NOT EXISTS pet_adoption_followups ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + adoption_id BIGINT UNSIGNED NOT NULL, + adopter_user_id BIGINT UNSIGNED NOT NULL, + due_at DATETIME(3) NOT NULL, + submitted_at DATETIME(3) NULL, + content VARCHAR(500) NOT NULL DEFAULT '', + media_json TEXT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_followups_due (submitted_at, due_at), + KEY idx_followups_adopter (adopter_user_id, due_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 配种信息:只展示与联系,不参与交易,所以同样没有金额字段。 +CREATE TABLE IF NOT EXISTS pet_mating_listings ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + pet_id BIGINT UNSIGNED NOT NULL, + owner_user_id BIGINT UNSIGNED NOT NULL, + city_code VARCHAR(20) NOT NULL DEFAULT '', + city_name VARCHAR(50) NOT NULL DEFAULT '', + expectation VARCHAR(500) NOT NULL DEFAULT '' COMMENT '对对方的期望', + vaccine_media VARCHAR(500) NOT NULL DEFAULT '' COMMENT '免疫证明照片', + status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '0 已下架 1 展示中', + moderation_status TINYINT UNSIGNED NOT NULL DEFAULT 0, + 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_mating_listing (status, moderation_status, id), + KEY idx_mating_owner (owner_user_id, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES + ('pet.adoption_enabled','true','boolean','是否开放送养与领养'), + ('pet.adoption_min_photos','3','integer','发布送养所需的最少照片数'), + ('pet.adoption_review_words','元,钱,价,押金,费用,红包,转账,收费,出售,买,卖,微信,QQ,电话','string','送养文案命中这些词时转人工审核,逗号分隔'), + ('pet.adoption_batch_days','90','integer','统计送养只数的时间窗口(天)'), + ('pet.adoption_batch_limit','3','integer','窗口内送养超过这个只数即转人工审核'), + ('pet.adoption_followup_days','30,90','string','送出后的回访节点,天数逗号分隔'), + ('pet.mating_enabled','false','boolean','是否开放配种撮合;开启前请确认动物福利与舆情风险'), + ('pet.mating_min_age_months','12','integer','可发布配种的最小月龄') +ON DUPLICATE KEY UPDATE description=VALUES(description); diff --git a/im/backend/migrations/038_pet_feed_wallet.sql b/im/backend/migrations/038_pet_feed_wallet.sql new file mode 100644 index 0000000..b547082 --- /dev/null +++ b/im/backend/migrations/038_pet_feed_wallet.sql @@ -0,0 +1,246 @@ +SET NAMES utf8mb4; + +-- 服务地址。发布时只展示城市与街道,门牌与联系方式加密存储, +-- 选定喂养者之后才解密下发;任务结束后由清理任务抹掉明文。 +CREATE TABLE IF NOT EXISTS pet_addresses ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + city_code VARCHAR(20) NOT NULL DEFAULT '', + city_name VARCHAR(50) NOT NULL DEFAULT '', + area_text VARCHAR(100) NOT NULL DEFAULT '' COMMENT '公开展示的粗略位置', + detail_cipher VARBINARY(1024) NOT NULL COMMENT '门牌与门锁说明,AES-GCM', + contact_cipher VARBINARY(512) NOT NULL COMMENT '联系人与电话,AES-GCM', + latitude DECIMAL(10,7) NULL, + longitude DECIMAL(10,7) NULL, + purge_at DATETIME(3) NULL COMMENT '到期清除明文', + 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), + PRIMARY KEY (id), + KEY idx_pet_addresses_user (user_id, id), + KEY idx_pet_addresses_purge (purge_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 喂养者资料。保证金是接单的前提,也是赔付的第一道来源。 +CREATE TABLE IF NOT EXISTS pet_sitters ( + user_id BIGINT UNSIGNED NOT NULL, + city_code VARCHAR(20) NOT NULL DEFAULT '', + city_name VARCHAR(50) NOT NULL DEFAULT '', + radius_m INT UNSIGNED NOT NULL DEFAULT 5000, + intro VARCHAR(500) NOT NULL DEFAULT '', + certs_json TEXT NULL, + deposit_cent INT UNSIGNED NOT NULL DEFAULT 0, + rating_total INT UNSIGNED NOT NULL DEFAULT 0, + rating_count INT UNSIGNED NOT NULL DEFAULT 0, + completed_count INT UNSIGNED NOT NULL DEFAULT 0, + cancelled_count INT UNSIGNED NOT NULL DEFAULT 0, + status TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 待审核 1 可接单 2 已驳回 3 已停用', + status_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), + PRIMARY KEY (user_id), + KEY idx_sitters_city (status, city_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 代喂任务。金额四段拆分,是为了对接分账与代征通道时不用再改结构。 +CREATE TABLE IF NOT EXISTS pet_feed_tasks ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + owner_user_id BIGINT UNSIGNED NOT NULL, + pet_id BIGINT UNSIGNED NOT NULL, + address_id BIGINT UNSIGNED NOT NULL, + city_code VARCHAR(20) NOT NULL DEFAULT '', + start_date DATE NOT NULL, + end_date DATE NOT NULL, + visits_per_day TINYINT UNSIGNED NOT NULL DEFAULT 1, + min_minutes SMALLINT UNSIGNED NOT NULL DEFAULT 20, + price_cent INT UNSIGNED NOT NULL COMMENT '单次报酬', + gross_cent INT UNSIGNED NOT NULL COMMENT '主人支付总额', + platform_fee_cent INT UNSIGNED NOT NULL DEFAULT 0, + tax_cent INT UNSIGNED NOT NULL DEFAULT 0, + payout_cent INT UNSIGNED NOT NULL DEFAULT 0, + note VARCHAR(500) NOT NULL DEFAULT '', + status VARCHAR(20) NOT NULL DEFAULT 'CREATED' COMMENT 'CREATED/ESCROWED/ASSIGNED/SERVING/COMPLETED/SETTLED/CANCELLED/DISPUTED/ARBITRATED/REFUNDED', + sitter_user_id BIGINT UNSIGNED NULL, + order_id BIGINT UNSIGNED NULL, + conversation_id BIGINT UNSIGNED NULL, + insurance_no VARCHAR(60) NOT NULL DEFAULT '', + applications_count INT UNSIGNED NOT NULL DEFAULT 0, + completed_visits INT UNSIGNED NOT NULL DEFAULT 0, + total_visits INT UNSIGNED NOT NULL DEFAULT 0, + confirm_due_at DATETIME(3) NULL COMMENT '自动确认时间', + settled_at DATETIME(3) NULL, + 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), + PRIMARY KEY (id), + KEY idx_feed_tasks_hall (status, city_code, id), + KEY idx_feed_tasks_owner (owner_user_id, id), + KEY idx_feed_tasks_sitter (sitter_user_id, id), + KEY idx_feed_tasks_confirm (status, confirm_due_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS pet_feed_task_items ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + task_id BIGINT UNSIGNED NOT NULL, + kind VARCHAR(20) NOT NULL COMMENT 'feed/water/litter/walk/medicine', + amount_text VARCHAR(100) NOT NULL DEFAULT '', + PRIMARY KEY (id), + KEY idx_feed_items_task (task_id, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS pet_feed_applications ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + task_id BIGINT UNSIGNED NOT NULL, + sitter_user_id BIGINT UNSIGNED NOT NULL, + message VARCHAR(300) NOT NULL DEFAULT '', + status TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 待选定 1 已选定 2 未选中 3 已撤回', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_feed_application (task_id, sitter_user_id), + KEY idx_feed_applications_sitter (sitter_user_id, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS pet_feed_visits ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + task_id BIGINT UNSIGNED NOT NULL, + visit_date DATE NOT NULL, + sequence TINYINT UNSIGNED NOT NULL DEFAULT 1, + started_at DATETIME(3) NULL, + finished_at DATETIME(3) NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 待上门 1 进行中 2 已完成 3 已跳过', + abnormal TINYINT(1) NOT NULL DEFAULT 0, + abnormal_note VARCHAR(255) NOT NULL DEFAULT '', + PRIMARY KEY (id), + UNIQUE KEY uk_feed_visit (task_id, visit_date, sequence), + KEY idx_feed_visits_task (task_id, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 打卡是履约的唯一证据。distance_m 与 verdict 让"是否真的到过"可复核。 +CREATE TABLE IF NOT EXISTS pet_feed_checkins ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + visit_id BIGINT UNSIGNED NOT NULL, + task_id BIGINT UNSIGNED NOT NULL, + step VARCHAR(20) NOT NULL COMMENT 'arrive/feed/water/litter/leave', + media_url VARCHAR(500) NOT NULL, + latitude DECIMAL(10,7) NULL, + longitude DECIMAL(10,7) NULL, + distance_m INT UNSIGNED NULL, + captured_at DATETIME(3) NOT NULL, + verdict VARCHAR(20) NOT NULL DEFAULT 'ok' COMMENT 'ok/far/stale/duplicate', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_checkin_step (visit_id, step), + KEY idx_checkins_task (task_id, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS pet_feed_reviews ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + task_id BIGINT UNSIGNED NOT NULL, + from_user_id BIGINT UNSIGNED NOT NULL, + to_user_id BIGINT UNSIGNED NOT NULL, + punctual TINYINT UNSIGNED NOT NULL DEFAULT 5, + accurate TINYINT UNSIGNED NOT NULL DEFAULT 5, + communication TINYINT UNSIGNED NOT NULL DEFAULT 5, + content VARCHAR(500) NOT NULL DEFAULT '', + visible_at DATETIME(3) NULL COMMENT '双盲:双方都提交或到期后才公开', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_feed_review (task_id, from_user_id), + KEY idx_feed_reviews_target (to_user_id, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS pet_feed_disputes ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + task_id BIGINT UNSIGNED NOT NULL, + raised_by BIGINT UNSIGNED NOT NULL, + reason VARCHAR(500) NOT NULL, + evidence_json TEXT NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 待处理 1 已裁定', + verdict VARCHAR(20) NOT NULL DEFAULT '' COMMENT 'payout/refund/split', + refund_cent INT UNSIGNED NOT NULL DEFAULT 0, + payout_cent INT UNSIGNED NOT NULL DEFAULT 0, + handled_by BIGINT UNSIGNED NULL, + handled_note VARCHAR(500) NOT NULL DEFAULT '', + handled_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_dispute_task (task_id), + KEY idx_disputes_status (status, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 收益账户。与"充值消费"严格分离:这里的余额只能提现,不能反向变成消费额度。 +CREATE TABLE IF NOT EXISTS wallet_accounts ( + user_id BIGINT UNSIGNED NOT NULL, + available_cent BIGINT NOT NULL DEFAULT 0, + frozen_cent BIGINT NOT NULL DEFAULT 0, + total_income_cent BIGINT NOT NULL DEFAULT 0, + total_withdraw_cent BIGINT NOT NULL DEFAULT 0, + 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), + PRIMARY KEY (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 账本:任何余额变动都必须有一条流水,任何时刻余额等于流水累加。 +CREATE TABLE IF NOT EXISTS wallet_transactions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + biz_type VARCHAR(30) NOT NULL COMMENT 'feed_settle/withdraw/withdraw_refund/adjust', + biz_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + direction TINYINT NOT NULL COMMENT '1 入账 -1 出账', + amount_cent BIGINT NOT NULL, + balance_after_cent BIGINT NOT NULL, + memo VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_wallet_biz (user_id, biz_type, biz_id), + KEY idx_wallet_tx_user (user_id, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS payout_accounts ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + kind VARCHAR(20) NOT NULL COMMENT 'alipay/bank', + account_cipher VARBINARY(512) NOT NULL, + account_mask VARCHAR(60) NOT NULL DEFAULT '', + holder_name VARCHAR(50) NOT NULL, + bank_name VARCHAR(100) NOT NULL DEFAULT '', + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_payout_user (user_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS withdrawals ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + account_id BIGINT UNSIGNED NOT NULL, + amount_cent BIGINT NOT NULL COMMENT '申请金额', + fee_cent BIGINT NOT NULL DEFAULT 0, + tax_cent BIGINT NOT NULL DEFAULT 0, + payout_cent BIGINT NOT NULL DEFAULT 0 COMMENT '实际到账', + status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/APPROVED/PAID/REJECTED/FAILED', + provider VARCHAR(30) NOT NULL DEFAULT '', + provider_order_no VARCHAR(80) NOT NULL DEFAULT '', + failed_reason VARCHAR(255) NOT NULL DEFAULT '', + handled_by BIGINT UNSIGNED NULL, + handled_at DATETIME(3) NULL, + 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), + PRIMARY KEY (id), + KEY idx_withdrawals_status (status, id), + KEY idx_withdrawals_user (user_id, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES + ('pet.feed_enabled','true','boolean','是否开放上门代喂'), + ('pet.feed_platform_fee_percent','15','integer','平台服务费比例(百分比)'), + ('pet.feed_min_price_cent','2000','integer','单次代喂最低报酬(分)'), + ('pet.feed_max_price_cent','50000','integer','单次代喂最高报酬(分)'), + ('pet.feed_max_days','14','integer','单个任务最长服务天数'), + ('pet.feed_confirm_hours','24','integer','服务完成后自动确认的小时数'), + ('pet.feed_checkin_max_distance_m','300','integer','打卡定位与地址的最大偏差(米)'), + ('pet.feed_checkin_max_delay_min','5','integer','照片拍摄与提交的最大间隔(分钟)'), + ('pet.sitter_deposit_cent','20000','integer','喂养者保证金(分)'), + ('pet.withdraw_enabled','false','boolean','是否开放提现;接入分账/代征通道并完成对账后再开启'), + ('pet.withdraw_min_cent','1000','integer','单笔提现最低金额(分)'), + ('pet.withdraw_daily_limit_cent','500000','integer','单日提现上限(分)'), + ('pet.withdraw_fee_percent','0','integer','提现手续费比例(百分比)') +ON DUPLICATE KEY UPDATE description=VALUES(description);