433 lines
14 KiB
Go
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]
|
|
}
|