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