audit full project flows and harden backend
This commit is contained in:
@@ -4,6 +4,7 @@ package iot
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -187,7 +188,14 @@ func SaveDeviceMessage(ctx *gin.Context) {
|
||||
if frame.Control&0x04 != 0 {
|
||||
status, errorCode = "failed", "DEVICE_REPORTED_FAILURE"
|
||||
}
|
||||
return tx.Model(&models.IotCommand{}).Where("device_id = ? AND packet_number = ? AND command_status IN ?", envelope.DeviceID, frame.PacketNumber, []string{"dispatched", "pending_confirmation"}).Updates(map[string]any{"command_status": status, "error_code": errorCode, "acknowledged_at": received, "updated_at": received}).Error
|
||||
result := tx.Model(&models.IotCommand{}).Where("device_id = ? AND packet_number = ? AND command_status IN ?", envelope.DeviceID, frame.PacketNumber, []string{"dispatched", "pending_confirmation"}).Updates(map[string]any{"command_status": status, "error_code": errorCode, "acknowledged_at": received, "updated_at": received})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected > 1 {
|
||||
return errors.New("ambiguous device acknowledgement")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
ctx.JSON(500, gin.H{"code": "IOT_MESSAGE_SAVE_FAILED"})
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/config"
|
||||
@@ -80,12 +81,18 @@ func complete(paymentNo, tradeNo string, amount int64, channel, callbackDigest s
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("payment_no = ?", paymentNo).First(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if order.PaymentStatus == StatusPaid {
|
||||
return nil
|
||||
}
|
||||
if order.Channel != channel || order.Amount != amount {
|
||||
return errors.New("payment identity or amount mismatch")
|
||||
}
|
||||
if strings.TrimSpace(tradeNo) == "" {
|
||||
return errors.New("channel trade number is required")
|
||||
}
|
||||
if order.PaymentStatus == StatusPaid {
|
||||
if order.ChannelTradeNo != tradeNo {
|
||||
return errors.New("duplicate callback trade number mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(order.ExpiresAt) {
|
||||
return tx.Model(&order).Updates(map[string]any{"payment_status": 50, "channel_trade_no": tradeNo, "callback_digest": callbackDigest, "failure_code": "PAID_AFTER_EXPIRED"}).Error
|
||||
}
|
||||
@@ -95,9 +102,10 @@ func complete(paymentNo, tradeNo string, amount int64, channel, callbackDigest s
|
||||
}
|
||||
switch order.BusinessType {
|
||||
case "ec_order":
|
||||
return tx.Model(&models.EcOrder{}).Where("identity = ? AND order_status = ?", order.BusinessIdentity, 16).Updates(map[string]any{"order_status": 18, "paid_at": &now}).Error
|
||||
result := tx.Model(&models.EcOrder{}).Where("identity = ? AND order_status = ?", order.BusinessIdentity, 16).Updates(map[string]any{"order_status": 18, "paid_at": &now})
|
||||
return requireSingleBusinessUpdate(result)
|
||||
case "gasorder":
|
||||
return tx.Model(&models.GasorderBasic{}).Where("identity = ? AND order_status IN ?", order.BusinessIdentity, []int{16, 18}).Update("order_status", 35).Error
|
||||
return requireSingleBusinessUpdate(tx.Model(&models.GasorderBasic{}).Where("identity = ? AND order_status IN ?", order.BusinessIdentity, []int{16, 18}).Update("order_status", 35))
|
||||
case "recharge":
|
||||
return completeRecharge(tx, order, now)
|
||||
}
|
||||
@@ -105,6 +113,16 @@ func complete(paymentNo, tradeNo string, amount int64, channel, callbackDigest s
|
||||
})
|
||||
}
|
||||
|
||||
func requireSingleBusinessUpdate(result *gorm.DB) error {
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New("payment business state conflict")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func completeRecharge(tx *gorm.DB, payment models.PaymentOrder, now time.Time) error {
|
||||
var recharge models.WalletRechargeOrder
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ? AND recharge_status = ?", payment.BusinessIdentity, 10).First(&recharge).Error; err != nil {
|
||||
|
||||
21
backend/api/internal/logic/payment/callback_test.go
Normal file
21
backend/api/internal/logic/payment/callback_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestRequireSingleBusinessUpdate(t *testing.T) {
|
||||
if err := requireSingleBusinessUpdate(&gorm.DB{RowsAffected: 1}); err != nil {
|
||||
t.Fatalf("single state update rejected: %v", err)
|
||||
}
|
||||
if err := requireSingleBusinessUpdate(&gorm.DB{}); err == nil {
|
||||
t.Fatal("zero-row state update must be rejected")
|
||||
}
|
||||
expected := errors.New("database unavailable")
|
||||
if err := requireSingleBusinessUpdate(&gorm.DB{Error: expected}); !errors.Is(err, expected) {
|
||||
t.Fatalf("database error lost: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -55,7 +56,9 @@ func closeChannelOrder(ctx context.Context, order models.PaymentOrder) error {
|
||||
|
||||
// CloseExpiredHandler 只接受 Worker 共享凭证,不暴露为平台用户动作。
|
||||
func CloseExpiredHandler(ctx *gin.Context) {
|
||||
if config.Spec.Payment.InternalServiceToken == "" || ctx.GetHeader("X-Heqi-Worker-Token") != config.Spec.Payment.InternalServiceToken {
|
||||
expected := config.Spec.Payment.InternalServiceToken
|
||||
actual := ctx.GetHeader("X-Heqi-Worker-Token")
|
||||
if expected == "" || len(actual) != len(expected) || subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) != 1 {
|
||||
ctx.AbortWithStatus(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user