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