27 changed files with 1649 additions and 89 deletions
@ -0,0 +1,66 @@ |
|||
package client |
|||
|
|||
import ( |
|||
"bytes" |
|||
"encoding/json" |
|||
"errors" |
|||
"fmt" |
|||
"io" |
|||
"net/http" |
|||
"strings" |
|||
"time" |
|||
) |
|||
|
|||
type AGPayClient struct { |
|||
baseURL string |
|||
httpClient *http.Client |
|||
} |
|||
|
|||
type AGPayCreateRequest struct { |
|||
TotalFeeFen int64 `json:"totalFee"` |
|||
MerchantOrder string `json:"tradeNo"` |
|||
NotifyURL string `json:"notifyUrl"` |
|||
} |
|||
|
|||
type AGPayRequestRejectedError struct { |
|||
StatusCode int |
|||
} |
|||
|
|||
func (e *AGPayRequestRejectedError) Error() string { |
|||
return fmt.Sprintf("AGPay rejected payment request with HTTP %d", e.StatusCode) |
|||
} |
|||
|
|||
func NewAGPayClient(baseURL string) (*AGPayClient, error) { |
|||
if baseURL == "" { |
|||
return nil, errors.New("AGPay base URL is required") |
|||
} |
|||
return &AGPayClient{baseURL: strings.TrimRight(baseURL, "/"), httpClient: &http.Client{Timeout: 15 * time.Second}}, nil |
|||
} |
|||
|
|||
func (c *AGPayClient) CreateWechatQRCode(req AGPayCreateRequest) ([]byte, error) { |
|||
if req.TotalFeeFen <= 0 || req.MerchantOrder == "" || req.NotifyURL == "" { |
|||
return nil, errors.New("invalid AGPay payment request") |
|||
} |
|||
body, err := json.Marshal(req) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
httpReq, err := http.NewRequest(http.MethodPost, c.baseURL+"/pay/wx/pay", bytes.NewReader(body)) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
httpReq.Header.Set("Content-Type", "application/json") |
|||
resp, err := c.httpClient.Do(httpReq) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("call AGPay: %w", err) |
|||
} |
|||
defer resp.Body.Close() |
|||
response, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { |
|||
return nil, &AGPayRequestRejectedError{StatusCode: resp.StatusCode} |
|||
} |
|||
return response, nil |
|||
} |
|||
@ -0,0 +1,156 @@ |
|||
package client |
|||
|
|||
import ( |
|||
"crypto/sha256" |
|||
"encoding/hex" |
|||
"encoding/json" |
|||
"errors" |
|||
"fmt" |
|||
"io" |
|||
"net/http" |
|||
"net/url" |
|||
"sort" |
|||
"strconv" |
|||
"strings" |
|||
"time" |
|||
) |
|||
|
|||
const defaultSimbossAPIBase = "https://api.simboss.com" |
|||
|
|||
type SimbossClient struct { |
|||
appID string |
|||
secret string |
|||
apiBase string |
|||
httpClient *http.Client |
|||
} |
|||
|
|||
type SimbossDeviceDetail struct { |
|||
Carrier string `json:"carrier"` |
|||
Status string `json:"status"` |
|||
DeviceStatus string `json:"deviceStatus"` |
|||
ExpireDate string `json:"expireDate"` |
|||
RatePlanID int64 `json:"ratePlanId"` |
|||
RatePlanName string `json:"iratePlanName"` |
|||
DataUsage float64 `json:"dataUsage"` |
|||
TotalDataVolume float64 `json:"totalDataVolume"` |
|||
RatePlanExpirationDate string `json:"ratePlanExpirationDate"` |
|||
} |
|||
|
|||
type SimbossRatePlan struct { |
|||
RatePlanID int64 `json:"ratePlanId"` |
|||
Name string `json:"name"` |
|||
Description string `json:"description"` |
|||
DataVolume float64 `json:"dataVolume"` |
|||
TimeLength int `json:"timeLength"` |
|||
TimeUnit string `json:"timeUnit"` |
|||
MaxRechargePeriod int `json:"maxRechargePeriod"` |
|||
} |
|||
|
|||
type simbossResponse struct { |
|||
Code string `json:"code"` |
|||
Message string `json:"message"` |
|||
Success bool `json:"success"` |
|||
Data json.RawMessage `json:"data"` |
|||
} |
|||
|
|||
func NewSimbossClient(appID, secret, apiBase string) (*SimbossClient, error) { |
|||
if appID == "" || secret == "" { |
|||
return nil, errors.New("SIMBOSS credentials are required") |
|||
} |
|||
if apiBase == "" { |
|||
apiBase = defaultSimbossAPIBase |
|||
} |
|||
return &SimbossClient{ |
|||
appID: appID, secret: secret, apiBase: strings.TrimRight(apiBase, "/"), |
|||
httpClient: &http.Client{Timeout: 30 * time.Second}, |
|||
}, nil |
|||
} |
|||
|
|||
func (c *SimbossClient) GetDeviceDetail(iccid string) (*SimbossDeviceDetail, error) { |
|||
var detail SimbossDeviceDetail |
|||
if err := c.post("/2.0/device/detail", map[string]string{"iccid": iccid}, &detail); err != nil { |
|||
return nil, err |
|||
} |
|||
return &detail, nil |
|||
} |
|||
|
|||
func (c *SimbossClient) GetRatePlans(iccid string) ([]SimbossRatePlan, error) { |
|||
var plans []SimbossRatePlan |
|||
if err := c.post("/2.0/device/rateplans", map[string]string{"iccid": iccid}, &plans); err != nil { |
|||
return nil, err |
|||
} |
|||
return plans, nil |
|||
} |
|||
|
|||
func (c *SimbossClient) Recharge(iccid string, ratePlanID int64, months int, externalOrder string) (string, error) { |
|||
if ratePlanID <= 0 || months <= 0 || externalOrder == "" { |
|||
return "", errors.New("invalid SIMBOSS recharge request") |
|||
} |
|||
var sequence string |
|||
err := c.post("/2.0/device/recharge", map[string]string{ |
|||
"iccid": iccid, |
|||
"ratePlanId": strconv.FormatInt(ratePlanID, 10), |
|||
"month": strconv.Itoa(months), |
|||
"externalOrder": externalOrder, |
|||
}, &sequence) |
|||
return sequence, err |
|||
} |
|||
|
|||
func (c *SimbossClient) post(path string, params map[string]string, target any) error { |
|||
params["appid"] = c.appID |
|||
params["timestamp"] = strconv.FormatInt(time.Now().UnixMilli(), 10) |
|||
params["sign"] = c.sign(params) |
|||
|
|||
form := url.Values{} |
|||
for key, value := range params { |
|||
form.Set(key, value) |
|||
} |
|||
req, err := http.NewRequest(http.MethodPost, c.apiBase+path, strings.NewReader(form.Encode())) |
|||
if err != nil { |
|||
return fmt.Errorf("create SIMBOSS request: %w", err) |
|||
} |
|||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=utf-8") |
|||
resp, err := c.httpClient.Do(req) |
|||
if err != nil { |
|||
return fmt.Errorf("call SIMBOSS: %w", err) |
|||
} |
|||
defer resp.Body.Close() |
|||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) |
|||
if err != nil { |
|||
return fmt.Errorf("read SIMBOSS response: %w", err) |
|||
} |
|||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { |
|||
return fmt.Errorf("SIMBOSS returned HTTP %d", resp.StatusCode) |
|||
} |
|||
var result simbossResponse |
|||
if err := json.Unmarshal(body, &result); err != nil { |
|||
return fmt.Errorf("decode SIMBOSS response: %w", err) |
|||
} |
|||
if result.Code != "0" && !result.Success { |
|||
return fmt.Errorf("SIMBOSS rejected request: code=%s", result.Code) |
|||
} |
|||
if err := json.Unmarshal(result.Data, target); err != nil { |
|||
return fmt.Errorf("decode SIMBOSS data: %w", err) |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
func (c *SimbossClient) sign(params map[string]string) string { |
|||
keys := make([]string, 0, len(params)) |
|||
for key := range params { |
|||
keys = append(keys, key) |
|||
} |
|||
sort.Strings(keys) |
|||
var builder strings.Builder |
|||
for i, key := range keys { |
|||
if i > 0 { |
|||
builder.WriteByte('&') |
|||
} |
|||
builder.WriteString(key) |
|||
builder.WriteByte('=') |
|||
builder.WriteString(params[key]) |
|||
} |
|||
builder.WriteString(c.secret) |
|||
sum := sha256.Sum256([]byte(builder.String())) |
|||
return hex.EncodeToString(sum[:]) |
|||
} |
|||
@ -0,0 +1,79 @@ |
|||
package handler |
|||
|
|||
import ( |
|||
"bytes" |
|||
"encoding/json" |
|||
"io" |
|||
"mime" |
|||
"net/http" |
|||
|
|||
"github.com/gin-gonic/gin" |
|||
"github.com/gin-gonic/gin/binding" |
|||
|
|||
"laic-backend/common" |
|||
"laic-backend/model" |
|||
"laic-backend/service" |
|||
"laic-backend/vo" |
|||
) |
|||
|
|||
func CreateWechatPayment(c *gin.Context) { |
|||
var req vo.PaymentCreateReq |
|||
if err := c.ShouldBindJSON(&req); err != nil { |
|||
common.FailWithBindError(c, common.ErrParam, err) |
|||
return |
|||
} |
|||
data, e := service.DefaultPaymentService.CreateWechatPayment(common.GetUserId(c), &req) |
|||
if e != nil { |
|||
common.FailWithBusiError(c, e) |
|||
return |
|||
} |
|||
common.OKWithData(c, data) |
|||
} |
|||
|
|||
func GetPaymentTransaction(c *gin.Context) { |
|||
id, e := parseID(c) |
|||
if e != nil { |
|||
common.FailWithBusiError(c, e) |
|||
return |
|||
} |
|||
data, e := service.DefaultPaymentService.GetTransaction(common.GetUserId(c), id) |
|||
if e != nil { |
|||
common.FailWithBusiError(c, e) |
|||
return |
|||
} |
|||
common.OKWithData(c, data) |
|||
} |
|||
|
|||
func AGPayCallback(c *gin.Context) { |
|||
contentType, _, err := mime.ParseMediaType(c.GetHeader("Content-Type")) |
|||
if err != nil || contentType != "application/json" { |
|||
c.Status(http.StatusBadRequest) |
|||
return |
|||
} |
|||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<10) |
|||
body, err := c.GetRawData() |
|||
if err != nil { |
|||
c.Status(http.StatusBadRequest) |
|||
return |
|||
} |
|||
var callback model.AGPayCallback |
|||
decoder := json.NewDecoder(bytes.NewReader(body)) |
|||
decoder.DisallowUnknownFields() |
|||
if err := decoder.Decode(&callback); err != nil || decoder.Decode(&struct{}{}) != io.EOF { |
|||
c.Status(http.StatusBadRequest) |
|||
return |
|||
} |
|||
if err := binding.Validator.ValidateStruct(&callback); err != nil { |
|||
c.Status(http.StatusBadRequest) |
|||
return |
|||
} |
|||
if err := service.DefaultPaymentService.ConfirmAGPayCallback(callback.TradeNo, body); err != nil { |
|||
if err == common.ErrInternal { |
|||
c.Status(http.StatusInternalServerError) |
|||
return |
|||
} |
|||
c.Status(http.StatusBadRequest) |
|||
return |
|||
} |
|||
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"}) |
|||
} |
|||
@ -0,0 +1,39 @@ |
|||
package model |
|||
|
|||
import "time" |
|||
|
|||
type PaymentTransaction struct { |
|||
ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` |
|||
UserID int64 `gorm:"column:user_id;type:BIGINT;not null" json:"userId"` |
|||
BusinessType string `gorm:"column:business_type;type:VARCHAR(32);not null" json:"businessType"` |
|||
BusinessOrderID int64 `gorm:"column:business_order_id;type:BIGINT;not null" json:"businessOrderId"` |
|||
Provider string `gorm:"column:provider;type:VARCHAR(32);not null" json:"provider"` |
|||
MerchantOrderNo string `gorm:"column:merchant_order_no;type:VARCHAR(128);not null" json:"merchantOrderNo"` |
|||
ProviderTradeNo *string `gorm:"column:provider_trade_no;type:VARCHAR(128)" json:"-"` |
|||
ProviderPayload string `gorm:"column:provider_payload;type:TEXT" json:"-"` |
|||
RequestedAt *time.Time `gorm:"column:requested_at" json:"-"` |
|||
TotalFeeFen int64 `gorm:"column:total_fee_fen;type:BIGINT;not null" json:"totalFeeFen"` |
|||
Status string `gorm:"column:status;type:VARCHAR(16);not null;default:unpaid" json:"status"` |
|||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` |
|||
PaidAt *time.Time `gorm:"column:paid_at" json:"paidAt"` |
|||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` |
|||
} |
|||
|
|||
func (PaymentTransaction) TableName() string { return "payment_transaction" } |
|||
|
|||
type PaymentCallbackEvent struct { |
|||
ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` |
|||
Provider string `gorm:"column:provider;type:VARCHAR(32);not null" json:"provider"` |
|||
EventKey string `gorm:"column:event_key;type:VARCHAR(128);not null" json:"eventKey"` |
|||
PayloadHash string `gorm:"column:payload_hash;type:VARCHAR(64);not null" json:"payloadHash"` |
|||
Verified bool `gorm:"column:verified;type:TINYINT(1);not null" json:"verified"` |
|||
ProcessedAt *time.Time `gorm:"column:processed_at" json:"processedAt"` |
|||
ResultCode string `gorm:"column:result_code;type:VARCHAR(64)" json:"resultCode"` |
|||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` |
|||
} |
|||
|
|||
type AGPayCallback struct { |
|||
TradeNo string `json:"tradeNo" binding:"required,max=128"` |
|||
} |
|||
|
|||
func (PaymentCallbackEvent) TableName() string { return "payment_callback_event" } |
|||
@ -0,0 +1,52 @@ |
|||
package model |
|||
|
|||
import "time" |
|||
|
|||
type SimPackage struct { |
|||
ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` |
|||
Code string `gorm:"column:code;type:VARCHAR(64);not null" json:"code"` |
|||
Name string `gorm:"column:name;type:VARCHAR(128);not null" json:"name"` |
|||
Carrier string `gorm:"column:carrier;type:VARCHAR(16);not null" json:"carrier"` |
|||
ProviderRatePlanID int64 `gorm:"column:provider_rate_plan_id;type:BIGINT;not null" json:"-"` |
|||
AmountGb int `gorm:"column:amount_gb;type:INT;not null" json:"amountGb"` |
|||
ValidityMonths int `gorm:"column:validity_months;type:INT;not null" json:"validityMonths"` |
|||
CostFeeFen int64 `gorm:"column:cost_fee_fen;type:BIGINT;not null" json:"-"` |
|||
SaleFeeFen int64 `gorm:"column:sale_fee_fen;type:BIGINT;not null" json:"saleFeeFen"` |
|||
Status string `gorm:"column:status;type:VARCHAR(16);not null;default:active" json:"status"` |
|||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` |
|||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` |
|||
} |
|||
|
|||
func (SimPackage) TableName() string { return "sim_package" } |
|||
|
|||
type SimRechargeOrder struct { |
|||
ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` |
|||
UserID int64 `gorm:"column:user_id;type:BIGINT;not null" json:"userId"` |
|||
SimCardID int64 `gorm:"column:sim_card_id;type:BIGINT;not null" json:"simCardId"` |
|||
PackageID int64 `gorm:"column:package_id;type:BIGINT;not null" json:"packageId"` |
|||
PackageCode string `gorm:"column:package_code;type:VARCHAR(64);not null" json:"packageCode"` |
|||
ProviderRatePlanID int64 `gorm:"column:provider_rate_plan_id;type:BIGINT;not null" json:"-"` |
|||
IccidSnapshot string `gorm:"column:iccid_snapshot;type:VARCHAR(32);not null" json:"-"` |
|||
AmountGb int `gorm:"column:amount_gb;type:INT;not null" json:"amountGb"` |
|||
ValidityMonths int `gorm:"column:validity_months;type:INT;not null" json:"validityMonths"` |
|||
TotalFeeFen int64 `gorm:"column:total_fee_fen;type:BIGINT;not null" json:"totalFeeFen"` |
|||
PaymentStatus string `gorm:"column:payment_status;type:VARCHAR(16);not null;default:unpaid" json:"paymentStatus"` |
|||
FulfillmentStatus string `gorm:"column:fulfillment_status;type:VARCHAR(16);not null;default:pending" json:"fulfillmentStatus"` |
|||
FulfillmentLeaseToken string `gorm:"column:fulfillment_lease_token;type:VARCHAR(64)" json:"-"` |
|||
FulfillmentLeaseUntil *time.Time `gorm:"column:fulfillment_lease_until" json:"-"` |
|||
ExternalOrderNo string `gorm:"column:external_order_no;type:VARCHAR(128);not null;uniqueIndex:uk_sim_recharge_external" json:"-"` |
|||
ProviderOrderNo string `gorm:"column:provider_order_no;type:VARCHAR(128)" json:"-"` |
|||
AttemptCount int `gorm:"column:attempt_count;type:INT;not null;default:0" json:"-"` |
|||
NextAttemptAt *time.Time `gorm:"column:next_attempt_at" json:"-"` |
|||
LastErrorCode string `gorm:"column:last_error_code;type:VARCHAR(64)" json:"-"` |
|||
LastErrorMessage string `gorm:"column:last_error_message;type:VARCHAR(256)" json:"-"` |
|||
FulfillmentStartedAt *time.Time `gorm:"column:fulfillment_started_at" json:"-"` |
|||
FulfillmentMessage string `gorm:"column:fulfillment_message;type:VARCHAR(128)" json:"fulfillmentMessage"` |
|||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` |
|||
PaidAt *time.Time `gorm:"column:paid_at" json:"paidAt"` |
|||
ClosedAt *time.Time `gorm:"column:closed_at" json:"closedAt"` |
|||
FulfilledAt *time.Time `gorm:"column:fulfilled_at" json:"fulfilledAt"` |
|||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` |
|||
} |
|||
|
|||
func (SimRechargeOrder) TableName() string { return "sim_recharge_order" } |
|||
@ -0,0 +1,430 @@ |
|||
package service |
|||
|
|||
import ( |
|||
"crypto/sha256" |
|||
"encoding/hex" |
|||
"errors" |
|||
"fmt" |
|||
"strconv" |
|||
"time" |
|||
|
|||
"gorm.io/gorm" |
|||
"gorm.io/gorm/clause" |
|||
|
|||
"laic-backend/cache" |
|||
"laic-backend/client" |
|||
"laic-backend/common" |
|||
"laic-backend/logger" |
|||
"laic-backend/model" |
|||
"laic-backend/tool" |
|||
"laic-backend/vo" |
|||
) |
|||
|
|||
type PaymentService struct { |
|||
agpay *client.AGPayClient |
|||
notifyURL string |
|||
} |
|||
|
|||
type trafficCreditAudit struct { |
|||
UserID int64 |
|||
OrderID int64 |
|||
LedgerID int64 |
|||
AmountBytes int64 |
|||
BalanceBefore int64 |
|||
BalanceAfter int64 |
|||
} |
|||
|
|||
var DefaultPaymentService = &PaymentService{} |
|||
|
|||
func InitAGPay(conf common.AGPay) error { |
|||
if conf.BaseURL == "" && conf.NotifyURL == "" { |
|||
return nil |
|||
} |
|||
instance, err := client.NewAGPayClient(conf.BaseURL) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
if conf.NotifyURL == "" { |
|||
return errors.New("AGPay notify URL is required") |
|||
} |
|||
DefaultPaymentService.agpay = instance |
|||
DefaultPaymentService.notifyURL = conf.NotifyURL |
|||
return nil |
|||
} |
|||
|
|||
func (s *PaymentService) CreateWechatPayment(userID int64, req *vo.PaymentCreateReq) (*vo.PaymentCreateVO, *common.BusiError) { |
|||
if s.agpay == nil { |
|||
return nil, common.ErrPaymentDisabled |
|||
} |
|||
|
|||
transaction, created, busiErr := s.getOrCreateTransaction(userID, req.BusinessType, req.BusinessOrderID) |
|||
if busiErr != nil { |
|||
return nil, busiErr |
|||
} |
|||
if transaction.Status == "paid" { |
|||
DefaultOperationLogService.RecordEvent(userID, "充值与履约", "创建支付交易", fmt.Sprintf("transactionId=%d businessType=%s businessOrderId=%d status=paid", transaction.ID, req.BusinessType, req.BusinessOrderID), "failed", "user_api") |
|||
return nil, common.ErrPaymentAlreadyPaid |
|||
} |
|||
if !created { |
|||
if transaction.Status == "cancelled" || transaction.Status == "closed" || transaction.Status == "failed" || transaction.ProviderPayload == "" { |
|||
DefaultOperationLogService.RecordEvent(userID, "充值与履约", "复用支付交易", fmt.Sprintf("transactionId=%d businessType=%s businessOrderId=%d status=%s", transaction.ID, transaction.BusinessType, transaction.BusinessOrderID, transaction.Status), "failed", "user_api") |
|||
return nil, common.ErrPaymentOrderConflict |
|||
} |
|||
DefaultOperationLogService.RecordEvent(userID, "充值与履约", "复用支付交易", fmt.Sprintf("transactionId=%d businessType=%s businessOrderId=%d status=%s", transaction.ID, transaction.BusinessType, transaction.BusinessOrderID, transaction.Status), "success", "user_api") |
|||
return paymentCreateVO(transaction, transaction.ProviderPayload), nil |
|||
} |
|||
|
|||
payload, err := s.agpay.CreateWechatQRCode(client.AGPayCreateRequest{ |
|||
TotalFeeFen: transaction.TotalFeeFen, MerchantOrder: transaction.MerchantOrderNo, NotifyURL: s.notifyURL, |
|||
}) |
|||
if err != nil { |
|||
logger.WARN("创建 AGPay 微信支付请求失败,交易保留待确认", transaction.ID, err) |
|||
return nil, common.ErrPaymentOrderConflict |
|||
} |
|||
payloadText := string(payload) |
|||
if err := common.DB.Model(&model.PaymentTransaction{}).Where("id = ? AND status = ?", transaction.ID, "processing").Updates(map[string]any{ |
|||
"provider_payload": payloadText, "requested_at": time.Now(), "updated_at": time.Now(), |
|||
}).Error; err != nil { |
|||
logger.ERROR("保存 AGPay 支付响应失败", err) |
|||
return nil, common.ErrInternal |
|||
} |
|||
transaction.ProviderPayload = payloadText |
|||
DefaultOperationLogService.RecordEvent(userID, "充值与履约", "创建支付交易", fmt.Sprintf("transactionId=%d businessType=%s businessOrderId=%d feeFen=%d", transaction.ID, transaction.BusinessType, transaction.BusinessOrderID, transaction.TotalFeeFen), "success", "user_api") |
|||
|
|||
return paymentCreateVO(transaction, payloadText), nil |
|||
} |
|||
|
|||
func (s *PaymentService) GetTransaction(userID, transactionID int64) (*vo.PaymentTransactionVO, *common.BusiError) { |
|||
var transaction model.PaymentTransaction |
|||
if err := common.DB.Where("id = ? AND user_id = ?", transactionID, userID).First(&transaction).Error; err != nil { |
|||
if errors.Is(err, gorm.ErrRecordNotFound) { |
|||
return nil, common.ErrPaymentTransactionNotFound |
|||
} |
|||
return nil, common.ErrInternal |
|||
} |
|||
return paymentTransactionVO(&transaction), nil |
|||
} |
|||
|
|||
func (s *PaymentService) markPaymentRequestFailed(transactionID int64, businessType string, businessOrderID int64) { |
|||
if err := common.DB.Transaction(func(tx *gorm.DB) error { |
|||
if err := tx.Model(&model.PaymentTransaction{}).Where("id = ? AND status = ?", transactionID, "processing").Updates(map[string]any{"status": "failed", "updated_at": time.Now()}).Error; err != nil { |
|||
return err |
|||
} |
|||
switch businessType { |
|||
case "traffic_order": |
|||
return tx.Model(&model.TrafficOrder{}).Where("id = ? AND pay_status = ?", businessOrderID, "processing").Updates(map[string]any{"pay_status": "unpaid", "payment_provider": "", "updated_at": time.Now()}).Error |
|||
case "sim_recharge_order": |
|||
return tx.Model(&model.SimRechargeOrder{}).Where("id = ? AND payment_status = ?", businessOrderID, "processing").Updates(map[string]any{"payment_status": "unpaid", "updated_at": time.Now()}).Error |
|||
default: |
|||
return common.ErrParam |
|||
} |
|||
}); err != nil { |
|||
logger.ERROR("回滚支付请求状态失败", err) |
|||
} |
|||
} |
|||
|
|||
func (s *PaymentService) getOrCreateTransaction(userID int64, businessType string, businessOrderID int64) (*model.PaymentTransaction, bool, *common.BusiError) { |
|||
var transaction model.PaymentTransaction |
|||
var created bool |
|||
var busiErr *common.BusiError |
|||
err := common.DB.Transaction(func(tx *gorm.DB) error { |
|||
amount, status, err := paymentBusinessOrder(tx, userID, businessType, businessOrderID) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). |
|||
Where("user_id = ? AND business_type = ? AND business_order_id = ? AND provider = ?", userID, businessType, businessOrderID, "agpay"). |
|||
First(&transaction).Error; err == nil { |
|||
return nil |
|||
} else if !errors.Is(err, gorm.ErrRecordNotFound) { |
|||
return err |
|||
} |
|||
if amount <= 0 { |
|||
busiErr = common.ErrParam |
|||
return busiErr |
|||
} |
|||
if status == "paid" { |
|||
busiErr = common.ErrPaymentAlreadyPaid |
|||
return busiErr |
|||
} |
|||
if status != "unpaid" && status != "processing" { |
|||
busiErr = common.ErrPaymentOrderConflict |
|||
return busiErr |
|||
} |
|||
|
|||
id, err := tool.NextID() |
|||
if err != nil { |
|||
return err |
|||
} |
|||
now := time.Now() |
|||
transaction = model.PaymentTransaction{ |
|||
ID: id, UserID: userID, BusinessType: businessType, BusinessOrderID: businessOrderID, |
|||
Provider: "agpay", MerchantOrderNo: "agpay-" + strconv.FormatInt(id, 10), |
|||
TotalFeeFen: amount, Status: "processing", CreatedAt: now, UpdatedAt: now, |
|||
} |
|||
if err := tx.Create(&transaction).Error; err != nil { |
|||
return err |
|||
} |
|||
created = true |
|||
return updatePaymentBusinessStatus(tx, businessType, businessOrderID, "processing") |
|||
}) |
|||
if err != nil { |
|||
if errors.Is(err, gorm.ErrRecordNotFound) { |
|||
return nil, false, common.ErrOrderNotFound |
|||
} |
|||
if busiErr != nil { |
|||
return nil, false, busiErr |
|||
} |
|||
logger.ERROR("创建支付交易失败", err) |
|||
return nil, false, common.ErrInternal |
|||
} |
|||
return &transaction, created, nil |
|||
} |
|||
|
|||
func paymentBusinessOrder(tx *gorm.DB, userID int64, businessType string, businessOrderID int64) (int64, string, error) { |
|||
switch businessType { |
|||
case "traffic_order": |
|||
var order model.TrafficOrder |
|||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND user_id = ?", businessOrderID, userID).First(&order).Error; err != nil { |
|||
return 0, "", err |
|||
} |
|||
return order.TotalFeeFen, order.PayStatus, nil |
|||
case "sim_recharge_order": |
|||
var order model.SimRechargeOrder |
|||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND user_id = ?", businessOrderID, userID).First(&order).Error; err != nil { |
|||
return 0, "", err |
|||
} |
|||
return order.TotalFeeFen, order.PaymentStatus, nil |
|||
default: |
|||
return 0, "", common.ErrParam |
|||
} |
|||
} |
|||
|
|||
func updatePaymentBusinessStatus(tx *gorm.DB, businessType string, businessOrderID int64, status string) error { |
|||
var result *gorm.DB |
|||
switch businessType { |
|||
case "traffic_order": |
|||
result = tx.Model(&model.TrafficOrder{}).Where("id = ? AND pay_status = ?", businessOrderID, "unpaid").Updates(map[string]any{"pay_status": status, "payment_provider": "agpay", "updated_at": time.Now()}) |
|||
case "sim_recharge_order": |
|||
result = tx.Model(&model.SimRechargeOrder{}).Where("id = ? AND payment_status = ?", businessOrderID, "unpaid").Updates(map[string]any{"payment_status": status, "updated_at": time.Now()}) |
|||
default: |
|||
return common.ErrParam |
|||
} |
|||
if result.Error != nil { |
|||
return result.Error |
|||
} |
|||
if result.RowsAffected != 1 { |
|||
return common.ErrPaymentOrderConflict |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
func paymentCreateVO(transaction *model.PaymentTransaction, payload string) *vo.PaymentCreateVO { |
|||
return &vo.PaymentCreateVO{ |
|||
TransactionID: transaction.ID, BusinessType: transaction.BusinessType, BusinessOrderID: transaction.BusinessOrderID, |
|||
Provider: transaction.Provider, MerchantOrderNo: transaction.MerchantOrderNo, TotalFeeFen: transaction.TotalFeeFen, |
|||
Status: transaction.Status, ProviderPayload: payload, CallbackEnabled: false, |
|||
} |
|||
} |
|||
|
|||
func paymentTransactionVO(transaction *model.PaymentTransaction) *vo.PaymentTransactionVO { |
|||
return &vo.PaymentTransactionVO{ |
|||
ID: transaction.ID, BusinessType: transaction.BusinessType, BusinessOrderID: transaction.BusinessOrderID, |
|||
Provider: transaction.Provider, MerchantOrderNo: transaction.MerchantOrderNo, TotalFeeFen: transaction.TotalFeeFen, |
|||
Status: transaction.Status, CreatedAt: transaction.CreatedAt, PaidAt: transaction.PaidAt, |
|||
} |
|||
} |
|||
|
|||
func (s *PaymentService) ConfirmAGPayCallback(tradeNo string, payload []byte) *common.BusiError { |
|||
if tradeNo == "" || len(tradeNo) > 128 { |
|||
return common.ErrParam |
|||
} |
|||
|
|||
now := time.Now() |
|||
sum := sha256.Sum256(payload) |
|||
payloadHash := hex.EncodeToString(sum[:]) |
|||
creditedUserID := int64(0) |
|||
var trafficCredit *trafficCreditAudit |
|||
var simOrderToSubmit *model.SimRechargeOrder |
|||
callbackAudit := "" |
|||
var callbackTransaction model.PaymentTransaction |
|||
var busiErr *common.BusiError |
|||
err := common.DB.Transaction(func(tx *gorm.DB) error { |
|||
var transaction model.PaymentTransaction |
|||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("provider = ? AND merchant_order_no = ?", "agpay", tradeNo).First(&transaction).Error; err != nil { |
|||
if errors.Is(err, gorm.ErrRecordNotFound) { |
|||
busiErr = common.ErrPaymentTransactionNotFound |
|||
return busiErr |
|||
} |
|||
return err |
|||
} |
|||
|
|||
var event model.PaymentCallbackEvent |
|||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("provider = ? AND event_key = ?", "agpay", tradeNo).First(&event).Error |
|||
if err == nil { |
|||
callbackTransaction = transaction |
|||
if event.PayloadHash != payloadHash { |
|||
callbackAudit = "conflict" |
|||
busiErr = common.ErrPaymentOrderConflict |
|||
return busiErr |
|||
} |
|||
callbackAudit = "duplicate" |
|||
callbackTransaction = transaction |
|||
return nil |
|||
} |
|||
if !errors.Is(err, gorm.ErrRecordNotFound) { |
|||
return err |
|||
} |
|||
id, err := tool.NextID() |
|||
if err != nil { |
|||
return err |
|||
} |
|||
event = model.PaymentCallbackEvent{ID: id, Provider: "agpay", EventKey: tradeNo, PayloadHash: payloadHash, Verified: true, CreatedAt: now} |
|||
if err := tx.Create(&event).Error; err != nil { |
|||
return err |
|||
} |
|||
|
|||
callbackTransaction = transaction |
|||
if transaction.Status == "paid" { |
|||
callbackAudit = "duplicate" |
|||
return tx.Model(&event).Updates(map[string]any{"processed_at": now, "result_code": "duplicate"}).Error |
|||
} |
|||
if transaction.Status == "cancelled" || transaction.Status == "closed" { |
|||
callbackAudit = transaction.Status |
|||
return tx.Model(&event).Updates(map[string]any{"processed_at": now, "result_code": transaction.Status}).Error |
|||
} |
|||
if transaction.Status != "processing" { |
|||
callbackAudit = "conflict" |
|||
busiErr = common.ErrPaymentOrderConflict |
|||
return busiErr |
|||
} |
|||
result := tx.Model(&transaction).Where("id = ? AND status = ?", transaction.ID, "processing").Updates(map[string]any{ |
|||
"status": "paid", "paid_at": now, "updated_at": now, |
|||
}) |
|||
if result.Error != nil { |
|||
return result.Error |
|||
} |
|||
if result.RowsAffected != 1 { |
|||
callbackAudit = "conflict" |
|||
busiErr = common.ErrPaymentOrderConflict |
|||
return busiErr |
|||
} |
|||
switch transaction.BusinessType { |
|||
case "traffic_order": |
|||
trafficCredit, err = creditPaidTrafficOrder(tx, transaction.BusinessOrderID, transaction.UserID, now) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
creditedUserID = trafficCredit.UserID |
|||
case "sim_recharge_order": |
|||
var order model.SimRechargeOrder |
|||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND user_id = ?", transaction.BusinessOrderID, transaction.UserID).First(&order).Error; err != nil { |
|||
return err |
|||
} |
|||
result := tx.Model(&model.SimRechargeOrder{}).Where("id = ? AND user_id = ? AND payment_status = ? AND fulfillment_status = ?", order.ID, transaction.UserID, "processing", "pending").Updates(map[string]any{ |
|||
"payment_status": "paid", "paid_at": now, "fulfillment_status": "submitting", "attempt_count": 1, |
|||
"fulfillment_started_at": now, "fulfillment_message": "续费提交处理中;如状态未更新,请联系管理员。", |
|||
"fulfillment_lease_token": nil, "fulfillment_lease_until": nil, "next_attempt_at": nil, |
|||
"last_error_code": "", "last_error_message": "", "updated_at": now, |
|||
}) |
|||
if result.Error != nil { |
|||
return result.Error |
|||
} |
|||
if result.RowsAffected != 1 { |
|||
callbackAudit = "conflict" |
|||
busiErr = common.ErrPaymentOrderConflict |
|||
return busiErr |
|||
} |
|||
order.PaymentStatus = "paid" |
|||
order.FulfillmentStatus = "submitting" |
|||
order.AttemptCount = 1 |
|||
order.PaidAt = &now |
|||
order.FulfillmentStartedAt = &now |
|||
order.FulfillmentMessage = "续费提交处理中;如状态未更新,请联系管理员。" |
|||
simOrderToSubmit = &order |
|||
default: |
|||
callbackAudit = "conflict" |
|||
busiErr = common.ErrPaymentOrderConflict |
|||
return busiErr |
|||
} |
|||
callbackAudit = "paid" |
|||
return tx.Model(&event).Updates(map[string]any{"processed_at": now, "result_code": "paid"}).Error |
|||
}) |
|||
if err != nil { |
|||
if callbackAudit == "conflict" && callbackTransaction.ID != 0 { |
|||
DefaultOperationLogService.RecordEvent(callbackTransaction.UserID, "充值与履约", "AGPay回调冲突", fmt.Sprintf("transactionId=%d businessType=%s businessOrderId=%d", callbackTransaction.ID, callbackTransaction.BusinessType, callbackTransaction.BusinessOrderID), "failed", "agpay_callback") |
|||
} |
|||
if busiErr != nil { |
|||
return busiErr |
|||
} |
|||
logger.ERROR("确认 AGPay 支付回调失败", err) |
|||
return common.ErrInternal |
|||
} |
|||
if callbackAudit != "" { |
|||
action := "确认AGPay支付回调" |
|||
if callbackAudit == "duplicate" { |
|||
action = "忽略重复AGPay回调" |
|||
} else if callbackAudit == "cancelled" || callbackAudit == "closed" { |
|||
action = "忽略迟到AGPay回调" |
|||
} |
|||
DefaultOperationLogService.RecordEvent(callbackTransaction.UserID, "充值与履约", action, fmt.Sprintf("transactionId=%d businessType=%s businessOrderId=%d result=%s", callbackTransaction.ID, callbackTransaction.BusinessType, callbackTransaction.BusinessOrderID, callbackAudit), "success", "agpay_callback") |
|||
} |
|||
if trafficCredit != nil { |
|||
DefaultOperationLogService.RecordEvent(trafficCredit.UserID, "充值与履约", "流量充值入账", fmt.Sprintf("orderId=%d ledgerId=%d amountBytes=%d balanceBefore=%d balanceAfter=%d", trafficCredit.OrderID, trafficCredit.LedgerID, trafficCredit.AmountBytes, trafficCredit.BalanceBefore, trafficCredit.BalanceAfter), "success", "agpay_callback") |
|||
} |
|||
if creditedUserID != 0 { |
|||
if err := common.Delete(cache.TrafficKeyOf(creditedUserID)); err != nil { |
|||
logger.WARN("删除支付入账余额缓存失败", err) |
|||
} |
|||
} |
|||
if simOrderToSubmit != nil { |
|||
DefaultOperationLogService.RecordEvent(simOrderToSubmit.UserID, "充值与履约", "启动SIM续费履约", fmt.Sprintf("orderId=%d attempt=1", simOrderToSubmit.ID), "success", "agpay_callback") |
|||
DefaultSimRechargeService.SubmitPaidOrderOnce(simOrderToSubmit) |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
func creditPaidTrafficOrder(tx *gorm.DB, orderID, expectedUserID int64, now time.Time) (*trafficCreditAudit, error) { |
|||
var order model.TrafficOrder |
|||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND user_id = ?", orderID, expectedUserID).First(&order).Error; err != nil { |
|||
return nil, err |
|||
} |
|||
if order.PayStatus == "paid" && order.CreditStatus == "credited" { |
|||
return nil, nil |
|||
} |
|||
if order.PayStatus != "processing" || order.CreditStatus != "pending" || order.AmountBytes <= 0 { |
|||
return nil, common.ErrPaymentOrderConflict |
|||
} |
|||
var user model.User |
|||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id", "traffic_balance").First(&user, order.UserID).Error; err != nil { |
|||
return nil, err |
|||
} |
|||
ledgerID := mustID() |
|||
if ledgerID == 0 { |
|||
return nil, errors.New("generate traffic credit ledger ID") |
|||
} |
|||
after := user.TrafficBalance + order.AmountBytes |
|||
if err := tx.Model(&user).UpdateColumn("traffic_balance", after).Error; err != nil { |
|||
return nil, err |
|||
} |
|||
idempotencyKey := "payment:" + strconv.FormatInt(order.ID, 10) |
|||
if err := tx.Create(&model.TrafficLedger{ |
|||
ID: ledgerID, AccountType: "user", AccountID: order.UserID, Direction: "credit", AmountBytes: order.AmountBytes, |
|||
BalanceBefore: user.TrafficBalance, BalanceAfter: after, SourceType: "traffic_order", SourceID: strconv.FormatInt(order.ID, 10), |
|||
IdempotencyKey: idempotencyKey, CreatedAt: now, |
|||
}).Error; err != nil { |
|||
return nil, err |
|||
} |
|||
result := tx.Model(&model.TrafficOrder{}).Where("id = ? AND user_id = ? AND pay_status = ? AND credit_status = ?", order.ID, expectedUserID, "processing", "pending").Updates(map[string]any{ |
|||
"pay_status": "paid", "payment_provider": "agpay", |
|||
"credit_status": "credited", "credit_ledger_id": ledgerID, "payment_idempotency_key": idempotencyKey, |
|||
"paid_at": now, "updated_at": now, |
|||
}) |
|||
if result.Error != nil { |
|||
return nil, result.Error |
|||
} |
|||
if result.RowsAffected != 1 { |
|||
return nil, common.ErrPaymentOrderConflict |
|||
} |
|||
return &trafficCreditAudit{UserID: order.UserID, OrderID: order.ID, LedgerID: ledgerID, AmountBytes: order.AmountBytes, BalanceBefore: user.TrafficBalance, BalanceAfter: after}, nil |
|||
} |
|||
@ -0,0 +1,324 @@ |
|||
package service |
|||
|
|||
import ( |
|||
"errors" |
|||
"fmt" |
|||
"strconv" |
|||
"time" |
|||
|
|||
"gorm.io/gorm" |
|||
"gorm.io/gorm/clause" |
|||
|
|||
"laic-backend/client" |
|||
"laic-backend/common" |
|||
"laic-backend/logger" |
|||
"laic-backend/model" |
|||
"laic-backend/tool" |
|||
) |
|||
|
|||
type simbossAdapter struct { |
|||
client *client.SimbossClient |
|||
} |
|||
|
|||
func InitSimboss(conf common.Simboss) error { |
|||
if conf.AppID == "" && conf.Secret == "" { |
|||
return nil |
|||
} |
|||
instance, err := client.NewSimbossClient(conf.AppID, conf.Secret, conf.APIBase) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
DefaultSimRechargeService.simboss = &simbossAdapter{client: instance} |
|||
return nil |
|||
} |
|||
|
|||
type SimRechargeService struct { |
|||
simboss *simbossAdapter |
|||
} |
|||
|
|||
var DefaultSimRechargeService = &SimRechargeService{} |
|||
|
|||
func (s *SimRechargeService) GetCard(userID, cardID int64, refresh bool) (*model.SimCard, *common.BusiError) { |
|||
var card model.SimCard |
|||
if err := common.DB.Where("id = ? AND user_id = ?", cardID, userID).First(&card).Error; err != nil { |
|||
if errors.Is(err, gorm.ErrRecordNotFound) { |
|||
return nil, common.ErrSimCardNotFound |
|||
} |
|||
return nil, common.ErrInternal |
|||
} |
|||
if refresh { |
|||
if s.simboss == nil { |
|||
return nil, common.ErrCarrierUnavailable |
|||
} |
|||
if err := s.refreshCard(&card); err != nil { |
|||
logger.WARN("刷新 SIM 卡信息失败", card.ID, err) |
|||
return nil, common.ErrCarrierUnavailable |
|||
} |
|||
} |
|||
return &card, nil |
|||
} |
|||
|
|||
func (s *SimRechargeService) ListPackages() ([]model.SimPackage, *common.BusiError) { |
|||
var packages []model.SimPackage |
|||
if err := common.DB.Where("status = ?", "active").Order("sale_fee_fen ASC, id ASC").Find(&packages).Error; err != nil { |
|||
return nil, common.ErrInternal |
|||
} |
|||
return packages, nil |
|||
} |
|||
|
|||
func (s *SimRechargeService) CreateOrder(userID, cardID, packageID int64) (*model.SimRechargeOrder, *common.BusiError) { |
|||
var order model.SimRechargeOrder |
|||
now := time.Now() |
|||
var busiErr *common.BusiError |
|||
err := common.DB.Transaction(func(tx *gorm.DB) error { |
|||
var card model.SimCard |
|||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND user_id = ?", cardID, userID).First(&card).Error; err != nil { |
|||
return err |
|||
} |
|||
var pending model.SimRechargeOrder |
|||
if err := tx.Where("sim_card_id = ? AND payment_status IN ?", cardID, []string{"unpaid", "processing"}).First(&pending).Error; err == nil { |
|||
busiErr = common.ErrPendingOrderExists |
|||
return busiErr |
|||
} else if !errors.Is(err, gorm.ErrRecordNotFound) { |
|||
return err |
|||
} |
|||
if card.Status != "active" || card.CarrierStatus == "cancelled" || card.CarrierStatus == "suspended" || card.Iccid == "" { |
|||
busiErr = common.ErrParam |
|||
return busiErr |
|||
} |
|||
var pkg model.SimPackage |
|||
if err := tx.Where("id = ? AND status = ?", packageID, "active").First(&pkg).Error; err != nil { |
|||
return err |
|||
} |
|||
if pkg.ProviderRatePlanID <= 0 || pkg.AmountGb <= 0 || pkg.ValidityMonths <= 0 || pkg.SaleFeeFen <= 0 { |
|||
busiErr = common.ErrParam |
|||
return busiErr |
|||
} |
|||
if card.Carrier != "" && pkg.Carrier != "" && card.Carrier != pkg.Carrier { |
|||
busiErr = common.ErrParam |
|||
return busiErr |
|||
} |
|||
id, err := tool.NextID() |
|||
if err != nil { |
|||
return err |
|||
} |
|||
order = model.SimRechargeOrder{ID: id, UserID: userID, SimCardID: card.ID, PackageID: pkg.ID, PackageCode: pkg.Code, ProviderRatePlanID: pkg.ProviderRatePlanID, IccidSnapshot: card.Iccid, AmountGb: pkg.AmountGb, ValidityMonths: pkg.ValidityMonths, TotalFeeFen: pkg.SaleFeeFen, PaymentStatus: "unpaid", FulfillmentStatus: "pending", ExternalOrderNo: "sim-" + strconv.FormatInt(id, 10), CreatedAt: now, UpdatedAt: now} |
|||
return tx.Create(&order).Error |
|||
}) |
|||
if err != nil { |
|||
if busiErr != nil { |
|||
return nil, busiErr |
|||
} |
|||
if errors.Is(err, gorm.ErrRecordNotFound) { |
|||
return nil, common.ErrSimCardNotFound |
|||
} |
|||
if code, _ := common.ParseError(err); code == 1062 { |
|||
return nil, common.ErrPendingOrderExists |
|||
} |
|||
logger.ERROR("创建 SIM 续费订单失败", err) |
|||
return nil, common.ErrInternal |
|||
} |
|||
DefaultOperationLogService.RecordEvent(userID, "充值与履约", "创建SIM续费订单", fmt.Sprintf("orderId=%d simCardId=%d feeFen=%d", order.ID, order.SimCardID, order.TotalFeeFen), "success", "user_api") |
|||
return &order, nil |
|||
} |
|||
|
|||
func (s *SimRechargeService) CloseExpiredOrders(cutoff time.Time) { |
|||
var orders []model.SimRechargeOrder |
|||
if err := common.DB.Where("payment_status IN ? AND created_at <= ?", []string{"unpaid", "processing"}, cutoff).Limit(100).Find(&orders).Error; err != nil { |
|||
logger.ERROR("扫描超时 SIM 订单失败", err) |
|||
return |
|||
} |
|||
for i := range orders { |
|||
now := time.Now() |
|||
closed := false |
|||
err := common.DB.Transaction(func(tx *gorm.DB) error { |
|||
result := tx.Model(&model.SimRechargeOrder{}).Where("id = ? AND payment_status IN ? AND created_at <= ?", orders[i].ID, []string{"unpaid", "processing"}, cutoff).Updates(map[string]any{"payment_status": "closed", "closed_at": now, "updated_at": now}) |
|||
if result.Error != nil { |
|||
return result.Error |
|||
} |
|||
if result.RowsAffected != 1 { |
|||
return nil |
|||
} |
|||
closed = true |
|||
return tx.Model(&model.PaymentTransaction{}).Where("business_type = ? AND business_order_id = ? AND status IN ?", "sim_recharge_order", orders[i].ID, []string{"unpaid", "processing"}).Updates(map[string]any{"status": "closed", "updated_at": now}).Error |
|||
}) |
|||
if err != nil { |
|||
logger.ERROR("关闭超时 SIM 订单失败", err) |
|||
continue |
|||
} |
|||
if closed { |
|||
DefaultOperationLogService.RecordEvent(orders[i].UserID, "充值与履约", "自动关闭SIM续费订单", fmt.Sprintf("orderId=%d status=closed", orders[i].ID), "success", "system") |
|||
} |
|||
} |
|||
} |
|||
|
|||
func (s *SimRechargeService) CancelOrder(userID, orderID int64) *common.BusiError { |
|||
now := time.Now() |
|||
var busiErr *common.BusiError |
|||
err := common.DB.Transaction(func(tx *gorm.DB) error { |
|||
var order model.SimRechargeOrder |
|||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND user_id = ?", orderID, userID).First(&order).Error; err != nil { |
|||
return err |
|||
} |
|||
if order.PaymentStatus != "unpaid" && order.PaymentStatus != "processing" { |
|||
busiErr = common.ErrOrderNotCancellable |
|||
return busiErr |
|||
} |
|||
result := tx.Model(&order).Where("id = ? AND payment_status IN ?", orderID, []string{"unpaid", "processing"}).Updates(map[string]any{"payment_status": "cancelled", "closed_at": now, "updated_at": now}) |
|||
if result.Error != nil { |
|||
return result.Error |
|||
} |
|||
if result.RowsAffected != 1 { |
|||
busiErr = common.ErrOrderNotCancellable |
|||
return busiErr |
|||
} |
|||
return tx.Model(&model.PaymentTransaction{}).Where("business_type = ? AND business_order_id = ? AND status IN ?", "sim_recharge_order", orderID, []string{"unpaid", "processing"}).Updates(map[string]any{"status": "cancelled", "updated_at": now}).Error |
|||
}) |
|||
if err != nil { |
|||
if busiErr != nil { |
|||
return busiErr |
|||
} |
|||
if errors.Is(err, gorm.ErrRecordNotFound) { |
|||
return common.ErrOrderNotFound |
|||
} |
|||
logger.ERROR("取消 SIM 订单失败", err) |
|||
return common.ErrInternal |
|||
} |
|||
DefaultOperationLogService.RecordEvent(userID, "充值与履约", "取消SIM续费订单", fmt.Sprintf("orderId=%d status=cancelled", orderID), "success", "user_api") |
|||
return nil |
|||
} |
|||
|
|||
func (s *SimRechargeService) GetOrder(userID, orderID int64) (*model.SimRechargeOrder, *common.BusiError) { |
|||
var order model.SimRechargeOrder |
|||
if err := common.DB.Where("id = ? AND user_id = ?", orderID, userID).First(&order).Error; err != nil { |
|||
if errors.Is(err, gorm.ErrRecordNotFound) { |
|||
return nil, common.ErrOrderNotFound |
|||
} |
|||
return nil, common.ErrInternal |
|||
} |
|||
return &order, nil |
|||
} |
|||
|
|||
func (s *SimRechargeService) SubmitPaidOrderOnce(order *model.SimRechargeOrder) { |
|||
const supportMessage = "续费处理异常,请联系管理员。" |
|||
DefaultOperationLogService.RecordEvent(order.UserID, "充值与履约", "提交SIMBOSS履约", fmt.Sprintf("orderId=%d attempt=1", order.ID), "success", "simboss") |
|||
if s.simboss == nil { |
|||
DefaultOperationLogService.RecordEvent(order.UserID, "充值与履约", "SIMBOSS履约转人工处理", fmt.Sprintf("orderId=%d errorCode=simboss_unavailable", order.ID), "failed", "simboss") |
|||
s.markManualReview(order.ID, "simboss_unavailable", supportMessage, errors.New("SIMBOSS is not configured")) |
|||
return |
|||
} |
|||
providerOrderNo, err := s.simboss.client.Recharge(order.IccidSnapshot, order.ProviderRatePlanID, order.ValidityMonths, order.ExternalOrderNo) |
|||
if err != nil { |
|||
logger.ERROR("SIM 续费提交 SIMBOSS 失败", err) |
|||
DefaultOperationLogService.RecordEvent(order.UserID, "充值与履约", "SIMBOSS履约转人工处理", fmt.Sprintf("orderId=%d errorCode=simboss_request_failed", order.ID), "failed", "simboss") |
|||
s.markManualReview(order.ID, "simboss_request_failed", supportMessage, err) |
|||
return |
|||
} |
|||
if providerOrderNo == "" { |
|||
err = errors.New("SIMBOSS returned an empty order number") |
|||
logger.ERROR("SIMBOSS 返回空订单号", err) |
|||
DefaultOperationLogService.RecordEvent(order.UserID, "充值与履约", "SIMBOSS履约转人工处理", fmt.Sprintf("orderId=%d errorCode=simboss_empty_order_no", order.ID), "failed", "simboss") |
|||
s.markManualReview(order.ID, "simboss_empty_order_no", supportMessage, err) |
|||
return |
|||
} |
|||
now := time.Now() |
|||
result := common.DB.Model(&model.SimRechargeOrder{}).Where("id = ? AND payment_status = ? AND fulfillment_status = ? AND attempt_count = ?", order.ID, "paid", "submitting", 1).Updates(map[string]any{ |
|||
"fulfillment_status": "success", "provider_order_no": providerOrderNo, "fulfilled_at": now, |
|||
"fulfillment_message": "", "last_error_code": "", "last_error_message": "", "updated_at": now, |
|||
}) |
|||
if result.Error != nil { |
|||
logger.ERROR("更新 SIM 续费履约结果失败", result.Error) |
|||
return |
|||
} |
|||
if result.RowsAffected != 1 { |
|||
logger.WARN("SIM 续费履约状态已变化", order.ID, errors.New("stale fulfillment state")) |
|||
} |
|||
} |
|||
|
|||
func (s *SimRechargeService) markManualReview(orderID int64, code, message string, cause error) { |
|||
now := time.Now() |
|||
result := common.DB.Model(&model.SimRechargeOrder{}).Where("id = ? AND payment_status = ? AND fulfillment_status = ? AND attempt_count = ?", orderID, "paid", "submitting", 1).Updates(map[string]any{ |
|||
"fulfillment_status": "manual_review", "fulfillment_message": message, |
|||
"last_error_code": code, "last_error_message": cause.Error(), "next_attempt_at": nil, |
|||
"fulfillment_lease_token": nil, "fulfillment_lease_until": nil, "updated_at": now, |
|||
}) |
|||
if result.Error != nil { |
|||
logger.ERROR("标记 SIM 续费人工处理失败", result.Error) |
|||
return |
|||
} |
|||
if result.RowsAffected != 1 { |
|||
logger.WARN("SIM 续费人工处理状态已变化", orderID, errors.New("stale fulfillment state")) |
|||
} |
|||
} |
|||
|
|||
func (s *SimRechargeService) refreshCard(card *model.SimCard) error { |
|||
detail, err := s.simboss.client.GetDeviceDetail(card.Iccid) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
now := time.Now() |
|||
updates := map[string]any{ |
|||
"carrier": detail.Carrier, "carrier_status": normalizeCarrierStatus(detail.Status, detail.DeviceStatus), |
|||
"used_gb": detail.DataUsage / 1024, "last_sync_at": now, "updated_at": now, |
|||
} |
|||
if detail.RatePlanExpirationDate != "" { |
|||
if expiresAt, err := parseSimbossDate(detail.RatePlanExpirationDate); err == nil { |
|||
updates["expired_at"] = expiresAt |
|||
} |
|||
} |
|||
if updates["carrier_status"] == "cancelled" { |
|||
updates["status"] = "expired" |
|||
} |
|||
if err := common.DB.Model(card).Updates(updates).Error; err != nil { |
|||
return err |
|||
} |
|||
return common.DB.First(card, card.ID).Error |
|||
} |
|||
|
|||
func parseSimbossDate(value string) (time.Time, error) { |
|||
for _, layout := range []string{"2006-01-02", time.RFC3339, "2006-01-02 15:04:05"} { |
|||
if parsed, err := time.ParseInLocation(layout, value, time.Local); err == nil { |
|||
return parsed, nil |
|||
} |
|||
} |
|||
return time.Time{}, errors.New("invalid SIMBOSS date") |
|||
} |
|||
|
|||
func normalizeCarrierStatus(status, deviceStatus string) string { |
|||
value := status + " " + deviceStatus |
|||
switch { |
|||
case containsFold(value, "cancel"): |
|||
return "cancelled" |
|||
case containsFold(value, "arrear"): |
|||
return "arrears" |
|||
case containsFold(value, "suspend"): |
|||
return "suspended" |
|||
default: |
|||
return "normal" |
|||
} |
|||
} |
|||
|
|||
func containsFold(value, fragment string) bool { |
|||
for i := 0; i+len(fragment) <= len(value); i++ { |
|||
if equalFoldASCII(value[i:i+len(fragment)], fragment) { |
|||
return true |
|||
} |
|||
} |
|||
return false |
|||
} |
|||
|
|||
func equalFoldASCII(value, fragment string) bool { |
|||
for i := range value { |
|||
left, right := value[i], fragment[i] |
|||
if left >= 'A' && left <= 'Z' { |
|||
left += 'a' - 'A' |
|||
} |
|||
if right >= 'A' && right <= 'Z' { |
|||
right += 'a' - 'A' |
|||
} |
|||
if left != right { |
|||
return false |
|||
} |
|||
} |
|||
return true |
|||
} |
|||
@ -0,0 +1,91 @@ |
|||
-- 支付与 SIM 套餐续费基础。执行前请先备份生产数据库。 |
|||
|
|||
ALTER TABLE traffic_order |
|||
ADD COLUMN total_fee_fen BIGINT NOT NULL DEFAULT 0 COMMENT '固定支付金额(分)' AFTER total_price, |
|||
ADD COLUMN payment_provider VARCHAR(32) DEFAULT '' COMMENT '支付渠道' AFTER pay_status, |
|||
ADD COLUMN provider_trade_no VARCHAR(128) DEFAULT '' COMMENT '第三方交易号' AFTER payment_provider, |
|||
ADD COLUMN credit_attempt_count INT NOT NULL DEFAULT 0 COMMENT '入账尝试次数' AFTER credit_status, |
|||
ADD COLUMN next_credit_at DATETIME NULL COMMENT '下次入账时间' AFTER credit_attempt_count, |
|||
ADD COLUMN closed_at DATETIME NULL COMMENT '关闭时间' AFTER paid_at, |
|||
ADD UNIQUE KEY uk_traffic_order_provider_trade (payment_provider, provider_trade_no), |
|||
ADD KEY idx_traffic_order_credit_retry (pay_status, credit_status, next_credit_at); |
|||
|
|||
UPDATE traffic_order |
|||
SET total_fee_fen = ROUND(total_price * 100) |
|||
WHERE total_fee_fen = 0 AND total_price IS NOT NULL; |
|||
|
|||
CREATE TABLE IF NOT EXISTS sim_package ( |
|||
id BIGINT PRIMARY KEY COMMENT '主键 ID', |
|||
code VARCHAR(64) NOT NULL COMMENT '平台套餐编码', |
|||
name VARCHAR(128) NOT NULL COMMENT '套餐名称', |
|||
carrier VARCHAR(16) NOT NULL COMMENT '运营商', |
|||
provider_rate_plan_id BIGINT NOT NULL COMMENT 'SIMBOSS 套餐标识', |
|||
amount_gb INT NOT NULL COMMENT '流量额度 GB', |
|||
validity_months INT NOT NULL COMMENT '有效月数', |
|||
cost_fee_fen BIGINT NOT NULL COMMENT '采购成本(分)', |
|||
sale_fee_fen BIGINT NOT NULL COMMENT '销售价格(分)', |
|||
status VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT 'active/inactive', |
|||
created_at DATETIME NULL COMMENT '创建时间', |
|||
updated_at DATETIME NULL COMMENT '更新时间', |
|||
UNIQUE KEY uk_sim_package_code (code), |
|||
UNIQUE KEY uk_sim_package_provider_plan (carrier, provider_rate_plan_id), |
|||
KEY idx_sim_package_status (status) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; |
|||
|
|||
CREATE TABLE IF NOT EXISTS sim_recharge_order ( |
|||
id BIGINT PRIMARY KEY COMMENT '主键 ID', |
|||
user_id BIGINT NOT NULL COMMENT '所属用户 ID', |
|||
sim_card_id BIGINT NOT NULL COMMENT 'SIM 卡 ID', |
|||
package_id BIGINT NOT NULL COMMENT '套餐 ID', |
|||
package_code VARCHAR(64) NOT NULL COMMENT '套餐编码快照', |
|||
provider_rate_plan_id BIGINT NOT NULL COMMENT 'SIMBOSS 套餐标识快照', |
|||
iccid_snapshot VARCHAR(32) NOT NULL COMMENT 'ICCID 快照,仅供履约', |
|||
amount_gb INT NOT NULL COMMENT '流量额度 GB', |
|||
validity_months INT NOT NULL COMMENT '有效月数', |
|||
total_fee_fen BIGINT NOT NULL COMMENT '固定支付金额(分)', |
|||
payment_status VARCHAR(16) NOT NULL DEFAULT 'unpaid' COMMENT 'unpaid/processing/paid/failed/closed', |
|||
fulfillment_status VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/submitting/retrying/success/failed', |
|||
external_order_no VARCHAR(128) NOT NULL COMMENT '平台稳定外部订单号', |
|||
provider_order_no VARCHAR(128) DEFAULT '' COMMENT '运营商流水号', |
|||
attempt_count INT NOT NULL DEFAULT 0 COMMENT '履约尝试次数', |
|||
next_attempt_at DATETIME NULL COMMENT '下次履约时间', |
|||
last_error_code VARCHAR(64) DEFAULT '' COMMENT '最后错误代码', |
|||
last_error_message VARCHAR(256) DEFAULT '' COMMENT '最后错误信息', |
|||
created_at DATETIME NULL COMMENT '创建时间', |
|||
paid_at DATETIME NULL COMMENT '支付时间', |
|||
fulfilled_at DATETIME NULL COMMENT '履约时间', |
|||
updated_at DATETIME NULL COMMENT '更新时间', |
|||
UNIQUE KEY uk_sim_recharge_external (external_order_no), |
|||
KEY idx_sim_recharge_user_created (user_id, created_at), |
|||
KEY idx_sim_recharge_fulfillment_retry (payment_status, fulfillment_status, next_attempt_at) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; |
|||
|
|||
CREATE TABLE IF NOT EXISTS payment_transaction ( |
|||
id BIGINT PRIMARY KEY COMMENT '主键 ID', |
|||
user_id BIGINT NOT NULL COMMENT '付款用户 ID', |
|||
business_type VARCHAR(32) NOT NULL COMMENT 'traffic_order/sim_recharge_order', |
|||
business_order_id BIGINT NOT NULL COMMENT '业务订单 ID', |
|||
provider VARCHAR(32) NOT NULL COMMENT '支付渠道', |
|||
merchant_order_no VARCHAR(128) NOT NULL COMMENT '商户订单号', |
|||
provider_trade_no VARCHAR(128) DEFAULT '' COMMENT '第三方交易号', |
|||
total_fee_fen BIGINT NOT NULL COMMENT '固定金额(分)', |
|||
status VARCHAR(16) NOT NULL DEFAULT 'unpaid' COMMENT 'unpaid/processing/paid/failed/closed', |
|||
created_at DATETIME NULL COMMENT '创建时间', |
|||
paid_at DATETIME NULL COMMENT '支付时间', |
|||
updated_at DATETIME NULL COMMENT '更新时间', |
|||
UNIQUE KEY uk_payment_merchant_order (provider, merchant_order_no), |
|||
UNIQUE KEY uk_payment_provider_trade (provider, provider_trade_no), |
|||
KEY idx_payment_business (business_type, business_order_id) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; |
|||
|
|||
CREATE TABLE IF NOT EXISTS payment_callback_event ( |
|||
id BIGINT PRIMARY KEY COMMENT '主键 ID', |
|||
provider VARCHAR(32) NOT NULL COMMENT '支付渠道', |
|||
event_key VARCHAR(128) NOT NULL COMMENT '回调事件幂等键', |
|||
payload_hash VARCHAR(64) NOT NULL COMMENT '载荷哈希', |
|||
verified TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否通过验签', |
|||
processed_at DATETIME NULL COMMENT '处理时间', |
|||
result_code VARCHAR(64) DEFAULT '' COMMENT '处理结果', |
|||
created_at DATETIME NULL COMMENT '创建时间', |
|||
UNIQUE KEY uk_payment_callback_event (provider, event_key) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; |
|||
@ -0,0 +1,12 @@ |
|||
-- 为已存在的 sim_recharge_order 表补充套餐快照字段。 |
|||
-- 新部署使用 014_payment_and_sim_recharge.sql 建表时已包含此列,无需重复执行本文件。 |
|||
ALTER TABLE sim_recharge_order |
|||
ADD COLUMN provider_rate_plan_id BIGINT NOT NULL DEFAULT 0 COMMENT 'SIMBOSS 套餐标识快照' AFTER package_code; |
|||
|
|||
UPDATE sim_recharge_order o |
|||
LEFT JOIN sim_package p ON p.id = o.package_id |
|||
SET o.provider_rate_plan_id = COALESCE(p.provider_rate_plan_id, 0) |
|||
WHERE o.provider_rate_plan_id = 0; |
|||
|
|||
ALTER TABLE sim_recharge_order |
|||
ALTER COLUMN provider_rate_plan_id DROP DEFAULT; |
|||
@ -0,0 +1,22 @@ |
|||
-- 支付与 SIM 履约加固。执行前请备份数据库,并先检查现有重复数据。 |
|||
|
|||
UPDATE payment_transaction SET provider_trade_no = NULL WHERE provider_trade_no = ''; |
|||
UPDATE traffic_order SET provider_trade_no = NULL WHERE provider_trade_no = ''; |
|||
|
|||
ALTER TABLE payment_transaction |
|||
DROP INDEX uk_payment_provider_trade, |
|||
MODIFY COLUMN provider_trade_no VARCHAR(128) NULL DEFAULT NULL COMMENT '第三方交易号', |
|||
ADD COLUMN provider_payload TEXT NULL COMMENT 'AGPay下单响应快照' AFTER provider_trade_no, |
|||
ADD COLUMN requested_at DATETIME NULL COMMENT '最近一次请求AGPay时间' AFTER status, |
|||
ADD UNIQUE KEY uk_payment_provider_trade (provider, provider_trade_no), |
|||
ADD UNIQUE KEY uk_payment_business (provider, business_type, business_order_id); |
|||
|
|||
ALTER TABLE traffic_order |
|||
DROP INDEX uk_traffic_order_provider_trade, |
|||
MODIFY COLUMN provider_trade_no VARCHAR(128) NULL DEFAULT NULL COMMENT '第三方交易号', |
|||
ADD UNIQUE KEY uk_traffic_order_provider_trade (payment_provider, provider_trade_no); |
|||
|
|||
ALTER TABLE sim_recharge_order |
|||
ADD COLUMN fulfillment_lease_token VARCHAR(64) NULL COMMENT '履约租约令牌' AFTER fulfillment_status, |
|||
ADD COLUMN fulfillment_lease_until DATETIME NULL COMMENT '履约租约截止时间' AFTER fulfillment_lease_token, |
|||
ADD KEY idx_sim_recharge_lease (fulfillment_status, fulfillment_lease_until); |
|||
@ -0,0 +1,26 @@ |
|||
-- 待支付订单约束与超时扫描优化。执行前请确认不存在重复的 unpaid/processing 订单。 |
|||
|
|||
ALTER TABLE traffic_order |
|||
MODIFY COLUMN pay_status VARCHAR(16) NOT NULL DEFAULT 'unpaid' COMMENT 'unpaid/processing/paid/cancelled/closed', |
|||
ADD KEY idx_traffic_order_pending_expire (pay_status, created_at, id); |
|||
|
|||
ALTER TABLE sim_recharge_order |
|||
ADD COLUMN closed_at DATETIME NULL COMMENT '关闭时间' AFTER paid_at, |
|||
MODIFY COLUMN payment_status VARCHAR(16) NOT NULL DEFAULT 'unpaid' COMMENT 'unpaid/processing/paid/cancelled/closed', |
|||
ADD KEY idx_sim_recharge_pending_expire (payment_status, created_at, id); |
|||
|
|||
ALTER TABLE payment_transaction |
|||
MODIFY COLUMN status VARCHAR(16) NOT NULL DEFAULT 'unpaid' COMMENT 'unpaid/processing/paid/failed/cancelled/closed', |
|||
ADD KEY idx_payment_business_status (business_type, business_order_id, status); |
|||
|
|||
ALTER TABLE traffic_order |
|||
ADD COLUMN pending_user_id BIGINT GENERATED ALWAYS AS ( |
|||
CASE WHEN pay_status IN ('unpaid', 'processing') THEN user_id ELSE NULL END |
|||
) STORED, |
|||
ADD UNIQUE KEY uk_traffic_order_pending_user (pending_user_id); |
|||
|
|||
ALTER TABLE sim_recharge_order |
|||
ADD COLUMN pending_sim_card_id BIGINT GENERATED ALWAYS AS ( |
|||
CASE WHEN payment_status IN ('unpaid', 'processing') THEN sim_card_id ELSE NULL END |
|||
) STORED, |
|||
ADD UNIQUE KEY uk_sim_recharge_pending_card (pending_sim_card_id); |
|||
@ -0,0 +1,15 @@ |
|||
-- SIM 续费改为支付成功后仅提交一次;异常订单转人工处理,禁止自动重试。 |
|||
|
|||
ALTER TABLE sim_recharge_order |
|||
ADD COLUMN fulfillment_started_at DATETIME NULL COMMENT '首次提交 SIMBOSS 时间' AFTER fulfillment_status, |
|||
ADD COLUMN fulfillment_message VARCHAR(128) NOT NULL DEFAULT '' COMMENT '用户可见履约提示' AFTER last_error_message, |
|||
MODIFY COLUMN fulfillment_status VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/submitting/success/manual_review'; |
|||
|
|||
UPDATE sim_recharge_order |
|||
SET fulfillment_status = 'manual_review', |
|||
fulfillment_message = '续费处理异常,请联系管理员。', |
|||
next_attempt_at = NULL, |
|||
fulfillment_lease_token = NULL, |
|||
fulfillment_lease_until = NULL, |
|||
last_error_code = CASE WHEN last_error_code = '' THEN 'legacy_manual_review' ELSE last_error_code END |
|||
WHERE payment_status = 'paid' AND fulfillment_status <> 'success'; |
|||
Loading…
Reference in new issue