代喂改走真实订单,并补上打卡纪律与地址明文清除

支付原来是个占位:调一下接口任务就算"已付"。现在任务和喂养者保证金都
走会员那套订单与支付网关,一笔订单买到什么由 applyPaidOrderTx 统一决定,
沙箱和真实回调走同一段逻辑。退款能释放托管中的任务,但不会去动已经结算
的那一笔——钱已经在喂养者账上,那种情况必须有人看过再说。

通道接通之前,客服可以在管理端手工标记已托管,必须写明钱怎么收的,进审计日志。

另外补上三件方案里写了但代码没做的事:打卡必须按到达、喂食、离开的顺序,
喂食离到达太近视为摆拍;地址新增紧急联系人与常去医院,随门牌一起只发给
被选中的喂养者;任务结束七天后由后台任务抹掉地址明文,只留城市与街道。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-07 09:07:38 +08:00
co-authored by Claude Opus 5
parent 97351777ec
commit 9345805133
9 changed files with 889 additions and 81 deletions
+110 -50
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"strings"
@@ -42,6 +43,7 @@ type feedTaskView struct {
Status string `json:"status"`
SitterUserID int64 `json:"sitterUserId"`
SitterName string `json:"sitterName"`
Conversation int64 `json:"conversationId"`
Applications int `json:"applicationsCount"`
TotalVisits int `json:"totalVisits"`
Done int `json:"completedVisits"`
@@ -68,13 +70,15 @@ func (a *App) feedFeeSplit(ctx context.Context, gross int64) (fee int64, payout
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"`
CityCode string `json:"cityCode"`
CityName string `json:"cityName"`
AreaText string `json:"areaText"`
Detail string `json:"detail"`
Contact string `json:"contact"`
Emergency string `json:"emergency"`
VetHospital string `json:"vetHospital"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "地址格式错误")
@@ -83,11 +87,14 @@ func (a *App) createPetAddress(w http.ResponseWriter, r *http.Request) {
req.AreaText = strings.TrimSpace(req.AreaText)
req.Detail = strings.TrimSpace(req.Detail)
req.Contact = strings.TrimSpace(req.Contact)
req.Emergency = strings.TrimSpace(req.Emergency)
req.VetHospital = strings.TrimSpace(req.VetHospital)
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 {
if len([]rune(req.AreaText)) > 100 || len([]rune(req.Detail)) > 200 || len([]rune(req.Contact)) > 100 ||
len([]rune(req.Emergency)) > 100 || len([]rune(req.VetHospital)) > 100 {
fail(w, http.StatusBadRequest, 20001, "地址内容过长")
return
}
@@ -107,8 +114,17 @@ func (a *App) createPetAddress(w http.ResponseWriter, r *http.Request) {
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)
// 紧急联系人是选填的,但填了就和门牌一样加密。
var emergency []byte
if req.Emergency != "" {
if emergency, err = a.encryptPhone(req.Emergency); 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,emergency_cipher,vet_hospital,latitude,longitude)
VALUES(?,?,?,?,?,?,?,?,?,?)`, current(r).ID, strings.TrimSpace(req.CityCode), strings.TrimSpace(req.CityName), req.AreaText,
detail, contact, emergency, req.VetHospital, req.Latitude, req.Longitude)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "保存失败")
return
@@ -269,10 +285,16 @@ func (a *App) createFeedTask(w http.ResponseWriter, r *http.Request) {
}
}
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, "服务地址不存在")
var purgeAt sql.NullTime
if a.db.QueryRowContext(r.Context(), `SELECT city_code,purge_at FROM pet_addresses WHERE id=? AND user_id=? AND LENGTH(detail_cipher)>0`,
req.AddressID, current(r).ID).Scan(&cityCode, &purgeAt) != nil {
fail(w, http.StatusNotFound, 30001, "服务地址不存在,请重新填写")
return
}
// 又用上了就别再排队清除。
if purgeAt.Valid {
_, _ = a.db.ExecContext(r.Context(), `UPDATE pet_addresses SET purge_at=NULL WHERE id=?`, req.AddressID)
}
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, "宠物不存在")
@@ -323,30 +345,9 @@ func (a *App) createFeedTask(w http.ResponseWriter, r *http.Request) {
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`
COALESCE(t.sitter_user_id,0),COALESCE(sitter.nickname,''),COALESCE(t.conversation_id,0),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{}
@@ -359,7 +360,7 @@ func (a *App) scanFeedTasks(ctx context.Context, rows *sql.Rows, viewer int64) [
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 {
&item.Conversation, &item.Applications, &item.TotalVisits, &item.Done, &created) != nil {
continue
}
item.SpeciesLabel = petSpecies[item.Species]
@@ -463,8 +464,8 @@ func (a *App) feedTaskDetail(w http.ResponseWriter, r *http.Request) {
// 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}
address := a.decryptAddress(r.Context(), task.ID)
view["address"] = address
}
if task.Mine {
applications, _ := a.feedApplications(r.Context(), id)
@@ -475,21 +476,31 @@ func (a *App) feedTaskDetail(w http.ResponseWriter, r *http.Request) {
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 "", ""
// decryptAddress hands the door number, the phone number and the emergency
// contact to the one person who has to go there. An address whose plaintext has
// already been purged comes back empty rather than as an error: by then the
// task is long over.
func (a *App) decryptAddress(ctx context.Context, taskID int64) map[string]any {
var detailCipher, contactCipher, emergencyCipher []byte
var hospital string
if a.db.QueryRowContext(ctx, `SELECT addr.detail_cipher,addr.contact_cipher,addr.emergency_cipher,addr.vet_hospital
FROM pet_feed_tasks t JOIN pet_addresses addr ON addr.id=t.address_id WHERE t.id=?`, taskID).
Scan(&detailCipher, &contactCipher, &emergencyCipher, &hospital) != nil {
return map[string]any{}
}
detail, err := a.decryptPhone(detailCipher)
if err != nil {
return "", ""
address := map[string]any{"vetHospital": hospital}
if detail, err := a.decryptPhone(detailCipher); err == nil {
address["detail"] = detail
}
contact, err := a.decryptPhone(contactCipher)
if err != nil {
return detail, ""
if contact, err := a.decryptPhone(contactCipher); err == nil {
address["contact"] = contact
}
return detail, contact
if len(emergencyCipher) > 0 {
if emergency, err := a.decryptPhone(emergencyCipher); err == nil {
address["emergency"] = emergency
}
}
return address
}
func (a *App) feedApplications(ctx context.Context, taskID int64) ([]map[string]any, error) {
@@ -697,6 +708,55 @@ func (a *App) assignFeedTask(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusInternalServerError, 50001, "操作失败")
return
}
// 双方的沟通要留在站内:出了纠纷,聊天记录和打卡照片是同一套证据。
conversationID, convErr := a.bindTaskConversation(r.Context(), id, current(r).ID, sitter)
if convErr != nil {
log.Printf("pet feed: 绑定会话失败 task=%d: %v", id, convErr)
}
a.notifyUser(r.Context(), sitter, "system", "你被选中了", "主人选择了你,任务详情里现在可以看到门牌与联系方式", "feed_task", id)
reply(w, map[string]bool{"success": true})
reply(w, map[string]any{"success": true, "conversationId": conversationID})
}
// bindTaskConversation makes sure the two sides have a direct conversation and
// remembers it on the task. It deliberately skips the stranger and block rules
// of the normal chat entry point: these two have a contract with each other,
// and the platform needs the exchange to happen where it can be reviewed.
func (a *App) bindTaskConversation(ctx context.Context, taskID, owner, sitter int64) (int64, error) {
first, second := owner, sitter
if first > second {
first, second = second, first
}
var id int64
err := a.db.QueryRowContext(ctx, `SELECT conversation_id FROM im_direct_conversations WHERE user1_id=? AND user2_id=?`, first, second).Scan(&id)
if err != nil {
tx, beginErr := a.db.BeginTx(ctx, nil)
if beginErr != nil {
return 0, beginErr
}
defer func() { _ = tx.Rollback() }()
result, execErr := tx.ExecContext(ctx, `INSERT INTO im_conversations(conversation_type)VALUES(1)`)
if execErr != nil {
return 0, execErr
}
id, _ = result.LastInsertId()
if _, execErr = tx.ExecContext(ctx, `INSERT INTO im_direct_conversations(conversation_id,user1_id,user2_id)VALUES(?,?,?)`, id, first, second); execErr != nil {
// 并发下另一边可能刚建好,直接用那一条。
if a.db.QueryRowContext(ctx, `SELECT conversation_id FROM im_direct_conversations WHERE user1_id=? AND user2_id=?`, first, second).Scan(&id) != nil {
return 0, execErr
}
return id, a.rememberTaskConversation(ctx, taskID, id)
}
if _, execErr = tx.ExecContext(ctx, `INSERT INTO im_conversation_members(conversation_id,user_id)VALUES(?,?),(?,?)`, id, first, id, second); execErr != nil {
return 0, execErr
}
if execErr = tx.Commit(); execErr != nil {
return 0, execErr
}
}
return id, a.rememberTaskConversation(ctx, taskID, id)
}
func (a *App) rememberTaskConversation(ctx context.Context, taskID, conversationID int64) error {
_, err := a.db.ExecContext(ctx, `UPDATE pet_feed_tasks SET conversation_id=? WHERE id=?`, conversationID, taskID)
return err
}