修复拉卡拉支付时间偏差
This commit is contained in:
@@ -18,6 +18,8 @@ RUN --mount=type=cache,target=/go/pkg/mod \
|
|||||||
FROM alpine:3.22
|
FROM alpine:3.22
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
RUN apk add --no-cache tzdata
|
||||||
COPY --from=build /out/hfb-api /app/hfb-api
|
COPY --from=build /out/hfb-api /app/hfb-api
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
CMD ["/app/hfb-api"]
|
CMD ["/app/hfb-api"]
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/timeutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -174,11 +176,12 @@ func (c *Client) CreatePayment(ctx context.Context, req CreatePaymentRequest) (*
|
|||||||
if expireMinutes <= 0 {
|
if expireMinutes <= 0 {
|
||||||
expireMinutes = 15
|
expireMinutes = 15
|
||||||
}
|
}
|
||||||
|
now := timeutil.ShanghaiNow()
|
||||||
reqData := map[string]any{
|
reqData := map[string]any{
|
||||||
"out_order_no": req.ThirdOrderID,
|
"out_order_no": req.ThirdOrderID,
|
||||||
"merchant_no": c.cfg.MerchantID,
|
"merchant_no": c.cfg.MerchantID,
|
||||||
"total_amount": req.AmountCent,
|
"total_amount": req.AmountCent,
|
||||||
"order_efficient_time": time.Now().Add(time.Duration(expireMinutes) * time.Minute).Format("20060102150405"),
|
"order_efficient_time": now.Add(time.Duration(expireMinutes) * time.Minute).Format("20060102150405"),
|
||||||
"notify_url": firstNonEmpty(req.NotifyURL, c.cfg.NotifyURL),
|
"notify_url": firstNonEmpty(req.NotifyURL, c.cfg.NotifyURL),
|
||||||
"support_refund": 1,
|
"support_refund": 1,
|
||||||
"support_repeat_pay": 1,
|
"support_repeat_pay": 1,
|
||||||
@@ -364,7 +367,7 @@ func ParsePayload(body []byte) (map[string]string, error) {
|
|||||||
|
|
||||||
func (c *Client) post(ctx context.Context, endpoint string, reqData map[string]any) (map[string]string, error) {
|
func (c *Client) post(ctx context.Context, endpoint string, reqData map[string]any) (map[string]string, error) {
|
||||||
body, err := json.Marshal(map[string]any{
|
body, err := json.Marshal(map[string]any{
|
||||||
"req_time": time.Now().Format("20060102150405"),
|
"req_time": timeutil.ShanghaiNow().Format("20060102150405"),
|
||||||
"version": "3.0",
|
"version": "3.0",
|
||||||
"req_data": reqData,
|
"req_data": reqData,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/timeutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestVerifyNotifyDetail(t *testing.T) {
|
func TestVerifyNotifyDetail(t *testing.T) {
|
||||||
@@ -90,6 +93,41 @@ func TestCreatePaymentSetsCounterParamWhenPayModeConfigured(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreatePaymentUsesShanghaiTimeWhenLocalIsUTC(t *testing.T) {
|
||||||
|
oldLocal := time.Local
|
||||||
|
time.Local = time.UTC
|
||||||
|
defer func() {
|
||||||
|
time.Local = oldLocal
|
||||||
|
}()
|
||||||
|
|
||||||
|
loc := timeutil.ShanghaiLocation()
|
||||||
|
before := time.Now().In(loc)
|
||||||
|
body := captureCreatePaymentBody(t, "")
|
||||||
|
after := time.Now().In(loc)
|
||||||
|
reqData := body["req_data"].(map[string]any)
|
||||||
|
|
||||||
|
reqTime := parseLakalaTestTime(t, body["req_time"].(string))
|
||||||
|
if reqTime.Before(before.Add(-2*time.Second)) || reqTime.After(after.Add(2*time.Second)) {
|
||||||
|
t.Fatalf("req_time = %s, want between %s and %s", reqTime, before, after)
|
||||||
|
}
|
||||||
|
|
||||||
|
efficientTime := parseLakalaTestTime(t, reqData["order_efficient_time"].(string))
|
||||||
|
wantMin := before.Add(15*time.Minute - 2*time.Second)
|
||||||
|
wantMax := after.Add(15*time.Minute + 2*time.Second)
|
||||||
|
if efficientTime.Before(wantMin) || efficientTime.After(wantMax) {
|
||||||
|
t.Fatalf("order_efficient_time = %s, want between %s and %s", efficientTime, wantMin, wantMax)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLakalaTestTime(t *testing.T, value string) time.Time {
|
||||||
|
t.Helper()
|
||||||
|
parsed, err := time.ParseInLocation("20060102150405", value, timeutil.ShanghaiLocation())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse lakala time %q: %v", value, err)
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
func testKeyPairPEM(t *testing.T, key *rsa.PrivateKey) (string, string) {
|
func testKeyPairPEM(t *testing.T, key *rsa.PrivateKey) (string, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
privateDER := x509.MarshalPKCS1PrivateKey(key)
|
privateDER := x509.MarshalPKCS1PrivateKey(key)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"hfb_sys/backend/internal/modules/chat"
|
"hfb_sys/backend/internal/modules/chat"
|
||||||
"hfb_sys/backend/internal/modules/notification"
|
"hfb_sys/backend/internal/modules/notification"
|
||||||
"hfb_sys/backend/internal/modules/wallet"
|
"hfb_sys/backend/internal/modules/wallet"
|
||||||
|
"hfb_sys/backend/internal/timeutil"
|
||||||
"hfb_sys/backend/pkg/money"
|
"hfb_sys/backend/pkg/money"
|
||||||
|
|
||||||
"gorm.io/datatypes"
|
"gorm.io/datatypes"
|
||||||
@@ -1627,7 +1628,7 @@ func makeAccountSnapshot(account model.GameAccount) (datatypes.JSON, error) {
|
|||||||
func newOrderNo() (string, error) {
|
func newOrderNo() (string, error) {
|
||||||
// 生成格式:RO + YYYYMMDDHHMMSS + 3位随机数
|
// 生成格式:RO + YYYYMMDDHHMMSS + 3位随机数
|
||||||
// 例如:RO20260603123456789
|
// 例如:RO20260603123456789
|
||||||
now := time.Now()
|
now := timeutil.ShanghaiNow()
|
||||||
|
|
||||||
// 时间部分:年月日时分秒 (14位)
|
// 时间部分:年月日时分秒 (14位)
|
||||||
timeStr := now.Format("20060102150405")
|
timeStr := now.Format("20060102150405")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
|
"hfb_sys/backend/internal/timeutil"
|
||||||
|
|
||||||
"gorm.io/datatypes"
|
"gorm.io/datatypes"
|
||||||
)
|
)
|
||||||
@@ -131,3 +132,29 @@ func TestArchiveListingAfterCheckoutMovesListingOffline(t *testing.T) {
|
|||||||
// - Order should enter completed or closed state
|
// - Order should enter completed or closed state
|
||||||
_ = time.Now()
|
_ = time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewOrderNoUsesShanghaiTimeWhenLocalIsUTC(t *testing.T) {
|
||||||
|
oldLocal := time.Local
|
||||||
|
time.Local = time.UTC
|
||||||
|
defer func() {
|
||||||
|
time.Local = oldLocal
|
||||||
|
}()
|
||||||
|
|
||||||
|
loc := timeutil.ShanghaiLocation()
|
||||||
|
before := time.Now().In(loc)
|
||||||
|
orderNo, err := newOrderNo()
|
||||||
|
after := time.Now().In(loc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newOrderNo() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(orderNo) != len("RO20260603123456789") {
|
||||||
|
t.Fatalf("orderNo length = %d, want %d: %s", len(orderNo), len("RO20260603123456789"), orderNo)
|
||||||
|
}
|
||||||
|
parsed, err := time.ParseInLocation("20060102150405", orderNo[2:16], loc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse order no time: %v", err)
|
||||||
|
}
|
||||||
|
if parsed.Before(before.Add(-2*time.Second)) || parsed.After(after.Add(2*time.Second)) {
|
||||||
|
t.Fatalf("order no time = %s, want between %s and %s", parsed, before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"hfb_sys/backend/internal/modules/order"
|
"hfb_sys/backend/internal/modules/order"
|
||||||
"hfb_sys/backend/internal/modules/paymentconfig"
|
"hfb_sys/backend/internal/modules/paymentconfig"
|
||||||
"hfb_sys/backend/internal/modules/wallet"
|
"hfb_sys/backend/internal/modules/wallet"
|
||||||
|
"hfb_sys/backend/internal/timeutil"
|
||||||
|
|
||||||
"gorm.io/datatypes"
|
"gorm.io/datatypes"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -756,9 +757,27 @@ func canReuseOrderPayment(payment model.PaymentOrder, runtimeConfig runtimePayme
|
|||||||
if payment.MerchantID != "" && runtimeConfig.MerchantID != "" && payment.MerchantID != runtimeConfig.MerchantID {
|
if payment.MerchantID != "" && runtimeConfig.MerchantID != "" && payment.MerchantID != runtimeConfig.MerchantID {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if payment.Status == "paying" && paymentCashierExpired(payment) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func paymentCashierExpired(payment model.PaymentOrder) bool {
|
||||||
|
if len(payment.RawRequest) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var raw map[string]string
|
||||||
|
if err := json.Unmarshal(payment.RawRequest, &raw); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
deadline := parseChannelTime(raw["order_efficient_time"])
|
||||||
|
if deadline == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !timeutil.ShanghaiNow().Before(*deadline)
|
||||||
|
}
|
||||||
|
|
||||||
func newOrderPayment(row model.RentalOrder, amountCent int64, req StartPaymentRequest, runtimeConfig runtimePaymentConfig) (model.PaymentOrder, error) {
|
func newOrderPayment(row model.RentalOrder, amountCent int64, req StartPaymentRequest, runtimeConfig runtimePaymentConfig) (model.PaymentOrder, error) {
|
||||||
paymentNo, err := newPaymentNo()
|
paymentNo, err := newPaymentNo()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -999,7 +1018,7 @@ func parseChannelTime(value string) *time.Time {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
for _, layout := range []string{"2006-01-02 15:04:05", "20060102150405", time.RFC3339} {
|
for _, layout := range []string{"2006-01-02 15:04:05", "20060102150405", time.RFC3339} {
|
||||||
parsed, err := time.ParseInLocation(layout, value, time.Local)
|
parsed, err := time.ParseInLocation(layout, value, timeutil.ShanghaiLocation())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return &parsed
|
return &parsed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ package payment
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
|
"hfb_sys/backend/internal/timeutil"
|
||||||
|
|
||||||
|
"gorm.io/datatypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCanReuseOrderPaymentRejectsTerminalAndOldChannel(t *testing.T) {
|
func TestCanReuseOrderPaymentRejectsTerminalAndOldChannel(t *testing.T) {
|
||||||
@@ -85,3 +89,42 @@ func TestNewOrderPaymentUsesPaymentNoAsThirdOrderID(t *testing.T) {
|
|||||||
t.Fatalf("payment order fields mismatch: %+v", payment)
|
t.Fatalf("payment order fields mismatch: %+v", payment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCanReuseOrderPaymentRejectsExpiredCashier(t *testing.T) {
|
||||||
|
runtimeConfig := runtimePaymentConfig{
|
||||||
|
Provider: "lakala",
|
||||||
|
MerchantID: "M2",
|
||||||
|
}
|
||||||
|
expiredPayment := model.PaymentOrder{
|
||||||
|
Status: "paying",
|
||||||
|
Provider: "lakala",
|
||||||
|
MerchantID: "M2",
|
||||||
|
RawRequest: datatypes.JSON([]byte(`{"order_efficient_time":"20000101000000"}`)),
|
||||||
|
}
|
||||||
|
if canReuseOrderPayment(expiredPayment, runtimeConfig) {
|
||||||
|
t.Fatal("canReuseOrderPayment() = true, want false for expired cashier")
|
||||||
|
}
|
||||||
|
|
||||||
|
activePayment := expiredPayment
|
||||||
|
activePayment.RawRequest = datatypes.JSON([]byte(`{"order_efficient_time":"20990101000000"}`))
|
||||||
|
if !canReuseOrderPayment(activePayment, runtimeConfig) {
|
||||||
|
t.Fatal("canReuseOrderPayment() = false, want true for active cashier")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseChannelTimeUsesShanghaiWhenLocalIsUTC(t *testing.T) {
|
||||||
|
oldLocal := time.Local
|
||||||
|
time.Local = time.UTC
|
||||||
|
defer func() {
|
||||||
|
time.Local = oldLocal
|
||||||
|
}()
|
||||||
|
|
||||||
|
parsed := parseChannelTime("20260607202700")
|
||||||
|
if parsed == nil {
|
||||||
|
t.Fatal("parseChannelTime() = nil")
|
||||||
|
}
|
||||||
|
got := parsed.In(timeutil.ShanghaiLocation()).Format("20060102150405")
|
||||||
|
if got != "20260607202700" {
|
||||||
|
t.Fatalf("parseChannelTime() = %s, want 20260607202700 in Asia/Shanghai", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
|
"hfb_sys/backend/internal/timeutil"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
@@ -291,7 +292,7 @@ func toLedgerDTO(row model.WalletLedger) LedgerDTO {
|
|||||||
func newLedgerNo() (string, error) {
|
func newLedgerNo() (string, error) {
|
||||||
// 生成格式:WL + YYYYMMDDHHMMSS + 毫秒 + 4位随机数
|
// 生成格式:WL + YYYYMMDDHHMMSS + 毫秒 + 4位随机数
|
||||||
// 例如:WL202606051234561230456,便于用户和客服识别。
|
// 例如:WL202606051234561230456,便于用户和客服识别。
|
||||||
now := time.Now()
|
now := timeutil.ShanghaiNow()
|
||||||
buf := make([]byte, 2)
|
buf := make([]byte, 2)
|
||||||
if _, err := rand.Read(buf); err != nil {
|
if _, err := rand.Read(buf); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package timeutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
shanghaiOnce sync.Once
|
||||||
|
shanghaiLoc *time.Location
|
||||||
|
)
|
||||||
|
|
||||||
|
// ShanghaiLocation 返回业务统一使用的北京时间时区。
|
||||||
|
func ShanghaiLocation() *time.Location {
|
||||||
|
shanghaiOnce.Do(func() {
|
||||||
|
loc, err := time.LoadLocation("Asia/Shanghai")
|
||||||
|
if err != nil {
|
||||||
|
loc = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||||
|
}
|
||||||
|
shanghaiLoc = loc
|
||||||
|
})
|
||||||
|
return shanghaiLoc
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShanghaiNow 返回当前北京时间,避免容器默认 UTC 影响无时区字符串。
|
||||||
|
func ShanghaiNow() time.Time {
|
||||||
|
return time.Now().In(ShanghaiLocation())
|
||||||
|
}
|
||||||
@@ -73,6 +73,8 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- ../backend/.env
|
- ../backend/.env
|
||||||
|
environment:
|
||||||
|
TZ: Asia/Shanghai
|
||||||
volumes:
|
volumes:
|
||||||
- ../backend/logs:/app/logs
|
- ../backend/logs:/app/logs
|
||||||
deploy:
|
deploy:
|
||||||
|
|||||||
Reference in New Issue
Block a user