From 954c3d6be3f34a457a08da1440e66bd42ce55e46 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 7 Jun 2026 20:42:57 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8B=89=E5=8D=A1=E6=8B=89?= =?UTF-8?q?=E6=94=AF=E4=BB=98=E6=97=B6=E9=97=B4=E5=81=8F=E5=B7=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Dockerfile | 2 + .../integrations/payment/lakala/client.go | 7 ++- .../payment/lakala/client_test.go | 38 ++++++++++++++++ backend/internal/modules/order/repository.go | 3 +- .../internal/modules/order/repository_test.go | 27 ++++++++++++ .../internal/modules/payment/repository.go | 21 ++++++++- .../modules/payment/repository_test.go | 43 +++++++++++++++++++ backend/internal/modules/wallet/repository.go | 3 +- backend/internal/timeutil/shanghai.go | 28 ++++++++++++ deploy/docker-compose.prod.yml | 2 + 10 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 backend/internal/timeutil/shanghai.go diff --git a/backend/Dockerfile b/backend/Dockerfile index b5ef301..68b024c 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -18,6 +18,8 @@ RUN --mount=type=cache,target=/go/pkg/mod \ FROM alpine:3.22 WORKDIR /app +ENV TZ=Asia/Shanghai +RUN apk add --no-cache tzdata COPY --from=build /out/hfb-api /app/hfb-api EXPOSE 8080 CMD ["/app/hfb-api"] diff --git a/backend/internal/integrations/payment/lakala/client.go b/backend/internal/integrations/payment/lakala/client.go index 79a09a1..06b2f9c 100644 --- a/backend/internal/integrations/payment/lakala/client.go +++ b/backend/internal/integrations/payment/lakala/client.go @@ -19,6 +19,8 @@ import ( "strconv" "strings" "time" + + "hfb_sys/backend/internal/timeutil" ) var ( @@ -174,11 +176,12 @@ func (c *Client) CreatePayment(ctx context.Context, req CreatePaymentRequest) (* if expireMinutes <= 0 { expireMinutes = 15 } + now := timeutil.ShanghaiNow() reqData := map[string]any{ "out_order_no": req.ThirdOrderID, "merchant_no": c.cfg.MerchantID, "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), "support_refund": 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) { body, err := json.Marshal(map[string]any{ - "req_time": time.Now().Format("20060102150405"), + "req_time": timeutil.ShanghaiNow().Format("20060102150405"), "version": "3.0", "req_data": reqData, }) diff --git a/backend/internal/integrations/payment/lakala/client_test.go b/backend/internal/integrations/payment/lakala/client_test.go index ed6273f..bdc4ff2 100644 --- a/backend/internal/integrations/payment/lakala/client_test.go +++ b/backend/internal/integrations/payment/lakala/client_test.go @@ -11,6 +11,9 @@ import ( "net/http/httptest" "strings" "testing" + "time" + + "hfb_sys/backend/internal/timeutil" ) 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) { t.Helper() privateDER := x509.MarshalPKCS1PrivateKey(key) diff --git a/backend/internal/modules/order/repository.go b/backend/internal/modules/order/repository.go index 000c2b6..9ab10b0 100644 --- a/backend/internal/modules/order/repository.go +++ b/backend/internal/modules/order/repository.go @@ -14,6 +14,7 @@ import ( "hfb_sys/backend/internal/modules/chat" "hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/wallet" + "hfb_sys/backend/internal/timeutil" "hfb_sys/backend/pkg/money" "gorm.io/datatypes" @@ -1627,7 +1628,7 @@ func makeAccountSnapshot(account model.GameAccount) (datatypes.JSON, error) { func newOrderNo() (string, error) { // 生成格式:RO + YYYYMMDDHHMMSS + 3位随机数 // 例如:RO20260603123456789 - now := time.Now() + now := timeutil.ShanghaiNow() // 时间部分:年月日时分秒 (14位) timeStr := now.Format("20060102150405") diff --git a/backend/internal/modules/order/repository_test.go b/backend/internal/modules/order/repository_test.go index 6f7c354..9f335a2 100644 --- a/backend/internal/modules/order/repository_test.go +++ b/backend/internal/modules/order/repository_test.go @@ -5,6 +5,7 @@ import ( "time" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/timeutil" "gorm.io/datatypes" ) @@ -131,3 +132,29 @@ func TestArchiveListingAfterCheckoutMovesListingOffline(t *testing.T) { // - Order should enter completed or closed state _ = 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) + } +} diff --git a/backend/internal/modules/payment/repository.go b/backend/internal/modules/payment/repository.go index e1e4606..966b385 100644 --- a/backend/internal/modules/payment/repository.go +++ b/backend/internal/modules/payment/repository.go @@ -14,6 +14,7 @@ import ( "hfb_sys/backend/internal/modules/order" "hfb_sys/backend/internal/modules/paymentconfig" "hfb_sys/backend/internal/modules/wallet" + "hfb_sys/backend/internal/timeutil" "gorm.io/datatypes" "gorm.io/gorm" @@ -756,9 +757,27 @@ func canReuseOrderPayment(payment model.PaymentOrder, runtimeConfig runtimePayme if payment.MerchantID != "" && runtimeConfig.MerchantID != "" && payment.MerchantID != runtimeConfig.MerchantID { return false } + if payment.Status == "paying" && paymentCashierExpired(payment) { + return false + } 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) { paymentNo, err := newPaymentNo() if err != nil { @@ -999,7 +1018,7 @@ func parseChannelTime(value string) *time.Time { return nil } 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 { return &parsed } diff --git a/backend/internal/modules/payment/repository_test.go b/backend/internal/modules/payment/repository_test.go index cafab48..fa21004 100644 --- a/backend/internal/modules/payment/repository_test.go +++ b/backend/internal/modules/payment/repository_test.go @@ -2,8 +2,12 @@ package payment import ( "testing" + "time" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/timeutil" + + "gorm.io/datatypes" ) func TestCanReuseOrderPaymentRejectsTerminalAndOldChannel(t *testing.T) { @@ -85,3 +89,42 @@ func TestNewOrderPaymentUsesPaymentNoAsThirdOrderID(t *testing.T) { 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) + } +} diff --git a/backend/internal/modules/wallet/repository.go b/backend/internal/modules/wallet/repository.go index 8000749..7b4a1c3 100644 --- a/backend/internal/modules/wallet/repository.go +++ b/backend/internal/modules/wallet/repository.go @@ -7,6 +7,7 @@ import ( "time" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/timeutil" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -291,7 +292,7 @@ func toLedgerDTO(row model.WalletLedger) LedgerDTO { func newLedgerNo() (string, error) { // 生成格式:WL + YYYYMMDDHHMMSS + 毫秒 + 4位随机数 // 例如:WL202606051234561230456,便于用户和客服识别。 - now := time.Now() + now := timeutil.ShanghaiNow() buf := make([]byte, 2) if _, err := rand.Read(buf); err != nil { return "", err diff --git a/backend/internal/timeutil/shanghai.go b/backend/internal/timeutil/shanghai.go new file mode 100644 index 0000000..ab141bc --- /dev/null +++ b/backend/internal/timeutil/shanghai.go @@ -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()) +} diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 2010587..c58356a 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -73,6 +73,8 @@ services: restart: unless-stopped env_file: - ../backend/.env + environment: + TZ: Asia/Shanghai volumes: - ../backend/logs:/app/logs deploy: