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}) }