统一时间格式和时区

This commit is contained in:
yml2213
2026-07-31 12:57:17 +08:00
parent 667bfbde06
commit 949a0eb8f7
24 changed files with 342 additions and 49 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ func Load() *Config {
return &Config{
Port: getEnv("PORT", "8080"),
JWTSecret: getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me"),
DatabaseURL: getEnv("DATABASE_URL", "postgres://affiliate:affiliate_dev_password@127.0.0.1:5432/affiliate_dash?sslmode=disable"),
DatabaseURL: getEnv("DATABASE_URL", "postgres://affiliate:affiliate_dev_password@127.0.0.1:5432/affiliate_dash?sslmode=disable&TimeZone=Asia/Shanghai"),
Mode: mode,
DataEncryptionKey: getEnv("DATA_ENCRYPTION_KEY", getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me")),
OpenAPIKey: getEnv("OPEN_API_KEY", "sk_source_dev_key_change_me"),
+3 -7
View File
@@ -95,13 +95,9 @@ func (h *OpenV1Handler) CreateOrder(c *gin.Context) {
}
openlog.Info(c, "create_order ok order_no=%s idempotent=%v amount=%d",
result.Order.OrderNo, result.Idempotent, result.Order.Amount)
c.JSON(status, response.Body{
Code: 0,
Message: "ok",
Data: gin.H{
"order": buildOpenOrderResponse(result.Order),
"idempotent": result.Idempotent,
},
response.WithStatus(c, status, gin.H{
"order": buildOpenOrderResponse(result.Order),
"idempotent": result.Idempotent,
})
}
+141 -1
View File
@@ -1,11 +1,23 @@
package response
import (
"encoding/json"
"fmt"
"net/http"
"reflect"
"strings"
"time"
"affiliate_dash/internal/pkg/timeutil"
"github.com/gin-gonic/gin"
)
var (
timeType = reflect.TypeOf(time.Time{})
rawMessageType = reflect.TypeOf(json.RawMessage{})
)
type Body struct {
Code int `json:"code"`
Message string `json:"message"`
@@ -13,7 +25,11 @@ type Body struct {
}
func OK(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, Body{Code: 0, Message: "ok", Data: data})
WithStatus(c, http.StatusOK, data)
}
func WithStatus(c *gin.Context, httpStatus int, data interface{}) {
c.JSON(httpStatus, Body{Code: 0, Message: "ok", Data: NormalizeTime(data)})
}
func Fail(c *gin.Context, httpStatus int, code int, message string) {
@@ -50,3 +66,127 @@ type PageData struct {
func Page(c *gin.Context, list interface{}, total int64, page, size int) {
OK(c, PageData{List: list, Total: total, Page: page, Size: size})
}
func FormatTime(value time.Time) string {
return timeutil.FormatAPITime(value)
}
func NormalizeTime(value interface{}) interface{} {
return normalizeTimeValue(reflect.ValueOf(value))
}
func normalizeTimeValue(value reflect.Value) interface{} {
if !value.IsValid() {
return nil
}
if value.Kind() == reflect.Interface {
if value.IsNil() {
return nil
}
return normalizeTimeValue(value.Elem())
}
if value.Type() == rawMessageType {
return value.Interface()
}
if value.Type() == timeType {
if !value.CanInterface() {
return nil
}
return FormatTime(value.Interface().(time.Time))
}
switch value.Kind() {
case reflect.Pointer:
if value.IsNil() {
return nil
}
if value.Type().Elem() == timeType {
return FormatTime(value.Elem().Interface().(time.Time))
}
return normalizeTimeValue(value.Elem())
case reflect.Slice, reflect.Array:
if value.Kind() == reflect.Slice && value.IsNil() {
return nil
}
if value.Type().Elem().Kind() == reflect.Uint8 {
return value.Interface()
}
out := make([]interface{}, 0, value.Len())
for i := 0; i < value.Len(); i++ {
out = append(out, normalizeTimeValue(value.Index(i)))
}
return out
case reflect.Map:
if value.IsNil() {
return nil
}
out := make(map[string]interface{}, value.Len())
iter := value.MapRange()
for iter.Next() {
out[mapKeyString(iter.Key())] = normalizeTimeValue(iter.Value())
}
return out
case reflect.Struct:
return normalizeTimeStruct(value)
default:
if value.CanInterface() {
return value.Interface()
}
return nil
}
}
func normalizeTimeStruct(value reflect.Value) map[string]interface{} {
out := make(map[string]interface{}, value.NumField())
valueType := value.Type()
for i := 0; i < value.NumField(); i++ {
field := valueType.Field(i)
if field.PkgPath != "" {
continue
}
name, skip, omitEmpty := jsonFieldName(field)
if skip {
continue
}
fieldValue := value.Field(i)
if omitEmpty && fieldValue.IsZero() {
continue
}
out[name] = normalizeTimeValue(fieldValue)
}
return out
}
func jsonFieldName(field reflect.StructField) (string, bool, bool) {
tag := field.Tag.Get("json")
if tag == "-" {
return "", true, false
}
if tag == "" {
return field.Name, false, false
}
parts := strings.Split(tag, ",")
name := parts[0]
if name == "" {
name = field.Name
}
return name, false, hasTagOption(parts[1:], "omitempty")
}
func hasTagOption(options []string, target string) bool {
for _, option := range options {
if option == target {
return true
}
}
return false
}
func mapKeyString(value reflect.Value) string {
if value.Kind() == reflect.String {
return value.String()
}
if value.CanInterface() {
return fmt.Sprint(value.Interface())
}
return ""
}
@@ -0,0 +1,54 @@
package response
import (
"encoding/json"
"testing"
"time"
)
func TestNormalizeTimeUsesSecondPrecisionCST(t *testing.T) {
value := time.Date(2026, 7, 30, 9, 53, 58, 147355000, time.UTC)
got := NormalizeTime(map[string]interface{}{
"created_at": value,
"nested": []interface{}{
&value,
json.RawMessage(`{"kept":true}`),
},
})
raw, err := json.Marshal(got)
if err != nil {
t.Fatalf("marshal normalized response: %v", err)
}
text := string(raw)
if !json.Valid(raw) {
t.Fatalf("normalized response should stay valid json: %s", text)
}
if text != `{"created_at":"2026-07-30T17:53:58+08:00","nested":["2026-07-30T17:53:58+08:00",{"kept":true}]}` {
t.Fatalf("unexpected normalized response: %s", text)
}
}
func TestNormalizeTimeStructKeepsJSONTags(t *testing.T) {
type sample struct {
CreatedAt time.Time `json:"created_at"`
Optional string `json:"optional,omitempty"`
Hidden string `json:"-"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
value := time.Date(2026, 7, 30, 9, 53, 58, 147355000, time.UTC)
got := NormalizeTime(sample{
CreatedAt: value,
Hidden: "secret",
})
raw, err := json.Marshal(got)
if err != nil {
t.Fatalf("marshal normalized struct: %v", err)
}
text := string(raw)
if text != `{"created_at":"2026-07-30T17:53:58+08:00"}` {
t.Fatalf("unexpected normalized struct: %s", text)
}
}
+31
View File
@@ -0,0 +1,31 @@
package timeutil
import "time"
const (
APITimeLayout = "2006-01-02T15:04:05-07:00"
OrderNoLayout = "20060102150405"
)
var businessLocation = time.FixedZone("CST", 8*60*60)
func Location() *time.Location {
return businessLocation
}
func Now() time.Time {
return time.Now().In(businessLocation)
}
func FormatAPITime(value time.Time) string {
if value.IsZero() {
return ""
}
return value.In(businessLocation).Truncate(time.Second).Format(APITimeLayout)
}
func StartOfDay(value time.Time) time.Time {
local := value.In(businessLocation)
year, month, day := local.Date()
return time.Date(year, month, day, 0, 0, 0, 0, businessLocation)
}
@@ -0,0 +1,21 @@
package timeutil
import (
"testing"
"time"
)
func TestFormatAPITimeUsesBusinessTimezone(t *testing.T) {
value := time.Date(2026, 7, 30, 9, 53, 58, 147355000, time.UTC)
if got := FormatAPITime(value); got != "2026-07-30T17:53:58+08:00" {
t.Fatalf("unexpected api time: %s", got)
}
}
func TestStartOfDayUsesBusinessTimezone(t *testing.T) {
value := time.Date(2026, 7, 30, 18, 30, 0, 0, time.UTC)
got := StartOfDay(value)
if got.Format(APITimeLayout) != "2026-07-31T00:00:00+08:00" {
t.Fatalf("unexpected day start: %s", got.Format(APITimeLayout))
}
}
+2 -1
View File
@@ -17,6 +17,7 @@ import (
"time"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/timeutil"
"github.com/google/uuid"
"gorm.io/gorm"
@@ -140,7 +141,7 @@ func (s *CallbackService) Enqueue(tx *gorm.DB, merchantID uint, event string, da
payload, err := json.Marshal(map[string]interface{}{
"event_id": eventID,
"event": event,
"occurred_at": now.UTC().Format(time.RFC3339Nano),
"occurred_at": timeutil.FormatAPITime(now),
"data": data,
})
if err != nil {
+2 -1
View File
@@ -16,6 +16,7 @@ import (
"time"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/timeutil"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -349,7 +350,7 @@ func (s *DeliveryService) claimDeliverySubmission(merchantID, apiClientID uint,
}
raw, _ := json.Marshal(map[string]interface{}{
"source": "delivery_proxy",
"submit_started_at": time.Now().UTC().Format(time.RFC3339),
"submit_started_at": timeutil.FormatAPITime(time.Now()),
"provider_order_stage": "claimed",
})
if err := tx.Model(&order).Updates(map[string]interface{}{
+5 -6
View File
@@ -10,6 +10,7 @@ import (
"unicode/utf8"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/timeutil"
"github.com/google/uuid"
"gorm.io/gorm"
@@ -599,11 +600,11 @@ func (s *FulfillmentService) ListWalletLedger(merchantID uint, page, size int) (
}
func newFulfillmentOrderNo() string {
return "FO" + time.Now().UTC().Format("20060102150405") + strings.ReplaceAll(uuid.NewString()[:12], "-", "")
return "FO" + timeutil.Now().Format(timeutil.OrderNoLayout) + strings.ReplaceAll(uuid.NewString()[:12], "-", "")
}
func newTestFulfillmentOrderNo() string {
return "O" + time.Now().UTC().Format("20060102150405") + strings.ReplaceAll(uuid.NewString()[:12], "-", "")
return "O" + timeutil.Now().Format(timeutil.OrderNoLayout) + strings.ReplaceAll(uuid.NewString()[:12], "-", "")
}
// calculateServiceFee 按"百分比或固定"二选一计算手续费:
@@ -712,9 +713,7 @@ func (s *FulfillmentService) Dashboard(merchantID uint, isPlatformAdmin bool) (*
if isPlatformAdmin {
stats.Scope = "platform"
}
now := time.Now()
year, month, day := now.Date()
todayStart := time.Date(year, month, day, 0, 0, 0, 0, now.Location())
todayStart := timeutil.StartOfDay(time.Now())
productScope := func() *gorm.DB {
tx := s.db.Model(&model.MerchantProduct{})
@@ -1130,7 +1129,7 @@ func buildShipNotifyResultData(existing string, in ShipNotifyInput, shippedAt *t
m["fail_reason"] = in.FailReason
}
if shippedAt != nil {
m["shipped_at"] = shippedAt.Format(time.RFC3339)
m["shipped_at"] = timeutil.FormatAPITime(*shippedAt)
}
if in.GameChannel != nil {
m["game_channel"] = *in.GameChannel