2026-05-12 14:08:46 +08:00

433 lines
14 KiB
Go

package binancerecharge
import (
"context"
"errors"
"fmt"
"math/big"
"net/http"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const pendingRetryAfter = 10 * time.Minute
var errDuplicateRechargeRecord = errors.New("duplicate binance recharge record")
type preparedRecharge struct {
SysOrigin string
DealerUserID int64
TargetUserID int64
OrderNo string
TransferType string
BillNo string
AmountUSDT string
GoldAmount int64
RateGoldPerUSD string
Verified VerifiedTransaction
}
func (s *Service) VerifyAndRecharge(ctx context.Context, user AuthUser, req VerifyRechargeRequest) (*VerifyRechargeResponse, error) {
if s.db == nil {
return nil, NewAppError(http.StatusServiceUnavailable, "database_unavailable", "database is unavailable")
}
if s.java == nil {
return nil, NewAppError(http.StatusServiceUnavailable, "java_gateway_unavailable", "java gateway is unavailable")
}
if s.verifier == nil {
return nil, NewAppError(http.StatusServiceUnavailable, "binance_verifier_unavailable", "binance verifier is unavailable")
}
sysOrigin := normalizeSysOrigin(user.SysOrigin)
if sysOrigin == "" {
return nil, NewAppError(http.StatusBadRequest, "sys_origin_required", "sysOrigin is required")
}
if user.UserID <= 0 {
return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "user is invalid")
}
if req.TargetUserID <= 0 {
return nil, NewAppError(http.StatusBadRequest, "target_user_required", "targetUserId is required")
}
orderNo := strings.TrimSpace(req.OrderNo)
if orderNo == "" {
return nil, NewAppError(http.StatusBadRequest, "order_no_required", "orderNo is required")
}
billNo := strings.TrimSpace(req.BillNo)
if billNo == "" {
return nil, NewAppError(http.StatusBadRequest, "bill_no_required", "billNo is required")
}
transferType, err := validateTransferType(req.TransferType)
if err != nil {
return nil, err
}
amountRat, amount, err := parsePositiveDecimal(req.TransferAmount, "transferAmount")
if err != nil {
return nil, err
}
cfg, rates, err := s.loadConfigAndRates(ctx, sysOrigin)
if err != nil {
return nil, err
}
rateRat, rateGoldPerUSD, err := matchRate(amountRat, rates)
if err != nil {
return nil, err
}
goldAmount, err := calculateGold(amountRat, rateRat)
if err != nil {
return nil, err
}
if goldAmount <= 0 {
return nil, NewAppError(http.StatusBadRequest, "gold_amount_zero", "calculated gold amount must be greater than 0")
}
profile, err := s.java.GetUserProfile(ctx, req.TargetUserID)
if err != nil {
return nil, NewAppError(http.StatusBadGateway, "target_user_query_failed", err.Error())
}
if normalizeSysOrigin(profile.OriginSys) != sysOrigin {
return nil, NewAppError(http.StatusBadRequest, "target_user_origin_mismatch", "target user does not belong to current sysOrigin")
}
verified, err := s.verifier.Verify(ctx, BinanceCredential{
APIKey: cfg.APIKey,
APISecret: cfg.APISecret,
}, BinanceVerifyQuery{
TransferType: transferType,
BillNo: billNo,
Amount: amount,
})
if err != nil {
return nil, err
}
if strings.TrimSpace(verified.TransactionID) == "" {
verified.TransactionID = billNo
}
if strings.TrimSpace(verified.Amount) == "" {
verified.Amount = amount
}
prepared := preparedRecharge{
SysOrigin: sysOrigin,
DealerUserID: user.UserID,
TargetUserID: req.TargetUserID,
OrderNo: orderNo,
TransferType: transferType,
BillNo: billNo,
AmountUSDT: amount,
GoldAmount: goldAmount,
RateGoldPerUSD: rateGoldPerUSD,
Verified: verified,
}
record, idempotent, err := s.prepareRechargeRecord(ctx, prepared)
if err != nil {
return nil, err
}
if idempotent {
resp := responseFromRecord(record, true)
return &resp, nil
}
if err := s.ensureFreightAccount(ctx, sysOrigin, req.TargetUserID); err != nil {
_ = s.markRecordFailed(ctx, record.ID, err.Error())
return nil, err
}
if err := s.java.ChangeFreightBalance(ctx, integration.FreightBalanceChangeRequest{
SysOrigin: sysOrigin,
UserID: req.TargetUserID,
AcceptUserID: req.TargetUserID,
Type: "INCOME",
AcceptAmount: strconv.FormatInt(goldAmount, 10),
OrderAmount: amount,
USDAmount: "0",
Origin: "PURCHASE",
Remark: fmt.Sprintf("Binance auto recharge order:%s bill:%s", orderNo, billNo),
RechargeType: rechargeTypeUSDT,
CreateUser: user.UserID,
}); err != nil {
_ = s.markRecordPendingIssue(ctx, record.ID, "freight balance change result is unknown: "+err.Error())
return nil, NewAppError(http.StatusBadGateway, "freight_balance_change_failed", err.Error())
}
remark := ""
failReason := ""
if err := s.java.IncreaseFreightRechargeRecord(ctx, req.TargetUserID, amount); err != nil {
failReason = truncateString("monthly freight recharge record update failed: "+err.Error(), 500)
remark = failReason
}
if err := s.markRecordSuccess(ctx, record.ID, remark, failReason); err != nil {
return nil, err
}
record.Status = verifyStatusSuccess
record.Remark = remark
record.FailReason = failReason
resp := responseFromRecord(record, false)
return &resp, nil
}
func (s *Service) loadConfigAndRates(ctx context.Context, sysOrigin string) (model.BinanceRechargeConfig, []model.BinanceRechargeRate, error) {
var cfg model.BinanceRechargeConfig
if err := s.db.WithContext(ctx).
Where("sys_origin = ?", sysOrigin).
First(&cfg).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return cfg, nil, NewAppError(http.StatusBadRequest, "binance_config_missing", "binance recharge config is missing")
}
return cfg, nil, NewAppError(http.StatusInternalServerError, "binance_config_query_failed", err.Error())
}
if !cfg.Enabled {
return cfg, nil, NewAppError(http.StatusBadRequest, "binance_config_disabled", "binance recharge verification is disabled")
}
if strings.TrimSpace(cfg.APIKey) == "" || strings.TrimSpace(cfg.APISecret) == "" {
return cfg, nil, NewAppError(http.StatusBadRequest, "binance_credentials_missing", "binance api key and secret are required")
}
var rates []model.BinanceRechargeRate
if err := s.db.WithContext(ctx).
Where("config_id = ?", cfg.ID).
Order("sort_order asc, min_amount asc, id asc").
Find(&rates).Error; err != nil {
return cfg, nil, NewAppError(http.StatusInternalServerError, "binance_rate_query_failed", err.Error())
}
if len(rates) == 0 {
return cfg, nil, NewAppError(http.StatusBadRequest, "binance_rates_missing", "binance recharge rates are missing")
}
return cfg, rates, nil
}
func matchRate(amount *big.Rat, rates []model.BinanceRechargeRate) (*big.Rat, string, error) {
for _, row := range rates {
minRat, _, err := parseNonNegativeDecimal(row.MinAmount, "minAmount")
if err != nil {
return nil, "", err
}
maxRat, _, err := parseNonNegativeDecimal(defaultZero(row.MaxAmount), "maxAmount")
if err != nil {
return nil, "", err
}
rateRat, rateGoldPerUSD, err := parsePositiveDecimal(row.GoldPerUSD, "goldPerUsd")
if err != nil {
return nil, "", err
}
if amount.Cmp(minRat) < 0 {
continue
}
if maxRat.Sign() > 0 && amount.Cmp(maxRat) >= 0 {
continue
}
return rateRat, rateGoldPerUSD, nil
}
return nil, "", NewAppError(http.StatusBadRequest, "binance_rate_not_matched", "transfer amount does not match any configured rate range")
}
func (s *Service) prepareRechargeRecord(ctx context.Context, prepared preparedRecharge) (model.BinanceRechargeVerifyRecord, bool, error) {
var record model.BinanceRechargeVerifyRecord
var idempotent bool
var err error
for attempt := 0; attempt < 2; attempt++ {
record, idempotent, err = s.prepareRechargeRecordOnce(ctx, prepared)
if errors.Is(err, errDuplicateRechargeRecord) {
continue
}
return record, idempotent, err
}
return record, idempotent, err
}
func (s *Service) prepareRechargeRecordOnce(ctx context.Context, prepared preparedRecharge) (model.BinanceRechargeVerifyRecord, bool, error) {
var out model.BinanceRechargeVerifyRecord
var idempotent bool
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var existing []model.BinanceRechargeVerifyRecord
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("sys_origin = ? AND (order_no = ? OR (transfer_type = ? AND bill_no = ?))",
prepared.SysOrigin,
prepared.OrderNo,
prepared.TransferType,
prepared.BillNo,
).
Limit(2).
Find(&existing).Error; err != nil {
return err
}
if len(existing) > 1 {
return NewAppError(http.StatusConflict, "recharge_order_or_bill_used", "orderNo or billNo has already been used")
}
now := time.Now()
if len(existing) == 1 {
record := existing[0]
switch record.Status {
case verifyStatusSuccess:
out = record
idempotent = true
return nil
case verifyStatusPending:
if now.Sub(record.UpdateTime) < pendingRetryAfter {
return NewAppError(http.StatusConflict, "recharge_verification_processing", "recharge verification is processing")
}
}
updates := recordValues(prepared, now, verifyStatusPending)
if err := tx.Model(&model.BinanceRechargeVerifyRecord{}).
Where("id = ?", record.ID).
Updates(updates).Error; err != nil {
return err
}
if err := tx.Where("id = ?", record.ID).First(&out).Error; err != nil {
return err
}
return nil
}
record := model.BinanceRechargeVerifyRecord{
SysOrigin: prepared.SysOrigin,
DealerUserID: prepared.DealerUserID,
TargetUserID: prepared.TargetUserID,
OrderNo: prepared.OrderNo,
TransferType: prepared.TransferType,
BillNo: prepared.BillNo,
AmountUSDT: prepared.AmountUSDT,
GoldAmount: prepared.GoldAmount,
RateGoldPerUSD: prepared.RateGoldPerUSD,
BinanceTransactionID: prepared.Verified.TransactionID,
BinanceOrderType: prepared.Verified.OrderType,
BinanceRawJSON: prepared.Verified.RawJSON,
Status: verifyStatusPending,
CreateTime: now,
UpdateTime: now,
}
if err := tx.Create(&record).Error; err != nil {
if isDuplicateRecordError(err) {
return errDuplicateRechargeRecord
}
return err
}
out = record
return nil
})
if err != nil {
var appErr *AppError
if errors.As(err, &appErr) {
return out, idempotent, err
}
return out, idempotent, NewAppError(http.StatusInternalServerError, "recharge_record_prepare_failed", err.Error())
}
return out, idempotent, nil
}
func recordValues(prepared preparedRecharge, now time.Time, status string) map[string]any {
return map[string]any{
"dealer_user_id": prepared.DealerUserID,
"target_user_id": prepared.TargetUserID,
"order_no": prepared.OrderNo,
"transfer_type": prepared.TransferType,
"bill_no": prepared.BillNo,
"amount_usdt": prepared.AmountUSDT,
"gold_amount": prepared.GoldAmount,
"rate_gold_per_usd": prepared.RateGoldPerUSD,
"binance_transaction_id": prepared.Verified.TransactionID,
"binance_order_type": prepared.Verified.OrderType,
"binance_raw_json": prepared.Verified.RawJSON,
"status": status,
"fail_reason": "",
"remark": "",
"update_time": now,
}
}
func (s *Service) ensureFreightAccount(ctx context.Context, sysOrigin string, targetUserID int64) error {
exists, err := s.java.ExistsFreightBalance(ctx, targetUserID)
if err != nil {
return NewAppError(http.StatusBadGateway, "freight_balance_query_failed", err.Error())
}
if exists {
return nil
}
existsSeller, err := s.java.ExistsFreightSeller(ctx, targetUserID)
if err != nil {
return NewAppError(http.StatusBadGateway, "freight_seller_query_failed", err.Error())
}
if existsSeller {
return NewAppError(http.StatusConflict, "freight_seller_exists_without_balance", "target user is already a freight seller without balance account")
}
if err := s.java.AddFreightAgentReview(ctx, integration.FreightAgentReviewRequest{
SysOrigin: sysOrigin,
UserID: targetUserID,
}); err != nil {
return NewAppError(http.StatusBadGateway, "freight_agent_create_failed", err.Error())
}
return nil
}
func (s *Service) markRecordFailed(ctx context.Context, id int64, reason string) error {
return s.updateRecordStatus(ctx, id, verifyStatusFailed, "", truncateString(reason, 500))
}
func (s *Service) markRecordPendingIssue(ctx context.Context, id int64, reason string) error {
return s.updateRecordStatus(ctx, id, verifyStatusPending, "", truncateString(reason, 500))
}
func (s *Service) markRecordSuccess(ctx context.Context, id int64, remark string, failReason string) error {
return s.updateRecordStatus(ctx, id, verifyStatusSuccess, truncateString(remark, 500), truncateString(failReason, 500))
}
func (s *Service) updateRecordStatus(ctx context.Context, id int64, status string, remark string, failReason string) error {
if id <= 0 {
return nil
}
if err := s.db.WithContext(ctx).Model(&model.BinanceRechargeVerifyRecord{}).
Where("id = ?", id).
Updates(map[string]any{
"status": status,
"remark": remark,
"fail_reason": failReason,
"update_time": time.Now(),
}).Error; err != nil {
return NewAppError(http.StatusInternalServerError, "recharge_record_update_failed", err.Error())
}
return nil
}
func responseFromRecord(record model.BinanceRechargeVerifyRecord, idempotent bool) VerifyRechargeResponse {
return VerifyRechargeResponse{
ID: record.ID,
SysOrigin: record.SysOrigin,
DealerUserID: record.DealerUserID,
TargetUserID: record.TargetUserID,
OrderNo: record.OrderNo,
TransferType: record.TransferType,
BillNo: record.BillNo,
TransferAmount: trimDecimalZeros(record.AmountUSDT),
GoldAmount: record.GoldAmount,
RateGoldPerUSD: trimDecimalZeros(record.RateGoldPerUSD),
Status: record.Status,
Idempotent: idempotent,
}
}
func isDuplicateRecordError(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "duplicate") ||
strings.Contains(message, "unique constraint") ||
strings.Contains(message, "duplicated key")
}
func truncateString(value string, limit int) string {
value = strings.TrimSpace(value)
if limit <= 0 || len(value) <= limit {
return value
}
return value[:limit]
}