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