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