Files
kefu/im/backend/internal/app/pet_feed_admin.go
T
Your NameandClaude Opus 5 46a60ffac3 宠物板块二到四期:领养、配种、上门代喂与收益账户
领养是信息服务:没有金额字段,文案里出现收费话术或短期内连续送养都会转人工,
送出时按 30/90 天建好回访任务。配种同样只做信息展示,且默认关闭。

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 08:14:19 +08:00

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, &note, &created) != nil {
continue
}
rating := 0.0
if ratingCount > 0 {
rating = float64(ratingTotal) / float64(ratingCount)
}
items = append(items, map[string]any{
"userId": userID, "nickname": nickname, "avatar": avatarThumbnailURL(avatar), "cityName": city,
"radiusM": radius, "intro": intro, "certs": certs.String, "depositCent": deposit,
"rating": rating, "ratingCount": ratingCount, "completedCount": completed, "cancelledCount": cancelled,
"status": status, "statusNote": note, "createdAt": created.Format(time.RFC3339),
})
}
reply(w, map[string]any{"items": items})
}
func (a *App) adminReviewSitter(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "编号无效")
return
}
var req struct {
Status int `json:"status"`
Note string `json:"note"`
DepositCent *int `json:"depositCent"`
}
if decode(r, &req) != nil || req.Status < 1 || req.Status > 3 {
fail(w, http.StatusBadRequest, 20001, "状态无效")
return
}
req.Note = strings.TrimSpace(req.Note)
// Turning someone down without saying why generates a support ticket that
// nobody can answer.
if req.Status != 1 && req.Note == "" {
fail(w, http.StatusBadRequest, 20001, "驳回或停用时请填写原因")
return
}
if req.DepositCent != nil {
if _, err = a.db.ExecContext(r.Context(), `UPDATE pet_sitters SET deposit_cent=? WHERE user_id=?`, *req.DepositCent, id); err != nil {
fail(w, http.StatusInternalServerError, 50001, "操作失败")
return
}
}
result, err := a.db.ExecContext(r.Context(), `UPDATE pet_sitters SET status=?,status_note=? WHERE user_id=?`, req.Status, req.Note, id)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "操作失败")
return
}
if affected, _ := result.RowsAffected(); affected == 0 {
fail(w, http.StatusNotFound, 30001, "喂养者不存在")
return
}
title := "喂养者资格已通过"
content := "你现在可以在代喂大厅接单了"
if req.Status != 1 {
title = "喂养者资格未通过"
content = req.Note
}
a.notifyUser(r.Context(), id, "system", title, content, "sitter", id)
a.audit(r, "review_sitter", "pet_sitter", id, map[string]any{"status": req.Status, "note": req.Note})
reply(w, map[string]bool{"success": true})
}
func (a *App) adminFeedTasks(w http.ResponseWriter, r *http.Request) {
_, size, offset := pagination(r)
where := " WHERE 1=1"
args := []any{}
if status := strings.TrimSpace(r.URL.Query().Get("status")); status != "" {
where += " AND t.status=?"
args = append(args, status)
}
if city := strings.TrimSpace(r.URL.Query().Get("cityCode")); city != "" {
where += " AND t.city_code=?"
args = append(args, city)
}
rows, err := a.db.QueryContext(r.Context(), `SELECT t.id,t.owner_user_id,owner.nickname,COALESCE(t.sitter_user_id,0),COALESCE(sitter.nickname,''),
p.name,addr.city_name,t.start_date,t.end_date,t.gross_cent,t.platform_fee_cent,t.payout_cent,t.status,
t.completed_visits,t.total_visits,t.confirm_due_at,t.settled_at,t.created_at
FROM pet_feed_tasks t JOIN pets p ON p.id=t.pet_id JOIN pet_addresses addr ON addr.id=t.address_id
JOIN user_profiles owner ON owner.user_id=t.owner_user_id
LEFT JOIN user_profiles sitter ON sitter.user_id=t.sitter_user_id`+where+` ORDER BY t.id DESC LIMIT ? OFFSET ?`,
append(args, size, offset)...)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "查询任务失败")
return
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var id, ownerID, sitterID int64
var ownerName, sitterName, petName, cityName, status string
var gross, fee, payout int64
var done, total int
var start, end, created time.Time
var confirmDue, settled sql.NullTime
if rows.Scan(&id, &ownerID, &ownerName, &sitterID, &sitterName, &petName, &cityName, &start, &end,
&gross, &fee, &payout, &status, &done, &total, &confirmDue, &settled, &created) != nil {
continue
}
item := map[string]any{
"id": id, "ownerUserId": ownerID, "ownerName": ownerName, "sitterUserId": sitterID, "sitterName": sitterName,
"petName": petName, "cityName": cityName, "startDate": start.Format("2006-01-02"), "endDate": end.Format("2006-01-02"),
"grossCent": gross, "platformFeeCent": fee, "payoutCent": payout, "status": status,
"completedVisits": done, "totalVisits": total, "createdAt": created.Format(time.RFC3339),
}
if confirmDue.Valid {
item["confirmDueAt"] = confirmDue.Time.Format(time.RFC3339)
}
if settled.Valid {
item["settledAt"] = settled.Time.Format(time.RFC3339)
}
items = append(items, item)
}
reply(w, map[string]any{"items": items})
}
func (a *App) adminFeedDisputes(w http.ResponseWriter, r *http.Request) {
_, size, offset := pagination(r)
where := ""
args := []any{}
if status := strings.TrimSpace(r.URL.Query().Get("status")); status != "" {
where = " WHERE d.status=?"
args = append(args, status)
}
rows, err := a.db.QueryContext(r.Context(), `SELECT d.id,d.task_id,d.raised_by,profile.nickname,d.reason,d.evidence_json,d.status,d.verdict,
d.refund_cent,d.payout_cent,d.handled_note,d.handled_at,d.created_at,t.gross_cent,t.payout_cent,t.status,
t.owner_user_id,COALESCE(t.sitter_user_id,0),t.completed_visits,t.total_visits
FROM pet_feed_disputes d JOIN pet_feed_tasks t ON t.id=d.task_id
JOIN user_profiles profile ON profile.user_id=d.raised_by`+where+` ORDER BY d.status ASC,d.id DESC LIMIT ? OFFSET ?`,
append(args, size, offset)...)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "查询申诉失败")
return
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var id, taskID, raisedBy, ownerID, sitterID int64
var nickname, reason, verdict, note, taskStatus string
var evidence sql.NullString
var status int
var refund, payout, gross, taskPayout int64
var done, total int
var handled sql.NullTime
var created time.Time
if rows.Scan(&id, &taskID, &raisedBy, &nickname, &reason, &evidence, &status, &verdict, &refund, &payout,
&note, &handled, &created, &gross, &taskPayout, &taskStatus, &ownerID, &sitterID, &done, &total) != nil {
continue
}
item := map[string]any{
"id": id, "taskId": taskID, "raisedBy": raisedBy, "raisedByName": nickname, "reason": reason,
"evidence": evidence.String, "status": status, "verdict": verdict, "refundCent": refund, "payoutCent": payout,
"handledNote": note, "createdAt": created.Format(time.RFC3339), "grossCent": gross, "taskPayoutCent": taskPayout,
"taskStatus": taskStatus, "ownerUserId": ownerID, "sitterUserId": sitterID,
"completedVisits": done, "totalVisits": total,
}
if handled.Valid {
item["handledAt"] = handled.Time.Format(time.RFC3339)
}
items = append(items, item)
}
reply(w, map[string]any{"items": items})
}
// adminHandleFeedDispute is where a human decides who was right. The three
// verdicts cover what actually happens: the sitter did the work (payout), the
// sitter never showed (refund), or some visits happened and some did not
// (split, with the operator naming the amount).
func (a *App) adminHandleFeedDispute(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "编号无效")
return
}
var req struct {
Verdict string `json:"verdict"`
PayoutCent int64 `json:"payoutCent"`
Note string `json:"note"`
}
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "格式错误")
return
}
req.Note = strings.TrimSpace(req.Note)
if len([]rune(req.Note)) < 5 {
fail(w, http.StatusBadRequest, 20001, "请写明裁定理由,双方都会看到")
return
}
var taskID, sitter int64
var status int
var taskPayout, gross int64
var taskStatus string
if a.db.QueryRowContext(r.Context(), `SELECT d.task_id,d.status,COALESCE(t.sitter_user_id,0),t.payout_cent,t.gross_cent,t.status
FROM pet_feed_disputes d JOIN pet_feed_tasks t ON t.id=d.task_id WHERE d.id=?`, id).
Scan(&taskID, &status, &sitter, &taskPayout, &gross, &taskStatus) != nil {
fail(w, http.StatusNotFound, 30001, "申诉不存在")
return
}
if status != 0 {
fail(w, http.StatusBadRequest, 20001, "这条申诉已经裁定过了")
return
}
var payout int64
switch req.Verdict {
case "payout":
payout = taskPayout
case "refund":
payout = 0
case "split":
payout = req.PayoutCent
if payout <= 0 || payout > taskPayout {
fail(w, http.StatusBadRequest, 20001, "部分支付金额应大于 0 且不超过服务报酬")
return
}
default:
fail(w, http.StatusBadRequest, 20001, "裁定结果无效")
return
}
refund := gross - payout
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "操作失败")
return
}
defer func() { _ = tx.Rollback() }()
if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_disputes SET status=1,verdict=?,refund_cent=?,payout_cent=?,handled_by=?,handled_note=?,handled_at=NOW(3)
WHERE id=? AND status=0`, req.Verdict, refund, payout, current(r).ID, req.Note, id); err != nil {
fail(w, http.StatusInternalServerError, 50001, "操作失败")
return
}
if payout > 0 {
if err = bookLedger(r.Context(), tx, walletEntry{
UserID: sitter, BizType: "feed_settle", BizID: taskID, Direction: 1,
AmountCen: payout, Memo: "代喂服务收入(客服裁定)",
}); err != nil && err != errLedgerDuplicate {
fail(w, http.StatusInternalServerError, 50001, "结算失败")
return
}
if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status='SETTLED',settled_at=NOW(3),payout_cent=? WHERE id=?`, payout, taskID); err != nil {
fail(w, http.StatusInternalServerError, 50001, "操作失败")
return
}
} else {
if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status='REFUNDED' WHERE id=?`, taskID); err != nil {
fail(w, http.StatusInternalServerError, 50001, "操作失败")
return
}
}
if tx.Commit() != nil {
fail(w, http.StatusInternalServerError, 50001, "操作失败")
return
}
var owner int64
_ = a.db.QueryRowContext(r.Context(), `SELECT owner_user_id FROM pet_feed_tasks WHERE id=?`, taskID).Scan(&owner)
for _, party := range []int64{owner, sitter} {
if party > 0 {
a.notifyUser(r.Context(), party, "system", "代喂申诉已裁定", req.Note, "feed_task", taskID)
}
}
a.audit(r, "handle_feed_dispute", "pet_feed_dispute", id, map[string]any{
"verdict": req.Verdict, "payoutCent": payout, "refundCent": refund, "note": req.Note,
})
reply(w, map[string]any{"success": true, "payoutCent": payout, "refundCent": refund})
}