diff --git a/.env.example b/.env.example index ea4b749..0ee40c3 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,8 @@ DATA_ENCRYPTION_KEY=your_32_plus_char_data_encryption_key_change_me POSTGRES_USER=affiliate POSTGRES_PASSWORD=affiliate_dev_password POSTGRES_DB=affiliate_dash -DATABASE_URL=postgres://affiliate:affiliate_dev_password@127.0.0.1:5432/affiliate_dash?sslmode=disable +TZ=Asia/Shanghai +DATABASE_URL="postgres://affiliate:affiliate_dev_password@127.0.0.1:5432/affiliate_dash?sslmode=disable&TimeZone=Asia/Shanghai" GIN_MODE=release OPEN_API_KEY=sk_source_dev_key_change_me OPEN_API_SECRET=sk_source_dev_secret_change_me diff --git a/Dockerfile.backend b/Dockerfile.backend index 1f0bfd5..ce71bdd 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -27,8 +27,11 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./cmd/server FROM ${BACKEND_RUNTIME_IMAGE} ARG ALPINE_MIRROR +ENV TZ=Asia/Shanghai RUN sed -i "s|https://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g; s|http://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g" /etc/apk/repositories \ - && apk --no-cache add ca-certificates tzdata curl + && apk --no-cache add ca-certificates tzdata curl \ + && ln -snf "/usr/share/zoneinfo/${TZ}" /etc/localtime \ + && echo "${TZ}" >/etc/timezone WORKDIR /app diff --git a/Dockerfile.caddy b/Dockerfile.caddy index 4ec6019..c8d84a9 100644 --- a/Dockerfile.caddy +++ b/Dockerfile.caddy @@ -1,6 +1,7 @@ ARG NODE_BUILDER_IMAGE=docker.m.daocloud.io/library/node:22-alpine ARG CADDY_BASE_IMAGE=docker.m.daocloud.io/library/caddy:2-alpine ARG NPM_REGISTRY=https://registry.npmmirror.com +ARG ALPINE_MIRROR=https://mirrors.aliyun.com/alpine FROM ${NODE_BUILDER_IMAGE} AS frontend-builder @@ -18,6 +19,13 @@ RUN npm run build FROM ${CADDY_BASE_IMAGE} +ARG ALPINE_MIRROR +ENV TZ=Asia/Shanghai +RUN sed -i "s|https://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g; s|http://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g" /etc/apk/repositories \ + && apk --no-cache add tzdata \ + && ln -snf "/usr/share/zoneinfo/${TZ}" /etc/localtime \ + && echo "${TZ}" >/etc/timezone + COPY Caddyfile /etc/caddy/Caddyfile COPY --from=frontend-builder /app/frontend/dist /usr/share/caddy diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 0fae64b..609af12 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -3,6 +3,7 @@ package main import ( "context" "log" + "time" "affiliate_dash/internal/config" "affiliate_dash/internal/database" @@ -10,6 +11,7 @@ import ( "affiliate_dash/internal/pkg/applog" "affiliate_dash/internal/pkg/jwt" "affiliate_dash/internal/pkg/openlog" + "affiliate_dash/internal/pkg/timeutil" "affiliate_dash/internal/router" "affiliate_dash/internal/service" @@ -20,6 +22,7 @@ import ( func main() { cfg := config.Load() + time.Local = timeutil.Location() // 日志:控制台 + 文件 logFile, err := applog.Setup(cfg.LogFile) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 85c8ee0..2678d70 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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"), diff --git a/backend/internal/handler/open_v1.go b/backend/internal/handler/open_v1.go index 22735ca..83c8052 100644 --- a/backend/internal/handler/open_v1.go +++ b/backend/internal/handler/open_v1.go @@ -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, }) } diff --git a/backend/internal/pkg/response/response.go b/backend/internal/pkg/response/response.go index 31fbfa2..2e240d9 100644 --- a/backend/internal/pkg/response/response.go +++ b/backend/internal/pkg/response/response.go @@ -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 "" +} diff --git a/backend/internal/pkg/response/response_test.go b/backend/internal/pkg/response/response_test.go new file mode 100644 index 0000000..47c7b36 --- /dev/null +++ b/backend/internal/pkg/response/response_test.go @@ -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) + } +} diff --git a/backend/internal/pkg/timeutil/timeutil.go b/backend/internal/pkg/timeutil/timeutil.go new file mode 100644 index 0000000..ffa68fa --- /dev/null +++ b/backend/internal/pkg/timeutil/timeutil.go @@ -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) +} diff --git a/backend/internal/pkg/timeutil/timeutil_test.go b/backend/internal/pkg/timeutil/timeutil_test.go new file mode 100644 index 0000000..7a69b9b --- /dev/null +++ b/backend/internal/pkg/timeutil/timeutil_test.go @@ -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)) + } +} diff --git a/backend/internal/service/callback.go b/backend/internal/service/callback.go index 4a9798d..f584da3 100644 --- a/backend/internal/service/callback.go +++ b/backend/internal/service/callback.go @@ -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 { diff --git a/backend/internal/service/delivery.go b/backend/internal/service/delivery.go index a5b67c2..70e5f77 100644 --- a/backend/internal/service/delivery.go +++ b/backend/internal/service/delivery.go @@ -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{}{ diff --git a/backend/internal/service/fulfillment.go b/backend/internal/service/fulfillment.go index 4b44138..988a0d7 100644 --- a/backend/internal/service/fulfillment.go +++ b/backend/internal/service/fulfillment.go @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 9efb603..10b50a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,9 @@ services: - POSTGRES_USER=${POSTGRES_USER:-affiliate} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-affiliate_dev_password} - POSTGRES_DB=${POSTGRES_DB:-affiliate_dash} + - TZ=${TZ:-Asia/Shanghai} + - PGTZ=${TZ:-Asia/Shanghai} + command: ["postgres", "-c", "timezone=${TZ:-Asia/Shanghai}"] volumes: - postgres-data:/var/lib/postgresql/data restart: unless-stopped @@ -29,8 +32,9 @@ services: - "8080:8080" environment: - PORT=8080 + - TZ=${TZ:-Asia/Shanghai} - JWT_SECRET=${JWT_SECRET} - - DATABASE_URL=postgres://${POSTGRES_USER:-affiliate}:${POSTGRES_PASSWORD:-affiliate_dev_password}@postgres:5432/${POSTGRES_DB:-affiliate_dash}?sslmode=disable + - "DATABASE_URL=postgres://${POSTGRES_USER:-affiliate}:${POSTGRES_PASSWORD:-affiliate_dev_password}@postgres:5432/${POSTGRES_DB:-affiliate_dash}?sslmode=disable&TimeZone=${TZ:-Asia/Shanghai}" - GIN_MODE=${GIN_MODE:-release} - OPEN_API_KEY=${OPEN_API_KEY} - OPEN_API_SECRET=${OPEN_API_SECRET} @@ -58,10 +62,13 @@ services: NODE_BUILDER_IMAGE: ${NODE_BUILDER_IMAGE:-docker.m.daocloud.io/library/node:22-alpine} CADDY_BASE_IMAGE: ${CADDY_BASE_IMAGE:-docker.m.daocloud.io/library/caddy:2-alpine} NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmmirror.com} + ALPINE_MIRROR: ${ALPINE_MIRROR:-https://mirrors.aliyun.com/alpine} image: affiliate-caddy:latest ports: - "80:80" - "443:443" + environment: + - TZ=${TZ:-Asia/Shanghai} volumes: - caddy-data:/data - caddy-config:/config diff --git a/docs/API对接关系.md b/docs/API对接关系.md index 492c326..15a907b 100644 --- a/docs/API对接关系.md +++ b/docs/API对接关系.md @@ -47,6 +47,8 @@ 两套签名串不同,即使 Header 名相似也不能互相套用。 +所有对外返回和回调里的时间字段统一使用 RFC3339 秒级北京时间,例如 `2026-07-30T17:53:58+08:00`;请求侧仍接受合法 RFC3339 时间。 + ## 4. 状态语言 内部订单使用 `payment_status` + `fulfillment_status` 两组状态;源头侧为了兼容对接,返回的是更贴近发货系统的旧状态名。 diff --git a/docs/发货通知约定.md b/docs/发货通知约定.md index 3abe715..1394bc1 100644 --- a/docs/发货通知约定.md +++ b/docs/发货通知约定.md @@ -22,7 +22,7 @@ "order_no": "O202607240733306742", "ship_status": "success", "provider_order_no": "6a6322a81b3b421994137260", - "shipped_at": "2026-07-24T08:30:33.000Z", + "shipped_at": "2026-07-24T16:30:33+08:00", "fail_reason": "", "game_uid": "4808146277", "role_name": "巫师哈丁12", @@ -38,7 +38,7 @@ "order_no": "O202607240733306742", "ship_status": "failed", "provider_order_no": "6a6322a81b3b421994137260", - "shipped_at": "2026-07-24T08:30:33.000Z", + "shipped_at": "2026-07-24T16:30:33+08:00", "fail_reason": "角色名不存在,渠道服校验失败", "game_uid": "4808146277", "role_name": "巫师哈丁12", @@ -54,7 +54,7 @@ | `order_no` | 是 | 店铺订单号 | | `ship_status` | 是 | 仅使用 `success` / `failed` | | `provider_order_no` | 否 | 上游发货单号 | -| `shipped_at` | 否 | RFC3339 时间;`success` 未传时可由服务端补时间 | +| `shipped_at` | 否 | RFC3339 时间,建议秒级北京时间;`success` 未传时可由服务端补时间 | | `fail_reason` | 失败时是 | `failed` 时必须填详细原因 | | `game_channel` | 否 | 账号区服 | | `game_uid` | 否 | 游戏角色 UUID | diff --git a/docs/开放接口-皮肤源头对接.md b/docs/开放接口-皮肤源头对接.md index fd96bf8..feb668d 100644 --- a/docs/开放接口-皮肤源头对接.md +++ b/docs/开放接口-皮肤源头对接.md @@ -37,6 +37,7 @@ - `code = 0`:成功 - `code != 0`:失败,看 `message` +- 时间字段统一使用 RFC3339 秒级北京时间,例如 `2026-07-30T17:53:58+08:00`;请求侧仍接受合法 RFC3339 时间。 --- @@ -239,7 +240,7 @@ Content-Type: application/json | `order_no` | 是 | 店铺订单号 | | `ship_status` | 是 | 仅 `success` / `failed` | | `provider_order_no` | 否 | 你们系统的发货单号 | -| `shipped_at` | 否 | RFC3339;success 未传则用服务端时间 | +| `shipped_at` | 否 | RFC3339 时间,建议秒级北京时间;success 未传则用服务端时间 | | `fail_reason` | 失败时是 | `failed` 时必填,填写详细失败原因 | ### 4.2 ship_status → 我们订单状态 diff --git a/frontend/src/openapi/endpoints.ts b/frontend/src/openapi/endpoints.ts index ead9589..e70b21c 100644 --- a/frontend/src/openapi/endpoints.ts +++ b/frontend/src/openapi/endpoints.ts @@ -113,7 +113,7 @@ const deliveryLinkResponseExample = `{ "data": { "order_no": "FO20260730000123", "delivery_url": "https://shop.example/delivery/dlc/FO20260730000123?exp=1753934400&sign=...", - "expires_at": "2026-07-31T12:00:00Z", + "expires_at": "2026-07-31T12:00:00+08:00", "exp": 1753934400, "sign": "..." } diff --git a/frontend/src/pages/Delivery.tsx b/frontend/src/pages/Delivery.tsx index 911152e..a0d8a6b 100644 --- a/frontend/src/pages/Delivery.tsx +++ b/frontend/src/pages/Delivery.tsx @@ -21,9 +21,9 @@ import { QrcodeOutlined, SendOutlined, } from '@ant-design/icons' -import dayjs from 'dayjs' import { deliveryApi } from '../api' import type { DeliveryBindResult, DeliveryOrderInfo, DeliverySubmitResult } from '../types' +import { formatDateTime } from '../utils/time' const statusMap: Record = { paid: { color: 'orange', text: '待发货' }, @@ -150,7 +150,7 @@ export default function Delivery() { {order.buyer_name || '-'} {order.amount} 积分 - {formatTime(order.created_at)} + {formatDateTime(order.created_at)} {order.product?.game || channel.toUpperCase()} @@ -253,10 +253,6 @@ function CenteredShell({ children }: { children: ReactNode }) { ) } -function formatTime(value?: string | null) { - return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-' -} - const panelStyle: CSSProperties = { background: '#fff', border: '1px solid #e5e7eb', diff --git a/frontend/src/pages/MerchantCenter.tsx b/frontend/src/pages/MerchantCenter.tsx index 0ef766a..0fe831a 100644 --- a/frontend/src/pages/MerchantCenter.tsx +++ b/frontend/src/pages/MerchantCenter.tsx @@ -23,8 +23,8 @@ import { WalletOutlined, } from '@ant-design/icons' import type { ColumnsType } from 'antd/es/table' -import dayjs from 'dayjs' import { merchantApi } from '../api' +import { formatDateTime } from '../utils/time' import type { ApiClient, ApiCredential, @@ -473,9 +473,9 @@ export default function MerchantCenter() { title: '链接有效期', dataIndex: 'delivery_link_expires_at', width: 160, - render: (_, record) => record.delivery_link_revoked_at ? 已作废 : formatTime(record.delivery_link_expires_at), + render: (_, record) => record.delivery_link_revoked_at ? 已作废 : formatDateTime(record.delivery_link_expires_at), }, - { title: '时间', dataIndex: 'created_at', width: 160, render: formatTime }, + { title: '时间', dataIndex: 'created_at', width: 180, render: formatDateTime }, { title: '发货链接', key: 'delivery_link', @@ -499,7 +499,7 @@ export default function MerchantCenter() { { title: '余额', dataIndex: 'balance_after', width: 110, render: money }, { title: '关联单号', dataIndex: 'reference_no', ellipsis: true }, { title: '备注', dataIndex: 'note', ellipsis: true, render: (v) => v || '-' }, - { title: '时间', dataIndex: 'created_at', width: 160, render: formatTime }, + { title: '时间', dataIndex: 'created_at', width: 180, render: formatDateTime }, ] const apiClientColumns: ColumnsType = [ @@ -508,7 +508,7 @@ export default function MerchantCenter() { { title: '签名', dataIndex: 'signature_version', width: 110, render: (v) => {v} }, { title: '权限', dataIndex: 'scopes', ellipsis: true }, { title: '状态', dataIndex: 'status', width: 90, render: activeStatusTag }, - { title: '最后使用', dataIndex: 'last_used_at', width: 160, render: formatTime }, + { title: '最后使用', dataIndex: 'last_used_at', width: 180, render: formatDateTime }, { title: '操作', key: 'action', @@ -526,7 +526,7 @@ export default function MerchantCenter() { { title: 'URL', dataIndex: 'url', ellipsis: true }, { title: '事件', dataIndex: 'events', width: 260, ellipsis: true }, { title: '状态', dataIndex: 'status', width: 90, render: activeStatusTag }, - { title: '创建时间', dataIndex: 'created_at', width: 160, render: formatTime }, + { title: '创建时间', dataIndex: 'created_at', width: 180, render: formatDateTime }, { title: '操作', key: 'action', @@ -824,7 +824,7 @@ export default function MerchantCenter() { - {testOrderResult.order.delivery_link_revoked_at ? 已作废 : formatTime(testOrderResult.order.delivery_link_expires_at)} + {testOrderResult.order.delivery_link_revoked_at ? 已作废 : formatDateTime(testOrderResult.order.delivery_link_expires_at)} 由后端签名生成,可复制、打开或作废。 @@ -969,10 +969,6 @@ function moneyWithSign(value?: number | null) { return `${prefix}${money(amount)}` } -function formatTime(value?: string | null) { - return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-' -} - function productStatusTag(value: string) { return value === 'active' ? 上架 : 下架 } diff --git a/frontend/src/pages/OpenApiDocs.tsx b/frontend/src/pages/OpenApiDocs.tsx index b631ef8..2d77487 100644 --- a/frontend/src/pages/OpenApiDocs.tsx +++ b/frontend/src/pages/OpenApiDocs.tsx @@ -134,6 +134,9 @@ function OverviewTab() { X-App-KeyX-TimestampX-NonceX-Sign 允许 ±300 秒 + + 所有时间字段统一返回 RFC3339 秒级北京时间,例如 2026-07-30T17:53:58+08:00 + POST 请求使用 application/json diff --git a/frontend/src/pages/PlatformMerchants.tsx b/frontend/src/pages/PlatformMerchants.tsx index 99873c3..63c21f6 100644 --- a/frontend/src/pages/PlatformMerchants.tsx +++ b/frontend/src/pages/PlatformMerchants.tsx @@ -16,10 +16,10 @@ import { } from 'antd' import { GiftOutlined, PlusOutlined, ReloadOutlined, TeamOutlined } from '@ant-design/icons' import type { ColumnsType } from 'antd/es/table' -import dayjs from 'dayjs' import { useNavigate } from 'react-router-dom' import { platformApi } from '../api' import type { Merchant, MerchantMember, PageResult, ProductCatalogItem } from '../types' +import { formatDateTime } from '../utils/time' const memberRoleOptions = [ { value: 'owner', label: '负责人' }, @@ -195,7 +195,7 @@ export default function PlatformMerchants() { ), }, { title: '状态', dataIndex: 'status', width: 90, render: (v) => v === 'active' ? 启用 : 禁用 }, - { title: '创建时间', dataIndex: 'created_at', width: 160, render: (v) => dayjs(v).format('YYYY-MM-DD HH:mm') }, + { title: '创建时间', dataIndex: 'created_at', width: 180, render: formatDateTime }, { title: '操作', key: 'action', diff --git a/frontend/src/utils/time.ts b/frontend/src/utils/time.ts new file mode 100644 index 0000000..27741bb --- /dev/null +++ b/frontend/src/utils/time.ts @@ -0,0 +1,5 @@ +import dayjs from 'dayjs' + +export function formatDateTime(value?: string | null) { + return value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-' +} diff --git a/start.sh b/start.sh index 362ff58..e835f44 100755 --- a/start.sh +++ b/start.sh @@ -26,8 +26,10 @@ POSTGRES_DB="${POSTGRES_DB:-affiliate_dash}" POSTGRES_PORT="${POSTGRES_PORT:-5432}" POSTGRES_IMAGE="${POSTGRES_IMAGE:-docker.m.daocloud.io/library/postgres:16-alpine}" DEV_DB_CONTAINER="${DEV_DB_CONTAINER:-affiliate_dash_postgres_dev}" -DATABASE_URL="${DATABASE_URL:-postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:${POSTGRES_PORT}/${POSTGRES_DB}?sslmode=disable}" +TZ="${TZ:-Asia/Shanghai}" +DATABASE_URL="${DATABASE_URL:-postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:${POSTGRES_PORT}/${POSTGRES_DB}?sslmode=disable&TimeZone=${TZ}}" export DATABASE_URL +export TZ # 结束指定端口上的进程(避免残留占用) free_port() { @@ -47,8 +49,28 @@ free_port() { fi } +postgres_container_exists() { + docker ps -a --format '{{.Names}}' | grep -qx "$DEV_DB_CONTAINER" +} + +postgres_container_running() { + docker ps --format '{{.Names}}' | grep -qx "$DEV_DB_CONTAINER" +} + +postgres_container_timezone_ready() { + local env cmd + env="$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$DEV_DB_CONTAINER" 2>/dev/null || true)" + cmd="$(docker inspect -f '{{json .Config.Cmd}}' "$DEV_DB_CONTAINER" 2>/dev/null || true)" + grep -qx "TZ=${TZ}" <<<"$env" && grep -qx "PGTZ=${TZ}" <<<"$env" && [[ "$cmd" == *"timezone=${TZ}"* ]] +} + ensure_postgres() { - if (echo >"/dev/tcp/127.0.0.1/${POSTGRES_PORT}") >/dev/null 2>&1; then + if command -v docker >/dev/null 2>&1 && postgres_container_exists; then + if ! postgres_container_timezone_ready; then + log "检测到旧 PostgreSQL 开发容器缺少时区配置,重建容器并保留数据卷: ${DEV_DB_CONTAINER}" + docker rm -f "$DEV_DB_CONTAINER" >/dev/null + fi + elif (echo >"/dev/tcp/127.0.0.1/${POSTGRES_PORT}") >/dev/null 2>&1; then log "检测到本机 PostgreSQL 端口已可访问: ${POSTGRES_PORT}" return 0 fi @@ -56,9 +78,9 @@ ensure_postgres() { log "未找到 Docker,请确认本机 PostgreSQL 已可通过 DATABASE_URL 访问" return 0 fi - if docker ps --format '{{.Names}}' | grep -qx "$DEV_DB_CONTAINER"; then + if postgres_container_running; then log "PostgreSQL 开发容器已运行: ${DEV_DB_CONTAINER}" - elif docker ps -a --format '{{.Names}}' | grep -qx "$DEV_DB_CONTAINER"; then + elif postgres_container_exists; then log "启动 PostgreSQL 开发容器: ${DEV_DB_CONTAINER}" docker start "$DEV_DB_CONTAINER" >/dev/null else @@ -69,8 +91,11 @@ ensure_postgres() { -e POSTGRES_USER="$POSTGRES_USER" \ -e POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \ -e POSTGRES_DB="$POSTGRES_DB" \ + -e TZ="$TZ" \ + -e PGTZ="$TZ" \ -v "${DEV_DB_CONTAINER}-data:/var/lib/postgresql/data" \ - "$POSTGRES_IMAGE" >/dev/null + "$POSTGRES_IMAGE" \ + -c timezone="$TZ" >/dev/null fi for i in $(seq 1 40); do