宠物板块二到四期:领养、配种、上门代喂与收益账户

领养是信息服务:没有金额字段,文案里出现收费话术或短期内连续送养都会转人工,
送出时按 30/90 天建好回访任务。配种同样只做信息展示,且默认关闭。

代喂是这个模块里唯一动钱的部分,顺序是它的安全边界:先付款再进大厅,选定
之后才解密门牌与电话,每次上门按到达/喂食/离开留照片,主人确认或超时自动确认
后才结算。结算只走账本,(user, biz_type, biz_id) 唯一键让重复结算付不出第二次;
申诉会冻结自动确认,由客服裁定全付、部分付或全退。

提现按方案默认关闭:收益先只记账,等分账通道与资质到位再在管理端开闸。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-07 08:14:19 +08:00
co-authored by Claude Opus 5
parent aee1881e69
commit 46a60ffac3
12 changed files with 3871 additions and 0 deletions
+45
View File
@@ -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)},
@@ -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, &note, &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})
}
+537
View File
@@ -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(&note)
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})
}
@@ -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())
}
})
}
+702
View File
@@ -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, &note)
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})
}
+309
View File
@@ -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, &note, &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,
&note, &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})
}
@@ -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
}
+548
View File
@@ -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})
}
+159
View File
@@ -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})
}
+455
View File
@@ -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})
}
@@ -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);
@@ -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);