领养是信息服务:没有金额字段,文案里出现收费话术或短期内连续送养都会转人工, 送出时按 30/90 天建好回访任务。配种同样只做信息展示,且默认关闭。 代喂是这个模块里唯一动钱的部分,顺序是它的安全边界:先付款再进大厅,选定 之后才解密门牌与电话,每次上门按到达/喂食/离开留照片,主人确认或超时自动确认 后才结算。结算只走账本,(user, biz_type, biz_id) 唯一键让重复结算付不出第二次; 申诉会冻结自动确认,由客服裁定全付、部分付或全退。 提现按方案默认关闭:收益先只记账,等分账通道与资质到位再在管理端开闸。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
703 lines
26 KiB
Go
703 lines
26 KiB
Go
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})
|
|
}
|