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