Files
kefu/im/backend/internal/app/wallet.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

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