领养是信息服务:没有金额字段,文案里出现收费话术或短期内连续送养都会转人工, 送出时按 30/90 天建好回访任务。配种同样只做信息展示,且默认关闭。 代喂是这个模块里唯一动钱的部分,顺序是它的安全边界:先付款再进大厅,选定 之后才解密门牌与电话,每次上门按到达/喂食/离开留照片,主人确认或超时自动确认 后才结算。结算只走账本,(user, biz_type, biz_id) 唯一键让重复结算付不出第二次; 申诉会冻结自动确认,由客服裁定全付、部分付或全退。 提现按方案默认关闭:收益先只记账,等分账通道与资质到位再在管理端开闸。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
456 lines
18 KiB
Go
456 lines
18 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// The earnings ledger. Two rules hold everything else up:
|
|
//
|
|
// 1. Every balance change writes a transaction in the same database
|
|
// transaction, so the balance is always the sum of the ledger.
|
|
// 2. A business event can only be booked once. The unique key on
|
|
// (user, biz_type, biz_id) makes a double settlement impossible rather
|
|
// than merely unlikely — a retried job cannot pay twice.
|
|
//
|
|
// This account holds money owed to the user and can only ever leave through a
|
|
// withdrawal. It is deliberately not convertible back into anything spendable
|
|
// inside the app.
|
|
|
|
type walletEntry struct {
|
|
UserID int64
|
|
BizType string
|
|
BizID int64
|
|
Direction int
|
|
AmountCen int64
|
|
Memo string
|
|
}
|
|
|
|
var errLedgerDuplicate = fmt.Errorf("这笔账已经记过")
|
|
|
|
// bookLedger moves money and records why, inside the caller's transaction.
|
|
// Direction 1 credits available balance, -1 debits it.
|
|
func bookLedger(ctx context.Context, tx *sql.Tx, entry walletEntry) error {
|
|
if entry.AmountCen <= 0 {
|
|
return fmt.Errorf("金额必须大于 0")
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `INSERT IGNORE INTO wallet_accounts(user_id) VALUES(?)`, entry.UserID); err != nil {
|
|
return err
|
|
}
|
|
var available, frozen int64
|
|
if err := tx.QueryRowContext(ctx, `SELECT available_cent,frozen_cent FROM wallet_accounts WHERE user_id=? FOR UPDATE`, entry.UserID).Scan(&available, &frozen); err != nil {
|
|
return err
|
|
}
|
|
delta := entry.AmountCen * int64(entry.Direction)
|
|
if available+delta < 0 {
|
|
return fmt.Errorf("余额不足")
|
|
}
|
|
after := available + delta
|
|
// The unique key is the guard: a repeated settlement fails here instead of
|
|
// silently paying a second time.
|
|
if _, err := tx.ExecContext(ctx, `INSERT INTO wallet_transactions(user_id,biz_type,biz_id,direction,amount_cent,balance_after_cent,memo)
|
|
VALUES(?,?,?,?,?,?,?)`, entry.UserID, entry.BizType, entry.BizID, entry.Direction, entry.AmountCen, after, entry.Memo); err != nil {
|
|
if isDuplicateKeyError(err) {
|
|
return errLedgerDuplicate
|
|
}
|
|
return err
|
|
}
|
|
income := int64(0)
|
|
if entry.Direction > 0 {
|
|
income = entry.AmountCen
|
|
}
|
|
withdrawn := int64(0)
|
|
if entry.Direction < 0 {
|
|
withdrawn = entry.AmountCen
|
|
}
|
|
_, err := tx.ExecContext(ctx, `UPDATE wallet_accounts SET available_cent=?,total_income_cent=total_income_cent+?,total_withdraw_cent=total_withdraw_cent+? WHERE user_id=?`,
|
|
after, income, withdrawn, entry.UserID)
|
|
return err
|
|
}
|
|
|
|
func isDuplicateKeyError(err error) bool {
|
|
return err != nil && strings.Contains(strings.ToLower(err.Error()), "duplicate entry")
|
|
}
|
|
|
|
func (a *App) walletSummary(w http.ResponseWriter, r *http.Request) {
|
|
var available, frozen, income, withdrawn int64
|
|
err := a.db.QueryRowContext(r.Context(), `SELECT available_cent,frozen_cent,total_income_cent,total_withdraw_cent FROM wallet_accounts WHERE user_id=?`, current(r).ID).
|
|
Scan(&available, &frozen, &income, &withdrawn)
|
|
if err != nil && err != sql.ErrNoRows {
|
|
fail(w, http.StatusInternalServerError, 50001, "查询余额失败")
|
|
return
|
|
}
|
|
var pending int64
|
|
_ = a.db.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(amount_cent),0) FROM withdrawals WHERE user_id=? AND status IN ('PENDING','APPROVED')`, current(r).ID).Scan(&pending)
|
|
reply(w, map[string]any{
|
|
"availableCent": available, "frozenCent": frozen, "totalIncomeCent": income,
|
|
"totalWithdrawCent": withdrawn, "pendingWithdrawCent": pending,
|
|
"withdrawEnabled": a.configBool(r.Context(), "pet.withdraw_enabled", false),
|
|
"minWithdrawCent": a.configInt(r.Context(), "pet.withdraw_min_cent", 1000),
|
|
})
|
|
}
|
|
|
|
func (a *App) walletTransactions(w http.ResponseWriter, r *http.Request) {
|
|
_, size, offset := pageOptions(r)
|
|
rows, err := a.db.QueryContext(r.Context(), `SELECT id,biz_type,biz_id,direction,amount_cent,balance_after_cent,memo,created_at
|
|
FROM wallet_transactions WHERE user_id=? ORDER BY id DESC LIMIT ? OFFSET ?`, current(r).ID, size+1, offset)
|
|
if err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "查询流水失败")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
items := []map[string]any{}
|
|
for rows.Next() {
|
|
var id, bizID, amount, balance int64
|
|
var bizType, memo string
|
|
var direction int
|
|
var created time.Time
|
|
if rows.Scan(&id, &bizType, &bizID, &direction, &amount, &balance, &memo, &created) != nil {
|
|
continue
|
|
}
|
|
items = append(items, map[string]any{
|
|
"id": id, "bizType": bizType, "bizId": bizID, "direction": direction,
|
|
"amountCent": amount, "balanceAfterCent": balance, "memo": memo,
|
|
"createdAt": created.Format(time.RFC3339),
|
|
})
|
|
}
|
|
hasMore := len(items) > size
|
|
if hasMore {
|
|
items = items[:size]
|
|
}
|
|
reply(w, map[string]any{"items": items, "hasMore": hasMore})
|
|
}
|
|
|
|
// --- 提现账户 -------------------------------------------------------------
|
|
|
|
func (a *App) payoutAccounts(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := a.db.QueryContext(r.Context(), `SELECT id,kind,account_mask,holder_name,bank_name FROM payout_accounts WHERE user_id=? AND status=1 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 kind, mask, holder, bank string
|
|
if rows.Scan(&id, &kind, &mask, &holder, &bank) != nil {
|
|
continue
|
|
}
|
|
items = append(items, map[string]any{"id": id, "kind": kind, "accountMask": mask, "holderName": holder, "bankName": bank})
|
|
}
|
|
reply(w, map[string]any{"items": items})
|
|
}
|
|
|
|
func (a *App) addPayoutAccount(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Kind string `json:"kind"`
|
|
Account string `json:"account"`
|
|
Holder string `json:"holder"`
|
|
BankName string `json:"bankName"`
|
|
}
|
|
if decode(r, &req) != nil {
|
|
fail(w, http.StatusBadRequest, 20001, "收款账户格式错误")
|
|
return
|
|
}
|
|
req.Kind = strings.TrimSpace(req.Kind)
|
|
req.Account = strings.TrimSpace(req.Account)
|
|
req.Holder = strings.TrimSpace(req.Holder)
|
|
if req.Kind != "alipay" && req.Kind != "bank" {
|
|
fail(w, http.StatusBadRequest, 20001, "请选择收款方式")
|
|
return
|
|
}
|
|
if len(req.Account) < 6 || len(req.Account) > 64 || req.Holder == "" || len([]rune(req.Holder)) > 50 {
|
|
fail(w, http.StatusBadRequest, 20001, "请填写完整的收款账号与姓名")
|
|
return
|
|
}
|
|
// The name has to match the verified identity: paying a stranger on someone
|
|
// else's behalf is exactly the pattern money laundering rules exist for.
|
|
realName, verified := a.verifiedRealName(r.Context(), current(r).ID)
|
|
if !verified {
|
|
fail(w, http.StatusForbidden, 30003, "请先完成实名认证")
|
|
return
|
|
}
|
|
if realName != "" && realName != req.Holder {
|
|
fail(w, http.StatusBadRequest, 20001, "收款人姓名必须与实名认证一致")
|
|
return
|
|
}
|
|
cipher, err := a.encryptPhone(req.Account)
|
|
if err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "保存失败")
|
|
return
|
|
}
|
|
result, err := a.db.ExecContext(r.Context(), `INSERT INTO payout_accounts(user_id,kind,account_cipher,account_mask,holder_name,bank_name) VALUES(?,?,?,?,?,?)`,
|
|
current(r).ID, req.Kind, cipher, maskAccount(req.Account), req.Holder, strings.TrimSpace(req.BankName))
|
|
if err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "保存失败")
|
|
return
|
|
}
|
|
id, _ := result.LastInsertId()
|
|
reply(w, map[string]any{"id": id})
|
|
}
|
|
|
|
func maskAccount(account string) string {
|
|
runes := []rune(account)
|
|
if len(runes) <= 4 {
|
|
return "****"
|
|
}
|
|
if len(runes) <= 8 {
|
|
return "****" + string(runes[len(runes)-4:])
|
|
}
|
|
return string(runes[:3]) + "****" + string(runes[len(runes)-4:])
|
|
}
|
|
|
|
// The payout name must match the verified identity, so both come from the same
|
|
// row. status is the string the verification flow writes, not a code.
|
|
func (a *App) verifiedRealName(ctx context.Context, userID int64) (string, bool) {
|
|
var name sql.NullString
|
|
var status string
|
|
if a.db.QueryRowContext(ctx, `SELECT real_name,status FROM user_verifications WHERE user_id=? AND verification_type='real_name'`, userID).Scan(&name, &status) != nil {
|
|
return "", false
|
|
}
|
|
return strings.TrimSpace(name.String), strings.EqualFold(status, "VERIFIED")
|
|
}
|
|
|
|
// --- 提现 -----------------------------------------------------------------
|
|
|
|
func (a *App) createWithdrawal(w http.ResponseWriter, r *http.Request) {
|
|
if !a.configBool(r.Context(), "pet.withdraw_enabled", false) {
|
|
fail(w, http.StatusForbidden, 30002, "提现通道尚未开放,收益会一直保留在账户里")
|
|
return
|
|
}
|
|
var req struct {
|
|
AmountCent int64 `json:"amountCent"`
|
|
AccountID int64 `json:"accountId"`
|
|
}
|
|
if decode(r, &req) != nil || req.AccountID == 0 {
|
|
fail(w, http.StatusBadRequest, 20001, "提现参数错误")
|
|
return
|
|
}
|
|
minCent := int64(a.configInt(r.Context(), "pet.withdraw_min_cent", 1000))
|
|
if req.AmountCent < minCent {
|
|
fail(w, http.StatusBadRequest, 20001, fmt.Sprintf("单笔提现不少于 %.2f 元", float64(minCent)/100))
|
|
return
|
|
}
|
|
if _, verified := a.verifiedRealName(r.Context(), current(r).ID); !verified {
|
|
fail(w, http.StatusForbidden, 30003, "请先完成实名认证")
|
|
return
|
|
}
|
|
var accountOwner int64
|
|
if a.db.QueryRowContext(r.Context(), `SELECT user_id FROM payout_accounts WHERE id=? AND status=1`, req.AccountID).Scan(&accountOwner) != nil || accountOwner != current(r).ID {
|
|
fail(w, http.StatusNotFound, 30001, "收款账户不存在")
|
|
return
|
|
}
|
|
var todayTotal int64
|
|
_ = a.db.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(amount_cent),0) FROM withdrawals
|
|
WHERE user_id=? AND status<>'REJECTED' AND created_at>=CURDATE()`, current(r).ID).Scan(&todayTotal)
|
|
if limit := int64(a.configInt(r.Context(), "pet.withdraw_daily_limit_cent", 500000)); limit > 0 && todayTotal+req.AmountCent > limit {
|
|
fail(w, http.StatusBadRequest, 20001, fmt.Sprintf("超过单日提现上限 %.2f 元", float64(limit)/100))
|
|
return
|
|
}
|
|
fee := req.AmountCent * int64(a.configInt(r.Context(), "pet.withdraw_fee_percent", 0)) / 100
|
|
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 withdrawals(user_id,account_id,amount_cent,fee_cent,payout_cent) VALUES(?,?,?,?,?)`,
|
|
current(r).ID, req.AccountID, req.AmountCent, fee, req.AmountCent-fee)
|
|
if err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "提交失败")
|
|
return
|
|
}
|
|
withdrawalID, _ := result.LastInsertId()
|
|
// The money leaves the available balance now, so it cannot be withdrawn
|
|
// twice while the first request is still being reviewed.
|
|
if err = bookLedger(r.Context(), tx, walletEntry{
|
|
UserID: current(r).ID, BizType: "withdraw", BizID: withdrawalID, Direction: -1,
|
|
AmountCen: req.AmountCent, Memo: "提现申请",
|
|
}); err != nil {
|
|
if err.Error() == "余额不足" {
|
|
fail(w, http.StatusBadRequest, 20001, "可提现余额不足")
|
|
return
|
|
}
|
|
fail(w, http.StatusInternalServerError, 50001, "提交失败")
|
|
return
|
|
}
|
|
if tx.Commit() != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "提交失败")
|
|
return
|
|
}
|
|
reply(w, map[string]any{"id": withdrawalID, "status": "PENDING"})
|
|
}
|
|
|
|
func (a *App) myWithdrawals(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := a.db.QueryContext(r.Context(), `SELECT w.id,w.amount_cent,w.fee_cent,w.payout_cent,w.status,w.failed_reason,w.created_at,p.account_mask,p.kind
|
|
FROM withdrawals w JOIN payout_accounts p ON p.id=w.account_id WHERE w.user_id=? ORDER BY w.id DESC LIMIT 50`, current(r).ID)
|
|
if err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "查询提现记录失败")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
items := []map[string]any{}
|
|
for rows.Next() {
|
|
var id, amount, fee, payout int64
|
|
var status, reason, mask, kind string
|
|
var created time.Time
|
|
if rows.Scan(&id, &amount, &fee, &payout, &status, &reason, &created, &mask, &kind) != nil {
|
|
continue
|
|
}
|
|
items = append(items, map[string]any{
|
|
"id": id, "amountCent": amount, "feeCent": fee, "payoutCent": payout, "status": status,
|
|
"failedReason": reason, "accountMask": mask, "kind": kind, "createdAt": created.Format(time.RFC3339),
|
|
})
|
|
}
|
|
reply(w, map[string]any{"items": items})
|
|
}
|
|
|
|
// --- 管理端 ---------------------------------------------------------------
|
|
|
|
func (a *App) adminWithdrawals(w http.ResponseWriter, r *http.Request) {
|
|
page, size, offset := pagination(r)
|
|
where := " WHERE 1=1"
|
|
args := []any{}
|
|
if status := strings.TrimSpace(r.URL.Query().Get("status")); status != "" {
|
|
where += " AND w.status=?"
|
|
args = append(args, status)
|
|
}
|
|
var total int
|
|
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM withdrawals w`+where, args...).Scan(&total)
|
|
rows, err := a.db.QueryContext(r.Context(), `SELECT w.id,w.user_id,profile.nickname,u.public_id,w.amount_cent,w.fee_cent,w.tax_cent,w.payout_cent,
|
|
w.status,w.failed_reason,w.created_at,p.kind,p.account_mask,p.holder_name,p.bank_name,
|
|
COALESCE(wa.available_cent,0),COALESCE(wa.total_income_cent,0)
|
|
FROM withdrawals w JOIN users u ON u.id=w.user_id JOIN user_profiles profile ON profile.user_id=w.user_id
|
|
JOIN payout_accounts p ON p.id=w.account_id LEFT JOIN wallet_accounts wa ON wa.user_id=w.user_id`+where+
|
|
` ORDER BY w.status='PENDING' DESC,w.id DESC LIMIT ? OFFSET ?`, append(append([]any{}, args...), size, offset)...)
|
|
if err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "查询提现失败")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
items := []map[string]any{}
|
|
for rows.Next() {
|
|
var id, userID, amount, fee, tax, payout, available, income int64
|
|
var nickname, publicID, status, reason, kind, mask, holder, bank string
|
|
var created time.Time
|
|
if rows.Scan(&id, &userID, &nickname, &publicID, &amount, &fee, &tax, &payout, &status, &reason, &created,
|
|
&kind, &mask, &holder, &bank, &available, &income) != nil {
|
|
continue
|
|
}
|
|
items = append(items, map[string]any{
|
|
"id": id, "userId": userID, "nickname": nickname, "publicId": publicID,
|
|
"amountCent": amount, "feeCent": fee, "taxCent": tax, "payoutCent": payout,
|
|
"status": status, "failedReason": reason, "kind": kind, "accountMask": mask,
|
|
"holderName": holder, "bankName": bank, "availableCent": available, "totalIncomeCent": income,
|
|
"createdAt": created.Format(time.RFC3339),
|
|
})
|
|
}
|
|
reply(w, map[string]any{"items": items, "total": total, "page": page, "size": size})
|
|
}
|
|
|
|
// adminHandleWithdrawal is deliberately manual. Until a payout channel is
|
|
// connected, an operator marks a transfer they made themselves; a rejection
|
|
// returns the money to the user's balance through the ledger, never by editing
|
|
// the balance directly.
|
|
func (a *App) adminHandleWithdrawal(w http.ResponseWriter, r *http.Request) {
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
fail(w, http.StatusBadRequest, 20001, "编号无效")
|
|
return
|
|
}
|
|
var req struct {
|
|
Action string `json:"action"`
|
|
Reason string `json:"reason"`
|
|
OrderNo string `json:"orderNo"`
|
|
TaxCent int64 `json:"taxCent"`
|
|
}
|
|
if decode(r, &req) != nil {
|
|
fail(w, http.StatusBadRequest, 20001, "参数错误")
|
|
return
|
|
}
|
|
var userID, amount int64
|
|
var status string
|
|
if a.db.QueryRowContext(r.Context(), `SELECT user_id,amount_cent,status FROM withdrawals WHERE id=?`, id).Scan(&userID, &amount, &status) != nil {
|
|
fail(w, http.StatusNotFound, 30001, "提现单不存在")
|
|
return
|
|
}
|
|
if status != "PENDING" && status != "APPROVED" {
|
|
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() }()
|
|
switch strings.TrimSpace(req.Action) {
|
|
case "approve":
|
|
_, err = tx.ExecContext(r.Context(), `UPDATE withdrawals SET status='APPROVED',handled_by=?,handled_at=NOW(3) WHERE id=?`, current(r).ID, id)
|
|
case "paid":
|
|
_, err = tx.ExecContext(r.Context(), `UPDATE withdrawals SET status='PAID',provider_order_no=?,tax_cent=?,payout_cent=amount_cent-fee_cent-?,handled_by=?,handled_at=NOW(3) WHERE id=?`,
|
|
strings.TrimSpace(req.OrderNo), req.TaxCent, req.TaxCent, current(r).ID, id)
|
|
case "reject", "fail":
|
|
reason := strings.TrimSpace(req.Reason)
|
|
if reason == "" {
|
|
fail(w, http.StatusBadRequest, 20001, "驳回时必须填写原因")
|
|
return
|
|
}
|
|
newStatus := "REJECTED"
|
|
if req.Action == "fail" {
|
|
newStatus = "FAILED"
|
|
}
|
|
if _, err = tx.ExecContext(r.Context(), `UPDATE withdrawals SET status=?,failed_reason=?,handled_by=?,handled_at=NOW(3) WHERE id=?`,
|
|
newStatus, reason, current(r).ID, id); err == nil {
|
|
err = bookLedger(r.Context(), tx, walletEntry{
|
|
UserID: userID, BizType: "withdraw_refund", BizID: id, Direction: 1,
|
|
AmountCen: amount, Memo: "提现未成功,金额退回:" + reason,
|
|
})
|
|
}
|
|
default:
|
|
fail(w, http.StatusBadRequest, 20001, "操作无效")
|
|
return
|
|
}
|
|
if err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "操作失败")
|
|
return
|
|
}
|
|
if tx.Commit() != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "操作失败")
|
|
return
|
|
}
|
|
a.audit(r, "withdrawal_"+req.Action, "withdrawal", id, map[string]any{"amountCent": amount, "reason": req.Reason})
|
|
reply(w, map[string]bool{"success": true})
|
|
}
|
|
|
|
// adminWalletAudit is the daily check the plan calls for: the balance of every
|
|
// account must equal the sum of its ledger. A mismatch is a stop-the-world
|
|
// event, so it is surfaced rather than logged.
|
|
func (a *App) adminWalletAudit(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := a.db.QueryContext(r.Context(), `SELECT a.user_id,a.available_cent,COALESCE(SUM(t.amount_cent*t.direction),0)
|
|
FROM wallet_accounts a LEFT JOIN wallet_transactions t ON t.user_id=a.user_id
|
|
GROUP BY a.user_id,a.available_cent HAVING a.available_cent<>COALESCE(SUM(t.amount_cent*t.direction),0)`)
|
|
if err != nil {
|
|
fail(w, http.StatusInternalServerError, 50001, "对账失败")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
mismatches := []map[string]any{}
|
|
for rows.Next() {
|
|
var userID, balance, ledger int64
|
|
if rows.Scan(&userID, &balance, &ledger) != nil {
|
|
continue
|
|
}
|
|
mismatches = append(mismatches, map[string]any{"userId": userID, "balanceCent": balance, "ledgerCent": ledger})
|
|
}
|
|
var accounts, escrowed int64
|
|
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM wallet_accounts`).Scan(&accounts)
|
|
_ = a.db.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(gross_cent),0) FROM pet_feed_tasks WHERE status IN ('ESCROWED','ASSIGNED','SERVING','COMPLETED','DISPUTED')`).Scan(&escrowed)
|
|
reply(w, map[string]any{"accounts": accounts, "escrowedCent": escrowed, "mismatches": mismatches, "healthy": len(mismatches) == 0})
|
|
}
|