宠物板块二到四期:领养、配种、上门代喂与收益账户
领养是信息服务:没有金额字段,文案里出现收费话术或短期内连续送养都会转人工, 送出时按 30/90 天建好回访任务。配种同样只做信息展示,且默认关闭。 代喂是这个模块里唯一动钱的部分,顺序是它的安全边界:先付款再进大厅,选定 之后才解密门牌与电话,每次上门按到达/喂食/离开留照片,主人确认或超时自动确认 后才结算。结算只走账本,(user, biz_type, biz_id) 唯一键让重复结算付不出第二次; 申诉会冻结自动确认,由客服裁定全付、部分付或全退。 提现按方案默认关闭:收益先只记账,等分账通道与资质到位再在管理端开闸。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
aee1881e69
commit
46a60ffac3
@@ -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, ¬e, &created,
|
||||
&petID, &petName, &species, &breed, &avatar, &vaccinated, &neutered, &body, &requirements, &flag, &applications) != nil {
|
||||
continue
|
||||
}
|
||||
vaccinatedAt := ""
|
||||
if vaccinated.Valid {
|
||||
vaccinatedAt = vaccinated.Time.Format("2006-01-02")
|
||||
}
|
||||
items = append(items, map[string]any{
|
||||
"id": id, "kind": kind, "ownerUserId": ownerID, "ownerNickname": nickname, "ownerPublicId": publicID,
|
||||
"cityName": cityName, "status": status, "moderationStatus": moderation, "moderationNote": note,
|
||||
"petId": petID, "petName": petName, "species": species, "speciesLabel": petSpecies[species],
|
||||
"breed": breed, "avatar": avatar, "vaccinatedAt": vaccinatedAt, "neutered": neutered == 1,
|
||||
"body": body, "requirements": requirements, "flag": flag, "applicationCount": applications,
|
||||
"createdAt": created.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
reply(w, map[string]any{"items": items, "total": total, "page": page, "size": size})
|
||||
}
|
||||
|
||||
func (a *App) adminModeratePetListing(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "编号无效")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Kind string `json:"kind"`
|
||||
Status int `json:"status"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if decode(r, &req) != nil || req.Status < 0 || req.Status > 2 {
|
||||
fail(w, http.StatusBadRequest, 20001, "审核状态无效")
|
||||
return
|
||||
}
|
||||
table, ok := petListingTables[strings.TrimSpace(req.Kind)]
|
||||
if !ok {
|
||||
fail(w, http.StatusBadRequest, 20001, "类型无效")
|
||||
return
|
||||
}
|
||||
req.Note = strings.TrimSpace(req.Note)
|
||||
if req.Status == 2 && req.Note == "" {
|
||||
fail(w, http.StatusBadRequest, 20001, "驳回时必须填写原因")
|
||||
return
|
||||
}
|
||||
if len([]rune(req.Note)) > 100 {
|
||||
fail(w, http.StatusBadRequest, 20001, "审核备注最多 100 字")
|
||||
return
|
||||
}
|
||||
var owner int64
|
||||
if a.db.QueryRowContext(r.Context(), fmt.Sprintf(`SELECT owner_user_id FROM %s WHERE id=? AND deleted_at IS NULL`, table), id).Scan(&owner) != nil {
|
||||
fail(w, http.StatusNotFound, 30001, "记录不存在")
|
||||
return
|
||||
}
|
||||
if _, err = a.db.ExecContext(r.Context(), fmt.Sprintf(`UPDATE %s SET moderation_status=?,moderation_note=? WHERE id=?`, table), req.Status, req.Note, id); err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "保存审核结果失败")
|
||||
return
|
||||
}
|
||||
title, body := "发布已通过", "你的信息已经通过审核,现在可以被其他人看到了"
|
||||
if req.Status == 2 {
|
||||
title, body = "发布未通过", req.Note
|
||||
}
|
||||
a.notifyUser(r.Context(), owner, "system", title, body, req.Kind, id)
|
||||
a.audit(r, "moderate", "pet_"+req.Kind, id, map[string]any{"status": req.Status, "note": req.Note})
|
||||
reply(w, map[string]bool{"success": true})
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Adoption is an information service, not a marketplace: there is no price
|
||||
// field anywhere in it, and anything in the free text that smells like one
|
||||
// sends the listing to a human instead of straight onto the shelf.
|
||||
|
||||
type adoptionView struct {
|
||||
ID int64 `json:"id"`
|
||||
PetID int64 `json:"petId"`
|
||||
OwnerUserID int64 `json:"ownerUserId"`
|
||||
OwnerName string `json:"ownerName"`
|
||||
PetName string `json:"petName"`
|
||||
Species string `json:"species"`
|
||||
SpeciesLabel string `json:"speciesLabel"`
|
||||
Breed string `json:"breed"`
|
||||
Gender int `json:"gender"`
|
||||
AgeMonths int `json:"ageMonths"`
|
||||
Neutered bool `json:"neutered"`
|
||||
VaccinatedAt string `json:"vaccinatedAt,omitempty"`
|
||||
Avatar string `json:"avatar"`
|
||||
Photos []string `json:"photos"`
|
||||
CityName string `json:"cityName"`
|
||||
Reason string `json:"reason"`
|
||||
Requirements string `json:"requirements"`
|
||||
Status int `json:"status"`
|
||||
Moderation int `json:"moderationStatus"`
|
||||
Applications int `json:"applicationCount"`
|
||||
Mine bool `json:"mine"`
|
||||
Applied bool `json:"applied"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// The questionnaire is the only real filter a giver has. Keeping the questions
|
||||
// server-side means every listing asks the same things and the answers stay
|
||||
// comparable.
|
||||
var adoptionQuestions = []struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
}{
|
||||
{"housing", "居住情况(自有/租住,房东是否允许养宠)"},
|
||||
{"members", "家人或室友是否都同意"},
|
||||
{"pets", "家里是否已有其他宠物"},
|
||||
{"experience", "过往养宠经验"},
|
||||
{"followup", "是否接受送出后的回访"},
|
||||
}
|
||||
|
||||
func (a *App) adoptionReviewReason(ctx context.Context, ownerID int64, text string) string {
|
||||
words := strings.Split(a.configPlain(ctx, "pet.adoption_review_words", ""), ",")
|
||||
lowered := strings.ToLower(text)
|
||||
for _, word := range words {
|
||||
word = strings.TrimSpace(word)
|
||||
if word != "" && strings.Contains(lowered, strings.ToLower(word)) {
|
||||
return "文案命中敏感词:" + word
|
||||
}
|
||||
}
|
||||
// Someone giving away a litter every month is a breeder, not an owner in
|
||||
// trouble. The window and threshold are both configurable.
|
||||
days := a.configInt(ctx, "pet.adoption_batch_days", 90)
|
||||
limit := a.configInt(ctx, "pet.adoption_batch_limit", 3)
|
||||
if days > 0 && limit > 0 {
|
||||
var recent int
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM pet_adoptions WHERE owner_user_id=? AND deleted_at IS NULL AND created_at>DATE_SUB(NOW(3),INTERVAL ? DAY)`, ownerID, days).Scan(&recent)
|
||||
if recent >= limit {
|
||||
return fmt.Sprintf("%d 天内已发布 %d 条送养", days, recent)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *App) createAdoption(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.configBool(r.Context(), "pet.adoption_enabled", true) {
|
||||
fail(w, http.StatusForbidden, 30002, "送养功能暂未开放")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
PetID int64 `json:"petId"`
|
||||
CityCode string `json:"cityCode"`
|
||||
CityName string `json:"cityName"`
|
||||
Reason string `json:"reason"`
|
||||
Requirements string `json:"requirements"`
|
||||
}
|
||||
if decode(r, &req) != nil || req.PetID == 0 {
|
||||
fail(w, http.StatusBadRequest, 20001, "送养信息格式错误")
|
||||
return
|
||||
}
|
||||
req.Reason = strings.TrimSpace(req.Reason)
|
||||
req.Requirements = strings.TrimSpace(req.Requirements)
|
||||
req.CityName = strings.TrimSpace(req.CityName)
|
||||
if len([]rune(req.Reason)) < 10 || len([]rune(req.Reason)) > 500 {
|
||||
fail(w, http.StatusBadRequest, 20001, "请说明送养原因,10 到 500 字")
|
||||
return
|
||||
}
|
||||
if len([]rune(req.Requirements)) > 500 || len([]rune(req.CityName)) > 50 {
|
||||
fail(w, http.StatusBadRequest, 20001, "内容过长")
|
||||
return
|
||||
}
|
||||
// The listing inherits its facts from the profile, so the profile has to be
|
||||
// complete first: photos to look at, and a vaccination record to trust.
|
||||
var vaccinated sql.NullTime
|
||||
var photos int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT p.vaccinated_at,(SELECT COUNT(*) FROM pet_media m WHERE m.pet_id=p.id)
|
||||
FROM pets p WHERE p.id=? AND p.owner_user_id=? AND p.status=1 AND p.deleted_at IS NULL`, req.PetID, current(r).ID).Scan(&vaccinated, &photos); err != nil {
|
||||
fail(w, http.StatusNotFound, 30001, "宠物不存在")
|
||||
return
|
||||
}
|
||||
if minPhotos := a.configInt(r.Context(), "pet.adoption_min_photos", 3); photos < minPhotos {
|
||||
fail(w, http.StatusBadRequest, 20001, fmt.Sprintf("发布送养前请至少上传 %d 张宠物照片", minPhotos))
|
||||
return
|
||||
}
|
||||
if !vaccinated.Valid {
|
||||
fail(w, http.StatusBadRequest, 20001, "请先在宠物档案中填写免疫日期")
|
||||
return
|
||||
}
|
||||
var existing int
|
||||
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM pet_adoptions WHERE pet_id=? AND status=1 AND deleted_at IS NULL`, req.PetID).Scan(&existing)
|
||||
if existing > 0 {
|
||||
fail(w, http.StatusBadRequest, 20001, "这只宠物已经在送养中")
|
||||
return
|
||||
}
|
||||
reviewReason := a.adoptionReviewReason(r.Context(), current(r).ID, req.Reason+" "+req.Requirements)
|
||||
result, err := a.db.ExecContext(r.Context(), `INSERT INTO pet_adoptions(pet_id,owner_user_id,city_code,city_name,reason,requirements,review_reason)
|
||||
VALUES(?,?,?,?,?,?,?)`, req.PetID, current(r).ID, strings.TrimSpace(req.CityCode), req.CityName, req.Reason, req.Requirements, reviewReason)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "发布失败")
|
||||
return
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
reply(w, map[string]any{"id": id, "pendingReview": true, "reviewReason": reviewReason})
|
||||
}
|
||||
|
||||
const adoptionColumns = `a.id,a.pet_id,a.owner_user_id,owner.nickname,a.city_name,a.reason,a.requirements,a.status,a.moderation_status,a.application_count,a.created_at,
|
||||
p.name,p.species,p.breed,p.gender,p.birthday,p.neutered,p.vaccinated_at,p.avatar_url`
|
||||
|
||||
func (a *App) scanAdoptions(ctx context.Context, rows *sql.Rows, viewer int64) []adoptionView {
|
||||
items := []adoptionView{}
|
||||
ids := []any{}
|
||||
placeholders := []string{}
|
||||
for rows.Next() {
|
||||
var item adoptionView
|
||||
var birthday, vaccinated sql.NullTime
|
||||
var created time.Time
|
||||
var neutered int
|
||||
if rows.Scan(&item.ID, &item.PetID, &item.OwnerUserID, &item.OwnerName, &item.CityName, &item.Reason,
|
||||
&item.Requirements, &item.Status, &item.Moderation, &item.Applications, &created,
|
||||
&item.PetName, &item.Species, &item.Breed, &item.Gender, &birthday, &neutered, &vaccinated, &item.Avatar) != nil {
|
||||
continue
|
||||
}
|
||||
item.SpeciesLabel = petSpecies[item.Species]
|
||||
item.Neutered = neutered == 1
|
||||
if birthday.Valid {
|
||||
item.AgeMonths = monthsSince(birthday.Time)
|
||||
}
|
||||
if vaccinated.Valid {
|
||||
item.VaccinatedAt = vaccinated.Time.Format("2006-01-02")
|
||||
}
|
||||
item.CreatedAt = created.Format(time.RFC3339)
|
||||
item.Mine = item.OwnerUserID == viewer
|
||||
item.Photos = []string{}
|
||||
items = append(items, item)
|
||||
ids = append(ids, item.PetID)
|
||||
placeholders = append(placeholders, "?")
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return items
|
||||
}
|
||||
mediaRows, err := a.db.QueryContext(ctx, `SELECT pet_id,media_url FROM pet_media WHERE pet_id IN (`+strings.Join(placeholders, ",")+`) ORDER BY pet_id,sort_order,id`, ids...)
|
||||
if err == nil {
|
||||
defer mediaRows.Close()
|
||||
for mediaRows.Next() {
|
||||
var petID int64
|
||||
var url string
|
||||
if mediaRows.Scan(&petID, &url) != nil {
|
||||
continue
|
||||
}
|
||||
for index := range items {
|
||||
if items[index].PetID == petID {
|
||||
items[index].Photos = append(items[index].Photos, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// One query tells the reader which listings they already applied to, so the
|
||||
// list can show "已申请" without a request per card.
|
||||
appliedRows, err := a.db.QueryContext(ctx, `SELECT adoption_id FROM pet_adoption_applications WHERE applicant_user_id=? AND status IN (0,1)`, viewer)
|
||||
if err == nil {
|
||||
defer appliedRows.Close()
|
||||
applied := map[int64]bool{}
|
||||
for appliedRows.Next() {
|
||||
var id int64
|
||||
if appliedRows.Scan(&id) == nil {
|
||||
applied[id] = true
|
||||
}
|
||||
}
|
||||
for index := range items {
|
||||
items[index].Applied = applied[items[index].ID]
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (a *App) adoptions(w http.ResponseWriter, r *http.Request) {
|
||||
_, size, offset := pageOptions(r)
|
||||
where := " WHERE a.deleted_at IS NULL"
|
||||
args := []any{}
|
||||
if strings.TrimSpace(r.URL.Query().Get("mine")) == "1" {
|
||||
where += " AND a.owner_user_id=?"
|
||||
args = append(args, current(r).ID)
|
||||
} else {
|
||||
// The shelf only carries approved, still-open listings.
|
||||
where += " AND a.status=1 AND a.moderation_status=1"
|
||||
if city := strings.TrimSpace(r.URL.Query().Get("cityCode")); city != "" {
|
||||
where += " AND a.city_code=?"
|
||||
args = append(args, city)
|
||||
}
|
||||
if species := strings.TrimSpace(r.URL.Query().Get("species")); species != "" {
|
||||
where += " AND p.species=?"
|
||||
args = append(args, species)
|
||||
}
|
||||
}
|
||||
query := `SELECT ` + adoptionColumns + ` FROM pet_adoptions a
|
||||
JOIN pets p ON p.id=a.pet_id JOIN user_profiles owner ON owner.user_id=a.owner_user_id` + where +
|
||||
` ORDER BY a.id DESC LIMIT ? OFFSET ?`
|
||||
rows, err := a.db.QueryContext(r.Context(), query, append(args, size+1, offset)...)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "查询送养信息失败")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := a.scanAdoptions(r.Context(), rows, current(r).ID)
|
||||
hasMore := len(items) > size
|
||||
if hasMore {
|
||||
items = items[:size]
|
||||
}
|
||||
reply(w, map[string]any{"items": items, "hasMore": hasMore, "questions": adoptionQuestions})
|
||||
}
|
||||
|
||||
func (a *App) adoptionDetail(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "编号无效")
|
||||
return
|
||||
}
|
||||
rows, queryErr := a.db.QueryContext(r.Context(), `SELECT `+adoptionColumns+` FROM pet_adoptions a
|
||||
JOIN pets p ON p.id=a.pet_id JOIN user_profiles owner ON owner.user_id=a.owner_user_id
|
||||
WHERE a.id=? AND a.deleted_at IS NULL AND (a.owner_user_id=? OR (a.status<>0 AND a.moderation_status=1))`, id, current(r).ID)
|
||||
if queryErr != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "查询失败")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := a.scanAdoptions(r.Context(), rows, current(r).ID)
|
||||
if len(items) == 0 {
|
||||
fail(w, http.StatusNotFound, 30001, "送养信息不存在")
|
||||
return
|
||||
}
|
||||
view := map[string]any{"adoption": items[0], "questions": adoptionQuestions}
|
||||
// Answers are private to the giver: they contain where a stranger lives.
|
||||
if items[0].Mine {
|
||||
applications, _ := a.adoptionApplications(r.Context(), id)
|
||||
view["applications"] = applications
|
||||
var note string
|
||||
_ = a.db.QueryRowContext(r.Context(), `SELECT moderation_note FROM pet_adoptions WHERE id=?`, id).Scan(¬e)
|
||||
view["moderationNote"] = note
|
||||
}
|
||||
reply(w, view)
|
||||
}
|
||||
|
||||
func (a *App) adoptionApplications(ctx context.Context, adoptionID int64) ([]map[string]any, error) {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT ap.id,ap.applicant_user_id,profile.nickname,profile.avatar_url,ap.answers_json,ap.message,ap.status,ap.created_at
|
||||
FROM pet_adoption_applications ap JOIN user_profiles profile ON profile.user_id=ap.applicant_user_id
|
||||
WHERE ap.adoption_id=? ORDER BY ap.id DESC`, adoptionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, userID int64
|
||||
var nickname, avatar, answers, message string
|
||||
var status int
|
||||
var created time.Time
|
||||
if rows.Scan(&id, &userID, &nickname, &avatar, &answers, &message, &status, &created) != nil {
|
||||
continue
|
||||
}
|
||||
parsed := map[string]string{}
|
||||
_ = json.Unmarshal([]byte(answers), &parsed)
|
||||
items = append(items, map[string]any{
|
||||
"id": id, "userId": userID, "nickname": nickname, "avatar": avatarThumbnailURL(avatar),
|
||||
"answers": parsed, "message": message, "status": status, "createdAt": created.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (a *App) applyAdoption(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "编号无效")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Answers map[string]string `json:"answers"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if decode(r, &req) != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "申请格式错误")
|
||||
return
|
||||
}
|
||||
for _, question := range adoptionQuestions {
|
||||
if strings.TrimSpace(req.Answers[question.Key]) == "" {
|
||||
fail(w, http.StatusBadRequest, 20001, "请回答:"+question.Label)
|
||||
return
|
||||
}
|
||||
if len([]rune(req.Answers[question.Key])) > 200 {
|
||||
fail(w, http.StatusBadRequest, 20001, "每个回答最多 200 字")
|
||||
return
|
||||
}
|
||||
}
|
||||
var owner int64
|
||||
var status, moderation int
|
||||
if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id,status,moderation_status FROM pet_adoptions WHERE id=? AND deleted_at IS NULL`, id).Scan(&owner, &status, &moderation) != nil {
|
||||
fail(w, http.StatusNotFound, 30001, "送养信息不存在")
|
||||
return
|
||||
}
|
||||
if owner == current(r).ID {
|
||||
fail(w, http.StatusBadRequest, 20001, "不能申请领养自己送养的宠物")
|
||||
return
|
||||
}
|
||||
if status != 1 || moderation != 1 {
|
||||
fail(w, http.StatusBadRequest, 20001, "这条送养信息已经不接受申请")
|
||||
return
|
||||
}
|
||||
answers, _ := json.Marshal(req.Answers)
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "提交失败")
|
||||
return
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if _, err = tx.ExecContext(r.Context(), `INSERT INTO pet_adoption_applications(adoption_id,applicant_user_id,answers_json,message)
|
||||
VALUES(?,?,?,?)`, id, current(r).ID, string(answers), strings.TrimSpace(req.Message)); err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "你已经申请过这条送养信息")
|
||||
return
|
||||
}
|
||||
if _, err = tx.ExecContext(r.Context(), `UPDATE pet_adoptions SET application_count=application_count+1 WHERE id=?`, id); err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "提交失败")
|
||||
return
|
||||
}
|
||||
if tx.Commit() != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "提交失败")
|
||||
return
|
||||
}
|
||||
a.notifyUser(r.Context(), owner, "system", "收到领养申请", "有人申请领养你送养的宠物,去看看是否合适", "adoption", id)
|
||||
reply(w, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
// decideAdoption is where a listing turns into a real handover: choosing one
|
||||
// applicant closes the listing, declines the rest and schedules the follow-ups.
|
||||
func (a *App) decideAdoption(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "编号无效")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ApplicationID int64 `json:"applicationId"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
if decode(r, &req) != nil || req.ApplicationID == 0 {
|
||||
fail(w, http.StatusBadRequest, 20001, "参数错误")
|
||||
return
|
||||
}
|
||||
var owner int64
|
||||
var status int
|
||||
if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id,status FROM pet_adoptions WHERE id=? AND deleted_at IS NULL`, id).Scan(&owner, &status) != nil || owner != current(r).ID {
|
||||
fail(w, http.StatusNotFound, 30001, "送养信息不存在")
|
||||
return
|
||||
}
|
||||
var applicant int64
|
||||
if a.db.QueryRowContext(r.Context(), `SELECT applicant_user_id FROM pet_adoption_applications WHERE id=? AND adoption_id=?`, req.ApplicationID, id).Scan(&applicant) != nil {
|
||||
fail(w, http.StatusNotFound, 30001, "申请不存在")
|
||||
return
|
||||
}
|
||||
if req.Action == "decline" {
|
||||
if _, err = a.db.ExecContext(r.Context(), `UPDATE pet_adoption_applications SET status=2,decided_at=NOW(3) WHERE id=? AND status=0`, req.ApplicationID); err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "操作失败")
|
||||
return
|
||||
}
|
||||
a.notifyUser(r.Context(), applicant, "system", "领养申请未通过", "送养人这次选择了其他领养人,可以再看看别的宠物", "adoption", id)
|
||||
reply(w, map[string]bool{"success": true})
|
||||
return
|
||||
}
|
||||
if status != 1 {
|
||||
fail(w, http.StatusBadRequest, 20001, "这条送养信息已经结束")
|
||||
return
|
||||
}
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "操作失败")
|
||||
return
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if _, err = tx.ExecContext(r.Context(), `UPDATE pet_adoption_applications SET status=1,decided_at=NOW(3) WHERE id=?`, req.ApplicationID); err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "操作失败")
|
||||
return
|
||||
}
|
||||
if _, err = tx.ExecContext(r.Context(), `UPDATE pet_adoption_applications SET status=2,decided_at=NOW(3) WHERE adoption_id=? AND id<>? AND status=0`, id, req.ApplicationID); err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "操作失败")
|
||||
return
|
||||
}
|
||||
if _, err = tx.ExecContext(r.Context(), `UPDATE pet_adoptions SET status=2,adopter_user_id=?,completed_at=NOW(3) WHERE id=?`, applicant, id); err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "操作失败")
|
||||
return
|
||||
}
|
||||
// Follow-ups are created now, with the handover, so nothing has to remember
|
||||
// to create them later.
|
||||
for _, day := range strings.Split(a.configPlain(r.Context(), "pet.adoption_followup_days", "30,90"), ",") {
|
||||
days, convErr := strconv.Atoi(strings.TrimSpace(day))
|
||||
if convErr != nil || days <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, err = tx.ExecContext(r.Context(), `INSERT INTO pet_adoption_followups(adoption_id,adopter_user_id,due_at) VALUES(?,?,DATE_ADD(NOW(3),INTERVAL ? DAY))`, id, applicant, days); err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "操作失败")
|
||||
return
|
||||
}
|
||||
}
|
||||
if tx.Commit() != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "操作失败")
|
||||
return
|
||||
}
|
||||
a.notifyUser(r.Context(), applicant, "system", "领养申请通过", "送养人选择了你,请尽快联系并当面确认", "adoption", id)
|
||||
reply(w, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
func (a *App) closeAdoption(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "编号无效")
|
||||
return
|
||||
}
|
||||
result, err := a.db.ExecContext(r.Context(), `UPDATE pet_adoptions SET status=0 WHERE id=? AND owner_user_id=? AND status=1`, id, current(r).ID)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "操作失败")
|
||||
return
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected == 0 {
|
||||
fail(w, http.StatusNotFound, 30001, "送养信息不存在或已结束")
|
||||
return
|
||||
}
|
||||
reply(w, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
func (a *App) myAdoptionFollowups(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.adoption_id,f.due_at,f.submitted_at,f.content,f.media_json,p.name,p.avatar_url
|
||||
FROM pet_adoption_followups f JOIN pet_adoptions a ON a.id=f.adoption_id JOIN pets p ON p.id=a.pet_id
|
||||
WHERE f.adopter_user_id=? ORDER BY f.submitted_at IS NOT NULL,f.due_at`, current(r).ID)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "查询回访失败")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, adoptionID int64
|
||||
var due time.Time
|
||||
var submitted sql.NullTime
|
||||
var content, petName, avatar string
|
||||
var mediaJSON sql.NullString
|
||||
if rows.Scan(&id, &adoptionID, &due, &submitted, &content, &mediaJSON, &petName, &avatar) != nil {
|
||||
continue
|
||||
}
|
||||
media := []string{}
|
||||
if mediaJSON.Valid {
|
||||
_ = json.Unmarshal([]byte(mediaJSON.String), &media)
|
||||
}
|
||||
items = append(items, map[string]any{
|
||||
"id": id, "adoptionId": adoptionID, "dueAt": due.Format(time.RFC3339),
|
||||
"submitted": submitted.Valid, "content": content, "media": media, "petName": petName, "avatar": avatar,
|
||||
"overdue": !submitted.Valid && time.Now().After(due),
|
||||
})
|
||||
}
|
||||
reply(w, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (a *App) submitAdoptionFollowup(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "编号无效")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
Media []string `json:"media"`
|
||||
}
|
||||
if decode(r, &req) != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "回访格式错误")
|
||||
return
|
||||
}
|
||||
req.Content = strings.TrimSpace(req.Content)
|
||||
if req.Content == "" && len(req.Media) == 0 {
|
||||
fail(w, http.StatusBadRequest, 20001, "写一句近况,或者上传一张照片")
|
||||
return
|
||||
}
|
||||
if len([]rune(req.Content)) > 500 || len(req.Media) > 9 {
|
||||
fail(w, http.StatusBadRequest, 20001, "内容过长或照片过多")
|
||||
return
|
||||
}
|
||||
for _, item := range req.Media {
|
||||
if !validMessageMediaURL(item, a.config.Environment == "production") {
|
||||
fail(w, http.StatusBadRequest, 20001, "照片地址无效")
|
||||
return
|
||||
}
|
||||
}
|
||||
media, _ := json.Marshal(req.Media)
|
||||
result, err := a.db.ExecContext(r.Context(), `UPDATE pet_adoption_followups SET content=?,media_json=?,submitted_at=NOW(3)
|
||||
WHERE id=? AND adopter_user_id=? AND submitted_at IS NULL`, req.Content, string(media), id, current(r).ID)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "提交失败")
|
||||
return
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected == 0 {
|
||||
fail(w, http.StatusNotFound, 30001, "回访不存在或已提交")
|
||||
return
|
||||
}
|
||||
reply(w, map[string]bool{"success": true})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,702 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The paid half of the module. The order of operations matters more than any
|
||||
// single rule here: money is taken before a sitter is chosen, the address is
|
||||
// revealed only after one is, the work leaves evidence, and the payout happens
|
||||
// once — from a ledger that can be audited afterwards.
|
||||
|
||||
const (
|
||||
feedVisitSteps = "arrive,feed,water,litter,leave"
|
||||
)
|
||||
|
||||
type feedTaskView struct {
|
||||
ID int64 `json:"id"`
|
||||
OwnerUserID int64 `json:"ownerUserId"`
|
||||
OwnerName string `json:"ownerName"`
|
||||
PetID int64 `json:"petId"`
|
||||
PetName string `json:"petName"`
|
||||
Species string `json:"species"`
|
||||
SpeciesLabel string `json:"speciesLabel"`
|
||||
PetAvatar string `json:"petAvatar"`
|
||||
CityName string `json:"cityName"`
|
||||
AreaText string `json:"areaText"`
|
||||
StartDate string `json:"startDate"`
|
||||
EndDate string `json:"endDate"`
|
||||
VisitsPerDay int `json:"visitsPerDay"`
|
||||
MinMinutes int `json:"minMinutes"`
|
||||
PriceCent int64 `json:"priceCent"`
|
||||
GrossCent int64 `json:"grossCent"`
|
||||
PayoutCent int64 `json:"payoutCent"`
|
||||
Note string `json:"note"`
|
||||
Status string `json:"status"`
|
||||
SitterUserID int64 `json:"sitterUserId"`
|
||||
SitterName string `json:"sitterName"`
|
||||
Applications int `json:"applicationsCount"`
|
||||
TotalVisits int `json:"totalVisits"`
|
||||
Done int `json:"completedVisits"`
|
||||
Items []string `json:"items"`
|
||||
Mine bool `json:"mine"`
|
||||
IsSitter bool `json:"isSitter"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
var feedItemKinds = map[string]string{
|
||||
"feed": "喂粮", "water": "换水", "litter": "铲砂", "walk": "遛狗", "medicine": "喂药",
|
||||
}
|
||||
|
||||
func (a *App) feedFeeSplit(ctx context.Context, gross int64) (fee int64, payout int64) {
|
||||
percent := int64(a.configInt(ctx, "pet.feed_platform_fee_percent", 15))
|
||||
if percent < 0 || percent > 50 {
|
||||
percent = 15
|
||||
}
|
||||
fee = gross * percent / 100
|
||||
return fee, gross - fee
|
||||
}
|
||||
|
||||
// --- 地址 -----------------------------------------------------------------
|
||||
|
||||
func (a *App) createPetAddress(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
CityCode string `json:"cityCode"`
|
||||
CityName string `json:"cityName"`
|
||||
AreaText string `json:"areaText"`
|
||||
Detail string `json:"detail"`
|
||||
Contact string `json:"contact"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
}
|
||||
if decode(r, &req) != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "地址格式错误")
|
||||
return
|
||||
}
|
||||
req.AreaText = strings.TrimSpace(req.AreaText)
|
||||
req.Detail = strings.TrimSpace(req.Detail)
|
||||
req.Contact = strings.TrimSpace(req.Contact)
|
||||
if req.AreaText == "" || req.Detail == "" || req.Contact == "" {
|
||||
fail(w, http.StatusBadRequest, 20001, "请填写完整的地址与联系方式")
|
||||
return
|
||||
}
|
||||
if len([]rune(req.AreaText)) > 100 || len([]rune(req.Detail)) > 200 || len([]rune(req.Contact)) > 100 {
|
||||
fail(w, http.StatusBadRequest, 20001, "地址内容过长")
|
||||
return
|
||||
}
|
||||
if req.Latitude == 0 || req.Longitude == 0 {
|
||||
fail(w, http.StatusBadRequest, 20001, "请先获取定位,打卡需要与地址比对")
|
||||
return
|
||||
}
|
||||
// Door number and phone are encrypted at rest; only the coarse area is ever
|
||||
// shown to people browsing the hall.
|
||||
detail, err := a.encryptPhone(req.Detail)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "保存失败")
|
||||
return
|
||||
}
|
||||
contact, err := a.encryptPhone(req.Contact)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "保存失败")
|
||||
return
|
||||
}
|
||||
result, err := a.db.ExecContext(r.Context(), `INSERT INTO pet_addresses(user_id,city_code,city_name,area_text,detail_cipher,contact_cipher,latitude,longitude)
|
||||
VALUES(?,?,?,?,?,?,?,?)`, current(r).ID, strings.TrimSpace(req.CityCode), strings.TrimSpace(req.CityName), req.AreaText, detail, contact, req.Latitude, req.Longitude)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "保存失败")
|
||||
return
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
reply(w, map[string]any{"id": id})
|
||||
}
|
||||
|
||||
func (a *App) petAddresses(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,city_name,area_text FROM pet_addresses WHERE user_id=? ORDER BY id DESC`, current(r).ID)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "查询地址失败")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var city, area string
|
||||
if rows.Scan(&id, &city, &area) == nil {
|
||||
items = append(items, map[string]any{"id": id, "cityName": city, "areaText": area})
|
||||
}
|
||||
}
|
||||
reply(w, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
// --- 喂养者 ---------------------------------------------------------------
|
||||
|
||||
func (a *App) sitterProfile(w http.ResponseWriter, r *http.Request) {
|
||||
var cityCode, cityName, intro, note string
|
||||
var radius, deposit, ratingTotal, ratingCount, completed, status int
|
||||
err := a.db.QueryRowContext(r.Context(), `SELECT city_code,city_name,radius_m,intro,deposit_cent,rating_total,rating_count,completed_count,status,status_note
|
||||
FROM pet_sitters WHERE user_id=?`, current(r).ID).Scan(&cityCode, &cityName, &radius, &intro, &deposit, &ratingTotal, &ratingCount, &completed, &status, ¬e)
|
||||
if err == sql.ErrNoRows {
|
||||
reply(w, map[string]any{"registered": false, "depositRequiredCent": a.configInt(r.Context(), "pet.sitter_deposit_cent", 20000)})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "查询失败")
|
||||
return
|
||||
}
|
||||
rating := 0.0
|
||||
if ratingCount > 0 {
|
||||
rating = math.Round(float64(ratingTotal)/float64(ratingCount)*10) / 10
|
||||
}
|
||||
reply(w, map[string]any{
|
||||
"registered": true, "cityCode": cityCode, "cityName": cityName, "radiusM": radius, "intro": intro,
|
||||
"depositCent": deposit, "depositRequiredCent": a.configInt(r.Context(), "pet.sitter_deposit_cent", 20000),
|
||||
"rating": rating, "ratingCount": ratingCount, "completedCount": completed,
|
||||
"status": status, "statusNote": note,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) applySitter(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
CityCode string `json:"cityCode"`
|
||||
CityName string `json:"cityName"`
|
||||
RadiusM int `json:"radiusM"`
|
||||
Intro string `json:"intro"`
|
||||
Certs []string `json:"certs"`
|
||||
}
|
||||
if decode(r, &req) != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "资料格式错误")
|
||||
return
|
||||
}
|
||||
req.Intro = strings.TrimSpace(req.Intro)
|
||||
if len([]rune(req.Intro)) < 10 || len([]rune(req.Intro)) > 500 {
|
||||
fail(w, http.StatusBadRequest, 20001, "请介绍一下你的养宠经验,10 到 500 字")
|
||||
return
|
||||
}
|
||||
if req.RadiusM < 1000 || req.RadiusM > 50000 {
|
||||
req.RadiusM = 5000
|
||||
}
|
||||
// Entering someone's home is not something an anonymous account may do.
|
||||
if _, verified := a.verifiedRealName(r.Context(), current(r).ID); !verified {
|
||||
fail(w, http.StatusForbidden, 30003, "上门服务需要先完成实名认证")
|
||||
return
|
||||
}
|
||||
certs, _ := json.Marshal(req.Certs)
|
||||
_, err := a.db.ExecContext(r.Context(), `INSERT INTO pet_sitters(user_id,city_code,city_name,radius_m,intro,certs_json,status)
|
||||
VALUES(?,?,?,?,?,?,0) ON DUPLICATE KEY UPDATE city_code=VALUES(city_code),city_name=VALUES(city_name),
|
||||
radius_m=VALUES(radius_m),intro=VALUES(intro),certs_json=VALUES(certs_json),status=IF(status=1,1,0),status_note=''`,
|
||||
current(r).ID, strings.TrimSpace(req.CityCode), strings.TrimSpace(req.CityName), req.RadiusM, req.Intro, string(certs))
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "提交失败")
|
||||
return
|
||||
}
|
||||
reply(w, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
// --- 任务 -----------------------------------------------------------------
|
||||
|
||||
func (a *App) createFeedTask(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.configBool(r.Context(), "pet.feed_enabled", true) {
|
||||
fail(w, http.StatusForbidden, 30002, "上门代喂暂未开放")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
PetID int64 `json:"petId"`
|
||||
AddressID int64 `json:"addressId"`
|
||||
StartDate string `json:"startDate"`
|
||||
EndDate string `json:"endDate"`
|
||||
VisitsPerDay int `json:"visitsPerDay"`
|
||||
MinMinutes int `json:"minMinutes"`
|
||||
PriceCent int64 `json:"priceCent"`
|
||||
Note string `json:"note"`
|
||||
Items []struct {
|
||||
Kind string `json:"kind"`
|
||||
Amount string `json:"amount"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if decode(r, &req) != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "任务格式错误")
|
||||
return
|
||||
}
|
||||
start, err := time.Parse("2006-01-02", strings.TrimSpace(req.StartDate))
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "开始日期格式应为 2006-01-02")
|
||||
return
|
||||
}
|
||||
end, err := time.Parse("2006-01-02", strings.TrimSpace(req.EndDate))
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "结束日期格式应为 2006-01-02")
|
||||
return
|
||||
}
|
||||
if end.Before(start) {
|
||||
fail(w, http.StatusBadRequest, 20001, "结束日期不能早于开始日期")
|
||||
return
|
||||
}
|
||||
days := int(end.Sub(start).Hours()/24) + 1
|
||||
// Beyond a couple of weeks this stops being "feed my cat while I travel" and
|
||||
// becomes boarding, which is a different product with a different licence.
|
||||
if maxDays := a.configInt(r.Context(), "pet.feed_max_days", 14); days > maxDays {
|
||||
fail(w, http.StatusBadRequest, 20001, fmt.Sprintf("单个任务最长 %d 天", maxDays))
|
||||
return
|
||||
}
|
||||
if req.VisitsPerDay < 1 || req.VisitsPerDay > 4 {
|
||||
fail(w, http.StatusBadRequest, 20001, "每天上门次数应在 1 到 4 之间")
|
||||
return
|
||||
}
|
||||
if req.MinMinutes < 10 || req.MinMinutes > 180 {
|
||||
req.MinMinutes = 20
|
||||
}
|
||||
minPrice := int64(a.configInt(r.Context(), "pet.feed_min_price_cent", 2000))
|
||||
maxPrice := int64(a.configInt(r.Context(), "pet.feed_max_price_cent", 50000))
|
||||
if req.PriceCent < minPrice || req.PriceCent > maxPrice {
|
||||
fail(w, http.StatusBadRequest, 20001, fmt.Sprintf("单次报酬应在 %.0f 到 %.0f 元之间", float64(minPrice)/100, float64(maxPrice)/100))
|
||||
return
|
||||
}
|
||||
if len(req.Items) == 0 {
|
||||
fail(w, http.StatusBadRequest, 20001, "请至少添加一项任务内容")
|
||||
return
|
||||
}
|
||||
for _, item := range req.Items {
|
||||
if _, ok := feedItemKinds[item.Kind]; !ok {
|
||||
fail(w, http.StatusBadRequest, 20001, "任务内容类型无效")
|
||||
return
|
||||
}
|
||||
}
|
||||
var cityCode string
|
||||
if a.db.QueryRowContext(r.Context(), `SELECT city_code FROM pet_addresses WHERE id=? AND user_id=?`, req.AddressID, current(r).ID).Scan(&cityCode) != nil {
|
||||
fail(w, http.StatusNotFound, 30001, "服务地址不存在")
|
||||
return
|
||||
}
|
||||
var petName string
|
||||
if a.db.QueryRowContext(r.Context(), `SELECT name FROM pets WHERE id=? AND owner_user_id=? AND status=1 AND deleted_at IS NULL`, req.PetID, current(r).ID).Scan(&petName) != nil {
|
||||
fail(w, http.StatusNotFound, 30001, "宠物不存在")
|
||||
return
|
||||
}
|
||||
totalVisits := days * req.VisitsPerDay
|
||||
gross := req.PriceCent * int64(totalVisits)
|
||||
fee, payout := a.feedFeeSplit(r.Context(), gross)
|
||||
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "创建失败")
|
||||
return
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
result, err := tx.ExecContext(r.Context(), `INSERT INTO pet_feed_tasks
|
||||
(owner_user_id,pet_id,address_id,city_code,start_date,end_date,visits_per_day,min_minutes,price_cent,gross_cent,platform_fee_cent,payout_cent,note,total_visits)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
current(r).ID, req.PetID, req.AddressID, cityCode, start, end, req.VisitsPerDay, req.MinMinutes,
|
||||
req.PriceCent, gross, fee, payout, strings.TrimSpace(req.Note), totalVisits)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "创建失败")
|
||||
return
|
||||
}
|
||||
taskID, _ := result.LastInsertId()
|
||||
for _, item := range req.Items {
|
||||
if _, err = tx.ExecContext(r.Context(), `INSERT INTO pet_feed_task_items(task_id,kind,amount_text) VALUES(?,?,?)`,
|
||||
taskID, item.Kind, strings.TrimSpace(item.Amount)); err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "创建失败")
|
||||
return
|
||||
}
|
||||
}
|
||||
// Every visit is planned up front, so both sides can see the schedule before
|
||||
// any money moves.
|
||||
for day := 0; day < days; day++ {
|
||||
date := start.AddDate(0, 0, day)
|
||||
for sequence := 1; sequence <= req.VisitsPerDay; sequence++ {
|
||||
if _, err = tx.ExecContext(r.Context(), `INSERT INTO pet_feed_visits(task_id,visit_date,sequence) VALUES(?,?,?)`, taskID, date, sequence); err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "创建失败")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if tx.Commit() != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "创建失败")
|
||||
return
|
||||
}
|
||||
reply(w, map[string]any{"id": taskID, "grossCent": gross, "platformFeeCent": fee, "payoutCent": payout, "totalVisits": totalVisits})
|
||||
}
|
||||
|
||||
// escrowFeedTask stands in for the payment callback: the task only becomes
|
||||
// visible to sitters once the money is held. Wiring it to the real gateway
|
||||
// replaces the body, not the state machine.
|
||||
func (a *App) escrowFeedTask(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "编号无效")
|
||||
return
|
||||
}
|
||||
result, err := a.db.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status='ESCROWED' WHERE id=? AND owner_user_id=? AND status='CREATED'`, id, current(r).ID)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "操作失败")
|
||||
return
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected == 0 {
|
||||
fail(w, http.StatusBadRequest, 20001, "任务状态不允许支付")
|
||||
return
|
||||
}
|
||||
reply(w, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
const feedTaskColumns = `t.id,t.owner_user_id,owner.nickname,t.pet_id,p.name,p.species,p.avatar_url,addr.city_name,addr.area_text,
|
||||
t.start_date,t.end_date,t.visits_per_day,t.min_minutes,t.price_cent,t.gross_cent,t.payout_cent,t.note,t.status,
|
||||
COALESCE(t.sitter_user_id,0),COALESCE(sitter.nickname,''),t.applications_count,t.total_visits,t.completed_visits,t.created_at`
|
||||
|
||||
func (a *App) scanFeedTasks(ctx context.Context, rows *sql.Rows, viewer int64) []feedTaskView {
|
||||
items := []feedTaskView{}
|
||||
ids := []any{}
|
||||
placeholders := []string{}
|
||||
for rows.Next() {
|
||||
var item feedTaskView
|
||||
var start, end time.Time
|
||||
var created time.Time
|
||||
if rows.Scan(&item.ID, &item.OwnerUserID, &item.OwnerName, &item.PetID, &item.PetName, &item.Species, &item.PetAvatar,
|
||||
&item.CityName, &item.AreaText, &start, &end, &item.VisitsPerDay, &item.MinMinutes, &item.PriceCent,
|
||||
&item.GrossCent, &item.PayoutCent, &item.Note, &item.Status, &item.SitterUserID, &item.SitterName,
|
||||
&item.Applications, &item.TotalVisits, &item.Done, &created) != nil {
|
||||
continue
|
||||
}
|
||||
item.SpeciesLabel = petSpecies[item.Species]
|
||||
item.StartDate = start.Format("2006-01-02")
|
||||
item.EndDate = end.Format("2006-01-02")
|
||||
item.CreatedAt = created.Format(time.RFC3339)
|
||||
item.Mine = item.OwnerUserID == viewer
|
||||
item.IsSitter = item.SitterUserID == viewer
|
||||
item.Items = []string{}
|
||||
items = append(items, item)
|
||||
ids = append(ids, item.ID)
|
||||
placeholders = append(placeholders, "?")
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return items
|
||||
}
|
||||
itemRows, err := a.db.QueryContext(ctx, `SELECT task_id,kind,amount_text FROM pet_feed_task_items WHERE task_id IN (`+strings.Join(placeholders, ",")+`) ORDER BY id`, ids...)
|
||||
if err == nil {
|
||||
defer itemRows.Close()
|
||||
for itemRows.Next() {
|
||||
var taskID int64
|
||||
var kind, amount string
|
||||
if itemRows.Scan(&taskID, &kind, &amount) != nil {
|
||||
continue
|
||||
}
|
||||
label := feedItemKinds[kind]
|
||||
if amount != "" {
|
||||
label += " " + amount
|
||||
}
|
||||
for index := range items {
|
||||
if items[index].ID == taskID {
|
||||
items[index].Items = append(items[index].Items, label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (a *App) feedTasks(w http.ResponseWriter, r *http.Request) {
|
||||
_, size, offset := pageOptions(r)
|
||||
scope := strings.TrimSpace(r.URL.Query().Get("scope"))
|
||||
where := ""
|
||||
args := []any{}
|
||||
switch scope {
|
||||
case "mine":
|
||||
where = " WHERE t.owner_user_id=?"
|
||||
args = append(args, current(r).ID)
|
||||
case "sitting":
|
||||
where = " WHERE t.sitter_user_id=?"
|
||||
args = append(args, current(r).ID)
|
||||
default:
|
||||
// The hall only shows funded, unassigned tasks: nobody should spend time
|
||||
// on a task that has not been paid for.
|
||||
where = " WHERE t.status='ESCROWED'"
|
||||
if city := strings.TrimSpace(r.URL.Query().Get("cityCode")); city != "" {
|
||||
where += " AND t.city_code=?"
|
||||
args = append(args, city)
|
||||
}
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT `+feedTaskColumns+` FROM pet_feed_tasks t
|
||||
JOIN pets p ON p.id=t.pet_id JOIN pet_addresses addr ON addr.id=t.address_id
|
||||
JOIN user_profiles owner ON owner.user_id=t.owner_user_id
|
||||
LEFT JOIN user_profiles sitter ON sitter.user_id=t.sitter_user_id`+where+` ORDER BY t.id DESC LIMIT ? OFFSET ?`,
|
||||
append(args, size+1, offset)...)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "查询任务失败")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := a.scanFeedTasks(r.Context(), rows, current(r).ID)
|
||||
hasMore := len(items) > size
|
||||
if hasMore {
|
||||
items = items[:size]
|
||||
}
|
||||
reply(w, map[string]any{"items": items, "hasMore": hasMore})
|
||||
}
|
||||
|
||||
func (a *App) feedTaskDetail(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "编号无效")
|
||||
return
|
||||
}
|
||||
rows, queryErr := a.db.QueryContext(r.Context(), `SELECT `+feedTaskColumns+` FROM pet_feed_tasks t
|
||||
JOIN pets p ON p.id=t.pet_id JOIN pet_addresses addr ON addr.id=t.address_id
|
||||
JOIN user_profiles owner ON owner.user_id=t.owner_user_id
|
||||
LEFT JOIN user_profiles sitter ON sitter.user_id=t.sitter_user_id WHERE t.id=?`, id)
|
||||
if queryErr != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "查询失败")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := a.scanFeedTasks(r.Context(), rows, current(r).ID)
|
||||
if len(items) == 0 {
|
||||
fail(w, http.StatusNotFound, 30001, "任务不存在")
|
||||
return
|
||||
}
|
||||
task := items[0]
|
||||
view := map[string]any{"task": task}
|
||||
// The exact address and phone number are released only to the person who has
|
||||
// to go there, and only once they have been chosen.
|
||||
if task.IsSitter && (task.Status == "ASSIGNED" || task.Status == "SERVING" || task.Status == "COMPLETED") {
|
||||
detail, contact := a.decryptAddress(r.Context(), task.ID)
|
||||
view["address"] = map[string]any{"detail": detail, "contact": contact}
|
||||
}
|
||||
if task.Mine {
|
||||
applications, _ := a.feedApplications(r.Context(), id)
|
||||
view["applications"] = applications
|
||||
}
|
||||
visits, _ := a.feedVisits(r.Context(), id)
|
||||
view["visits"] = visits
|
||||
reply(w, view)
|
||||
}
|
||||
|
||||
func (a *App) decryptAddress(ctx context.Context, taskID int64) (string, string) {
|
||||
var detailCipher, contactCipher []byte
|
||||
if a.db.QueryRowContext(ctx, `SELECT addr.detail_cipher,addr.contact_cipher FROM pet_feed_tasks t JOIN pet_addresses addr ON addr.id=t.address_id WHERE t.id=?`, taskID).
|
||||
Scan(&detailCipher, &contactCipher) != nil {
|
||||
return "", ""
|
||||
}
|
||||
detail, err := a.decryptPhone(detailCipher)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
contact, err := a.decryptPhone(contactCipher)
|
||||
if err != nil {
|
||||
return detail, ""
|
||||
}
|
||||
return detail, contact
|
||||
}
|
||||
|
||||
func (a *App) feedApplications(ctx context.Context, taskID int64) ([]map[string]any, error) {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT ap.id,ap.sitter_user_id,profile.nickname,profile.avatar_url,ap.message,ap.status,ap.created_at,
|
||||
COALESCE(s.completed_count,0),COALESCE(s.rating_total,0),COALESCE(s.rating_count,0)
|
||||
FROM pet_feed_applications ap JOIN user_profiles profile ON profile.user_id=ap.sitter_user_id
|
||||
LEFT JOIN pet_sitters s ON s.user_id=ap.sitter_user_id WHERE ap.task_id=? ORDER BY ap.id DESC`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, userID int64
|
||||
var nickname, avatar, message string
|
||||
var status, completed, ratingTotal, ratingCount int
|
||||
var created time.Time
|
||||
if rows.Scan(&id, &userID, &nickname, &avatar, &message, &status, &created, &completed, &ratingTotal, &ratingCount) != nil {
|
||||
continue
|
||||
}
|
||||
rating := 0.0
|
||||
if ratingCount > 0 {
|
||||
rating = math.Round(float64(ratingTotal)/float64(ratingCount)*10) / 10
|
||||
}
|
||||
items = append(items, map[string]any{
|
||||
"id": id, "sitterUserId": userID, "nickname": nickname, "avatar": avatarThumbnailURL(avatar),
|
||||
"message": message, "status": status, "completedCount": completed, "rating": rating,
|
||||
"createdAt": created.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (a *App) feedVisits(ctx context.Context, taskID int64) ([]map[string]any, error) {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id,visit_date,sequence,started_at,finished_at,status,abnormal FROM pet_feed_visits WHERE task_id=? ORDER BY visit_date,sequence`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
visits := []map[string]any{}
|
||||
ids := []any{}
|
||||
placeholders := []string{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var date time.Time
|
||||
var sequence, status, abnormal int
|
||||
var started, finished sql.NullTime
|
||||
if rows.Scan(&id, &date, &sequence, &started, &finished, &status, &abnormal) != nil {
|
||||
continue
|
||||
}
|
||||
visit := map[string]any{
|
||||
"id": id, "date": date.Format("2006-01-02"), "sequence": sequence, "status": status,
|
||||
"abnormal": abnormal == 1, "checkins": []map[string]any{},
|
||||
}
|
||||
if started.Valid {
|
||||
visit["startedAt"] = started.Time.Format(time.RFC3339)
|
||||
}
|
||||
if finished.Valid {
|
||||
visit["finishedAt"] = finished.Time.Format(time.RFC3339)
|
||||
}
|
||||
visits = append(visits, visit)
|
||||
ids = append(ids, id)
|
||||
placeholders = append(placeholders, "?")
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return visits, nil
|
||||
}
|
||||
checkinRows, err := a.db.QueryContext(ctx, `SELECT visit_id,step,media_url,distance_m,captured_at,verdict FROM pet_feed_checkins WHERE visit_id IN (`+strings.Join(placeholders, ",")+`) ORDER BY id`, ids...)
|
||||
if err != nil {
|
||||
return visits, nil
|
||||
}
|
||||
defer checkinRows.Close()
|
||||
for checkinRows.Next() {
|
||||
var visitID int64
|
||||
var step, media, verdict string
|
||||
var distance sql.NullInt64
|
||||
var captured time.Time
|
||||
if checkinRows.Scan(&visitID, &step, &media, &distance, &captured, &verdict) != nil {
|
||||
continue
|
||||
}
|
||||
for index := range visits {
|
||||
if visits[index]["id"] == visitID {
|
||||
list := visits[index]["checkins"].([]map[string]any)
|
||||
visits[index]["checkins"] = append(list, map[string]any{
|
||||
"step": step, "media": media, "distanceM": distance.Int64,
|
||||
"capturedAt": captured.Format(time.RFC3339), "verdict": verdict,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return visits, nil
|
||||
}
|
||||
|
||||
func (a *App) applyFeedTask(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "编号无效")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if decode(r, &req) != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "格式错误")
|
||||
return
|
||||
}
|
||||
var status int
|
||||
var deposit int
|
||||
if a.db.QueryRowContext(r.Context(), `SELECT status,deposit_cent FROM pet_sitters WHERE user_id=?`, current(r).ID).Scan(&status, &deposit) != nil {
|
||||
fail(w, http.StatusForbidden, 30003, "请先申请成为喂养者")
|
||||
return
|
||||
}
|
||||
if status != 1 {
|
||||
fail(w, http.StatusForbidden, 30003, "喂养者资格审核通过后才能接单")
|
||||
return
|
||||
}
|
||||
if required := a.configInt(r.Context(), "pet.sitter_deposit_cent", 20000); deposit < required {
|
||||
fail(w, http.StatusForbidden, 30003, fmt.Sprintf("请先缴纳 %.0f 元保证金", float64(required)/100))
|
||||
return
|
||||
}
|
||||
var owner int64
|
||||
var taskStatus string
|
||||
if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id,status FROM pet_feed_tasks WHERE id=?`, id).Scan(&owner, &taskStatus) != nil {
|
||||
fail(w, http.StatusNotFound, 30001, "任务不存在")
|
||||
return
|
||||
}
|
||||
if owner == current(r).ID {
|
||||
fail(w, http.StatusBadRequest, 20001, "不能接自己发布的任务")
|
||||
return
|
||||
}
|
||||
if taskStatus != "ESCROWED" {
|
||||
fail(w, http.StatusBadRequest, 20001, "任务已经不接受报名")
|
||||
return
|
||||
}
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "报名失败")
|
||||
return
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if _, err = tx.ExecContext(r.Context(), `INSERT INTO pet_feed_applications(task_id,sitter_user_id,message) VALUES(?,?,?)`,
|
||||
id, current(r).ID, strings.TrimSpace(req.Message)); err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "你已经报名过这个任务")
|
||||
return
|
||||
}
|
||||
if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET applications_count=applications_count+1 WHERE id=?`, id); err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "报名失败")
|
||||
return
|
||||
}
|
||||
if tx.Commit() != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "报名失败")
|
||||
return
|
||||
}
|
||||
a.notifyUser(r.Context(), owner, "system", "有人接单了", "有喂养者报名了你的代喂任务,去看看是否合适", "feed_task", id)
|
||||
reply(w, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
func (a *App) assignFeedTask(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "编号无效")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ApplicationID int64 `json:"applicationId"`
|
||||
}
|
||||
if decode(r, &req) != nil || req.ApplicationID == 0 {
|
||||
fail(w, http.StatusBadRequest, 20001, "参数错误")
|
||||
return
|
||||
}
|
||||
var owner int64
|
||||
var status string
|
||||
if a.db.QueryRowContext(r.Context(), `SELECT owner_user_id,status FROM pet_feed_tasks WHERE id=?`, id).Scan(&owner, &status) != nil || owner != current(r).ID {
|
||||
fail(w, http.StatusNotFound, 30001, "任务不存在")
|
||||
return
|
||||
}
|
||||
if status != "ESCROWED" {
|
||||
fail(w, http.StatusBadRequest, 20001, "任务状态不允许选定")
|
||||
return
|
||||
}
|
||||
var sitter int64
|
||||
if a.db.QueryRowContext(r.Context(), `SELECT sitter_user_id FROM pet_feed_applications WHERE id=? AND task_id=?`, req.ApplicationID, id).Scan(&sitter) != nil {
|
||||
fail(w, http.StatusNotFound, 30001, "报名不存在")
|
||||
return
|
||||
}
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "操作失败")
|
||||
return
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_applications SET status=1 WHERE id=?`, req.ApplicationID); err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "操作失败")
|
||||
return
|
||||
}
|
||||
if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_applications SET status=2 WHERE task_id=? AND id<>? AND status=0`, id, req.ApplicationID); err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "操作失败")
|
||||
return
|
||||
}
|
||||
if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status='ASSIGNED',sitter_user_id=? WHERE id=? AND status='ESCROWED'`, sitter, id); err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "操作失败")
|
||||
return
|
||||
}
|
||||
if tx.Commit() != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "操作失败")
|
||||
return
|
||||
}
|
||||
a.notifyUser(r.Context(), sitter, "system", "你被选中了", "主人选择了你,任务详情里现在可以看到门牌与联系方式", "feed_task", id)
|
||||
reply(w, map[string]bool{"success": true})
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Operations side of the paid module: who may take orders, which tasks are
|
||||
// stuck, and who gets the money when the two sides disagree.
|
||||
|
||||
func (a *App) adminSitters(w http.ResponseWriter, r *http.Request) {
|
||||
_, size, offset := pagination(r)
|
||||
where := " WHERE 1=1"
|
||||
args := []any{}
|
||||
if status := strings.TrimSpace(r.URL.Query().Get("status")); status != "" {
|
||||
where += " AND s.status=?"
|
||||
args = append(args, status)
|
||||
}
|
||||
if keyword := strings.TrimSpace(r.URL.Query().Get("keyword")); keyword != "" {
|
||||
where += " AND (profile.nickname LIKE ? OR s.user_id=?)"
|
||||
args = append(args, "%"+keyword+"%", keyword)
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT s.user_id,profile.nickname,profile.avatar_url,s.city_name,s.radius_m,s.intro,s.certs_json,
|
||||
s.deposit_cent,s.rating_total,s.rating_count,s.completed_count,s.cancelled_count,s.status,s.status_note,s.created_at
|
||||
FROM pet_sitters s JOIN user_profiles profile ON profile.user_id=s.user_id`+where+` ORDER BY s.status ASC,s.user_id DESC LIMIT ? OFFSET ?`,
|
||||
append(args, size, offset)...)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "查询喂养者失败")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var userID int64
|
||||
var nickname, avatar, city, intro, note string
|
||||
var certs sql.NullString
|
||||
var radius, deposit, ratingTotal, ratingCount, completed, cancelled, status int
|
||||
var created time.Time
|
||||
if rows.Scan(&userID, &nickname, &avatar, &city, &radius, &intro, &certs, &deposit,
|
||||
&ratingTotal, &ratingCount, &completed, &cancelled, &status, ¬e, &created) != nil {
|
||||
continue
|
||||
}
|
||||
rating := 0.0
|
||||
if ratingCount > 0 {
|
||||
rating = float64(ratingTotal) / float64(ratingCount)
|
||||
}
|
||||
items = append(items, map[string]any{
|
||||
"userId": userID, "nickname": nickname, "avatar": avatarThumbnailURL(avatar), "cityName": city,
|
||||
"radiusM": radius, "intro": intro, "certs": certs.String, "depositCent": deposit,
|
||||
"rating": rating, "ratingCount": ratingCount, "completedCount": completed, "cancelledCount": cancelled,
|
||||
"status": status, "statusNote": note, "createdAt": created.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
reply(w, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (a *App) adminReviewSitter(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "编号无效")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Status int `json:"status"`
|
||||
Note string `json:"note"`
|
||||
DepositCent *int `json:"depositCent"`
|
||||
}
|
||||
if decode(r, &req) != nil || req.Status < 1 || req.Status > 3 {
|
||||
fail(w, http.StatusBadRequest, 20001, "状态无效")
|
||||
return
|
||||
}
|
||||
req.Note = strings.TrimSpace(req.Note)
|
||||
// Turning someone down without saying why generates a support ticket that
|
||||
// nobody can answer.
|
||||
if req.Status != 1 && req.Note == "" {
|
||||
fail(w, http.StatusBadRequest, 20001, "驳回或停用时请填写原因")
|
||||
return
|
||||
}
|
||||
if req.DepositCent != nil {
|
||||
if _, err = a.db.ExecContext(r.Context(), `UPDATE pet_sitters SET deposit_cent=? WHERE user_id=?`, *req.DepositCent, id); err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "操作失败")
|
||||
return
|
||||
}
|
||||
}
|
||||
result, err := a.db.ExecContext(r.Context(), `UPDATE pet_sitters SET status=?,status_note=? WHERE user_id=?`, req.Status, req.Note, id)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "操作失败")
|
||||
return
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected == 0 {
|
||||
fail(w, http.StatusNotFound, 30001, "喂养者不存在")
|
||||
return
|
||||
}
|
||||
title := "喂养者资格已通过"
|
||||
content := "你现在可以在代喂大厅接单了"
|
||||
if req.Status != 1 {
|
||||
title = "喂养者资格未通过"
|
||||
content = req.Note
|
||||
}
|
||||
a.notifyUser(r.Context(), id, "system", title, content, "sitter", id)
|
||||
a.audit(r, "review_sitter", "pet_sitter", id, map[string]any{"status": req.Status, "note": req.Note})
|
||||
reply(w, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
func (a *App) adminFeedTasks(w http.ResponseWriter, r *http.Request) {
|
||||
_, size, offset := pagination(r)
|
||||
where := " WHERE 1=1"
|
||||
args := []any{}
|
||||
if status := strings.TrimSpace(r.URL.Query().Get("status")); status != "" {
|
||||
where += " AND t.status=?"
|
||||
args = append(args, status)
|
||||
}
|
||||
if city := strings.TrimSpace(r.URL.Query().Get("cityCode")); city != "" {
|
||||
where += " AND t.city_code=?"
|
||||
args = append(args, city)
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT t.id,t.owner_user_id,owner.nickname,COALESCE(t.sitter_user_id,0),COALESCE(sitter.nickname,''),
|
||||
p.name,addr.city_name,t.start_date,t.end_date,t.gross_cent,t.platform_fee_cent,t.payout_cent,t.status,
|
||||
t.completed_visits,t.total_visits,t.confirm_due_at,t.settled_at,t.created_at
|
||||
FROM pet_feed_tasks t JOIN pets p ON p.id=t.pet_id JOIN pet_addresses addr ON addr.id=t.address_id
|
||||
JOIN user_profiles owner ON owner.user_id=t.owner_user_id
|
||||
LEFT JOIN user_profiles sitter ON sitter.user_id=t.sitter_user_id`+where+` ORDER BY t.id DESC LIMIT ? OFFSET ?`,
|
||||
append(args, size, offset)...)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "查询任务失败")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, ownerID, sitterID int64
|
||||
var ownerName, sitterName, petName, cityName, status string
|
||||
var gross, fee, payout int64
|
||||
var done, total int
|
||||
var start, end, created time.Time
|
||||
var confirmDue, settled sql.NullTime
|
||||
if rows.Scan(&id, &ownerID, &ownerName, &sitterID, &sitterName, &petName, &cityName, &start, &end,
|
||||
&gross, &fee, &payout, &status, &done, &total, &confirmDue, &settled, &created) != nil {
|
||||
continue
|
||||
}
|
||||
item := map[string]any{
|
||||
"id": id, "ownerUserId": ownerID, "ownerName": ownerName, "sitterUserId": sitterID, "sitterName": sitterName,
|
||||
"petName": petName, "cityName": cityName, "startDate": start.Format("2006-01-02"), "endDate": end.Format("2006-01-02"),
|
||||
"grossCent": gross, "platformFeeCent": fee, "payoutCent": payout, "status": status,
|
||||
"completedVisits": done, "totalVisits": total, "createdAt": created.Format(time.RFC3339),
|
||||
}
|
||||
if confirmDue.Valid {
|
||||
item["confirmDueAt"] = confirmDue.Time.Format(time.RFC3339)
|
||||
}
|
||||
if settled.Valid {
|
||||
item["settledAt"] = settled.Time.Format(time.RFC3339)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
reply(w, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (a *App) adminFeedDisputes(w http.ResponseWriter, r *http.Request) {
|
||||
_, size, offset := pagination(r)
|
||||
where := ""
|
||||
args := []any{}
|
||||
if status := strings.TrimSpace(r.URL.Query().Get("status")); status != "" {
|
||||
where = " WHERE d.status=?"
|
||||
args = append(args, status)
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT d.id,d.task_id,d.raised_by,profile.nickname,d.reason,d.evidence_json,d.status,d.verdict,
|
||||
d.refund_cent,d.payout_cent,d.handled_note,d.handled_at,d.created_at,t.gross_cent,t.payout_cent,t.status,
|
||||
t.owner_user_id,COALESCE(t.sitter_user_id,0),t.completed_visits,t.total_visits
|
||||
FROM pet_feed_disputes d JOIN pet_feed_tasks t ON t.id=d.task_id
|
||||
JOIN user_profiles profile ON profile.user_id=d.raised_by`+where+` ORDER BY d.status ASC,d.id DESC LIMIT ? OFFSET ?`,
|
||||
append(args, size, offset)...)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "查询申诉失败")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, taskID, raisedBy, ownerID, sitterID int64
|
||||
var nickname, reason, verdict, note, taskStatus string
|
||||
var evidence sql.NullString
|
||||
var status int
|
||||
var refund, payout, gross, taskPayout int64
|
||||
var done, total int
|
||||
var handled sql.NullTime
|
||||
var created time.Time
|
||||
if rows.Scan(&id, &taskID, &raisedBy, &nickname, &reason, &evidence, &status, &verdict, &refund, &payout,
|
||||
¬e, &handled, &created, &gross, &taskPayout, &taskStatus, &ownerID, &sitterID, &done, &total) != nil {
|
||||
continue
|
||||
}
|
||||
item := map[string]any{
|
||||
"id": id, "taskId": taskID, "raisedBy": raisedBy, "raisedByName": nickname, "reason": reason,
|
||||
"evidence": evidence.String, "status": status, "verdict": verdict, "refundCent": refund, "payoutCent": payout,
|
||||
"handledNote": note, "createdAt": created.Format(time.RFC3339), "grossCent": gross, "taskPayoutCent": taskPayout,
|
||||
"taskStatus": taskStatus, "ownerUserId": ownerID, "sitterUserId": sitterID,
|
||||
"completedVisits": done, "totalVisits": total,
|
||||
}
|
||||
if handled.Valid {
|
||||
item["handledAt"] = handled.Time.Format(time.RFC3339)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
reply(w, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
// adminHandleFeedDispute is where a human decides who was right. The three
|
||||
// verdicts cover what actually happens: the sitter did the work (payout), the
|
||||
// sitter never showed (refund), or some visits happened and some did not
|
||||
// (split, with the operator naming the amount).
|
||||
func (a *App) adminHandleFeedDispute(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "编号无效")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Verdict string `json:"verdict"`
|
||||
PayoutCent int64 `json:"payoutCent"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if decode(r, &req) != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "格式错误")
|
||||
return
|
||||
}
|
||||
req.Note = strings.TrimSpace(req.Note)
|
||||
if len([]rune(req.Note)) < 5 {
|
||||
fail(w, http.StatusBadRequest, 20001, "请写明裁定理由,双方都会看到")
|
||||
return
|
||||
}
|
||||
var taskID, sitter int64
|
||||
var status int
|
||||
var taskPayout, gross int64
|
||||
var taskStatus string
|
||||
if a.db.QueryRowContext(r.Context(), `SELECT d.task_id,d.status,COALESCE(t.sitter_user_id,0),t.payout_cent,t.gross_cent,t.status
|
||||
FROM pet_feed_disputes d JOIN pet_feed_tasks t ON t.id=d.task_id WHERE d.id=?`, id).
|
||||
Scan(&taskID, &status, &sitter, &taskPayout, &gross, &taskStatus) != nil {
|
||||
fail(w, http.StatusNotFound, 30001, "申诉不存在")
|
||||
return
|
||||
}
|
||||
if status != 0 {
|
||||
fail(w, http.StatusBadRequest, 20001, "这条申诉已经裁定过了")
|
||||
return
|
||||
}
|
||||
|
||||
var payout int64
|
||||
switch req.Verdict {
|
||||
case "payout":
|
||||
payout = taskPayout
|
||||
case "refund":
|
||||
payout = 0
|
||||
case "split":
|
||||
payout = req.PayoutCent
|
||||
if payout <= 0 || payout > taskPayout {
|
||||
fail(w, http.StatusBadRequest, 20001, "部分支付金额应大于 0 且不超过服务报酬")
|
||||
return
|
||||
}
|
||||
default:
|
||||
fail(w, http.StatusBadRequest, 20001, "裁定结果无效")
|
||||
return
|
||||
}
|
||||
refund := gross - payout
|
||||
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "操作失败")
|
||||
return
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_disputes SET status=1,verdict=?,refund_cent=?,payout_cent=?,handled_by=?,handled_note=?,handled_at=NOW(3)
|
||||
WHERE id=? AND status=0`, req.Verdict, refund, payout, current(r).ID, req.Note, id); err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "操作失败")
|
||||
return
|
||||
}
|
||||
if payout > 0 {
|
||||
if err = bookLedger(r.Context(), tx, walletEntry{
|
||||
UserID: sitter, BizType: "feed_settle", BizID: taskID, Direction: 1,
|
||||
AmountCen: payout, Memo: "代喂服务收入(客服裁定)",
|
||||
}); err != nil && err != errLedgerDuplicate {
|
||||
fail(w, http.StatusInternalServerError, 50001, "结算失败")
|
||||
return
|
||||
}
|
||||
if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status='SETTLED',settled_at=NOW(3),payout_cent=? WHERE id=?`, payout, taskID); err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "操作失败")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status='REFUNDED' WHERE id=?`, taskID); err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "操作失败")
|
||||
return
|
||||
}
|
||||
}
|
||||
if tx.Commit() != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "操作失败")
|
||||
return
|
||||
}
|
||||
var owner int64
|
||||
_ = a.db.QueryRowContext(r.Context(), `SELECT owner_user_id FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&owner)
|
||||
for _, party := range []int64{owner, sitter} {
|
||||
if party > 0 {
|
||||
a.notifyUser(r.Context(), party, "system", "代喂申诉已裁定", req.Note, "feed_task", taskID)
|
||||
}
|
||||
}
|
||||
a.audit(r, "handle_feed_dispute", "pet_feed_dispute", id, map[string]any{
|
||||
"verdict": req.Verdict, "payoutCent": payout, "refundCent": refund, "note": req.Note,
|
||||
})
|
||||
reply(w, map[string]any{"success": true, "payoutCent": payout, "refundCent": refund})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user