统一时间格式和时区

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
+2 -1
View File
@@ -4,7 +4,8 @@ DATA_ENCRYPTION_KEY=your_32_plus_char_data_encryption_key_change_me
POSTGRES_USER=affiliate POSTGRES_USER=affiliate
POSTGRES_PASSWORD=affiliate_dev_password POSTGRES_PASSWORD=affiliate_dev_password
POSTGRES_DB=affiliate_dash 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 GIN_MODE=release
OPEN_API_KEY=sk_source_dev_key_change_me OPEN_API_KEY=sk_source_dev_key_change_me
OPEN_API_SECRET=sk_source_dev_secret_change_me OPEN_API_SECRET=sk_source_dev_secret_change_me
+4 -1
View File
@@ -27,8 +27,11 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./cmd/server
FROM ${BACKEND_RUNTIME_IMAGE} FROM ${BACKEND_RUNTIME_IMAGE}
ARG ALPINE_MIRROR 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 \ 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 WORKDIR /app
+8
View File
@@ -1,6 +1,7 @@
ARG NODE_BUILDER_IMAGE=docker.m.daocloud.io/library/node:22-alpine 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 CADDY_BASE_IMAGE=docker.m.daocloud.io/library/caddy:2-alpine
ARG NPM_REGISTRY=https://registry.npmmirror.com ARG NPM_REGISTRY=https://registry.npmmirror.com
ARG ALPINE_MIRROR=https://mirrors.aliyun.com/alpine
FROM ${NODE_BUILDER_IMAGE} AS frontend-builder FROM ${NODE_BUILDER_IMAGE} AS frontend-builder
@@ -18,6 +19,13 @@ RUN npm run build
FROM ${CADDY_BASE_IMAGE} 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 Caddyfile /etc/caddy/Caddyfile
COPY --from=frontend-builder /app/frontend/dist /usr/share/caddy COPY --from=frontend-builder /app/frontend/dist /usr/share/caddy
+3
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"log" "log"
"time"
"affiliate_dash/internal/config" "affiliate_dash/internal/config"
"affiliate_dash/internal/database" "affiliate_dash/internal/database"
@@ -10,6 +11,7 @@ import (
"affiliate_dash/internal/pkg/applog" "affiliate_dash/internal/pkg/applog"
"affiliate_dash/internal/pkg/jwt" "affiliate_dash/internal/pkg/jwt"
"affiliate_dash/internal/pkg/openlog" "affiliate_dash/internal/pkg/openlog"
"affiliate_dash/internal/pkg/timeutil"
"affiliate_dash/internal/router" "affiliate_dash/internal/router"
"affiliate_dash/internal/service" "affiliate_dash/internal/service"
@@ -20,6 +22,7 @@ import (
func main() { func main() {
cfg := config.Load() cfg := config.Load()
time.Local = timeutil.Location()
// 日志:控制台 + 文件 // 日志:控制台 + 文件
logFile, err := applog.Setup(cfg.LogFile) logFile, err := applog.Setup(cfg.LogFile)
+1 -1
View File
@@ -47,7 +47,7 @@ func Load() *Config {
return &Config{ return &Config{
Port: getEnv("PORT", "8080"), Port: getEnv("PORT", "8080"),
JWTSecret: getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me"), 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, Mode: mode,
DataEncryptionKey: getEnv("DATA_ENCRYPTION_KEY", getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me")), 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"), 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", openlog.Info(c, "create_order ok order_no=%s idempotent=%v amount=%d",
result.Order.OrderNo, result.Idempotent, result.Order.Amount) result.Order.OrderNo, result.Idempotent, result.Order.Amount)
c.JSON(status, response.Body{ response.WithStatus(c, status, gin.H{
Code: 0, "order": buildOpenOrderResponse(result.Order),
Message: "ok", "idempotent": result.Idempotent,
Data: gin.H{
"order": buildOpenOrderResponse(result.Order),
"idempotent": result.Idempotent,
},
}) })
} }
+141 -1
View File
@@ -1,11 +1,23 @@
package response package response
import ( import (
"encoding/json"
"fmt"
"net/http" "net/http"
"reflect"
"strings"
"time"
"affiliate_dash/internal/pkg/timeutil"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
var (
timeType = reflect.TypeOf(time.Time{})
rawMessageType = reflect.TypeOf(json.RawMessage{})
)
type Body struct { type Body struct {
Code int `json:"code"` Code int `json:"code"`
Message string `json:"message"` Message string `json:"message"`
@@ -13,7 +25,11 @@ type Body struct {
} }
func OK(c *gin.Context, data interface{}) { 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) { 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) { func Page(c *gin.Context, list interface{}, total int64, page, size int) {
OK(c, PageData{List: list, Total: total, Page: page, Size: size}) 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" "time"
"affiliate_dash/internal/model" "affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/timeutil"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "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{}{ payload, err := json.Marshal(map[string]interface{}{
"event_id": eventID, "event_id": eventID,
"event": event, "event": event,
"occurred_at": now.UTC().Format(time.RFC3339Nano), "occurred_at": timeutil.FormatAPITime(now),
"data": data, "data": data,
}) })
if err != nil { if err != nil {
+2 -1
View File
@@ -16,6 +16,7 @@ import (
"time" "time"
"affiliate_dash/internal/model" "affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/timeutil"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -349,7 +350,7 @@ func (s *DeliveryService) claimDeliverySubmission(merchantID, apiClientID uint,
} }
raw, _ := json.Marshal(map[string]interface{}{ raw, _ := json.Marshal(map[string]interface{}{
"source": "delivery_proxy", "source": "delivery_proxy",
"submit_started_at": time.Now().UTC().Format(time.RFC3339), "submit_started_at": timeutil.FormatAPITime(time.Now()),
"provider_order_stage": "claimed", "provider_order_stage": "claimed",
}) })
if err := tx.Model(&order).Updates(map[string]interface{}{ if err := tx.Model(&order).Updates(map[string]interface{}{
+5 -6
View File
@@ -10,6 +10,7 @@ import (
"unicode/utf8" "unicode/utf8"
"affiliate_dash/internal/model" "affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/timeutil"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
@@ -599,11 +600,11 @@ func (s *FulfillmentService) ListWalletLedger(merchantID uint, page, size int) (
} }
func newFulfillmentOrderNo() string { 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 { 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 按"百分比或固定"二选一计算手续费: // calculateServiceFee 按"百分比或固定"二选一计算手续费:
@@ -712,9 +713,7 @@ func (s *FulfillmentService) Dashboard(merchantID uint, isPlatformAdmin bool) (*
if isPlatformAdmin { if isPlatformAdmin {
stats.Scope = "platform" stats.Scope = "platform"
} }
now := time.Now() todayStart := timeutil.StartOfDay(time.Now())
year, month, day := now.Date()
todayStart := time.Date(year, month, day, 0, 0, 0, 0, now.Location())
productScope := func() *gorm.DB { productScope := func() *gorm.DB {
tx := s.db.Model(&model.MerchantProduct{}) tx := s.db.Model(&model.MerchantProduct{})
@@ -1130,7 +1129,7 @@ func buildShipNotifyResultData(existing string, in ShipNotifyInput, shippedAt *t
m["fail_reason"] = in.FailReason m["fail_reason"] = in.FailReason
} }
if shippedAt != nil { if shippedAt != nil {
m["shipped_at"] = shippedAt.Format(time.RFC3339) m["shipped_at"] = timeutil.FormatAPITime(*shippedAt)
} }
if in.GameChannel != nil { if in.GameChannel != nil {
m["game_channel"] = *in.GameChannel m["game_channel"] = *in.GameChannel
+8 -1
View File
@@ -5,6 +5,9 @@ services:
- POSTGRES_USER=${POSTGRES_USER:-affiliate} - POSTGRES_USER=${POSTGRES_USER:-affiliate}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-affiliate_dev_password} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-affiliate_dev_password}
- POSTGRES_DB=${POSTGRES_DB:-affiliate_dash} - POSTGRES_DB=${POSTGRES_DB:-affiliate_dash}
- TZ=${TZ:-Asia/Shanghai}
- PGTZ=${TZ:-Asia/Shanghai}
command: ["postgres", "-c", "timezone=${TZ:-Asia/Shanghai}"]
volumes: volumes:
- postgres-data:/var/lib/postgresql/data - postgres-data:/var/lib/postgresql/data
restart: unless-stopped restart: unless-stopped
@@ -29,8 +32,9 @@ services:
- "8080:8080" - "8080:8080"
environment: environment:
- PORT=8080 - PORT=8080
- TZ=${TZ:-Asia/Shanghai}
- JWT_SECRET=${JWT_SECRET} - 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} - GIN_MODE=${GIN_MODE:-release}
- OPEN_API_KEY=${OPEN_API_KEY} - OPEN_API_KEY=${OPEN_API_KEY}
- OPEN_API_SECRET=${OPEN_API_SECRET} - 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} 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} CADDY_BASE_IMAGE: ${CADDY_BASE_IMAGE:-docker.m.daocloud.io/library/caddy:2-alpine}
NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmmirror.com} NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmmirror.com}
ALPINE_MIRROR: ${ALPINE_MIRROR:-https://mirrors.aliyun.com/alpine}
image: affiliate-caddy:latest image: affiliate-caddy:latest
ports: ports:
- "80:80" - "80:80"
- "443:443" - "443:443"
environment:
- TZ=${TZ:-Asia/Shanghai}
volumes: volumes:
- caddy-data:/data - caddy-data:/data
- caddy-config:/config - caddy-config:/config
+2
View File
@@ -47,6 +47,8 @@
两套签名串不同,即使 Header 名相似也不能互相套用。 两套签名串不同,即使 Header 名相似也不能互相套用。
所有对外返回和回调里的时间字段统一使用 RFC3339 秒级北京时间,例如 `2026-07-30T17:53:58+08:00`;请求侧仍接受合法 RFC3339 时间。
## 4. 状态语言 ## 4. 状态语言
内部订单使用 `payment_status` + `fulfillment_status` 两组状态;源头侧为了兼容对接,返回的是更贴近发货系统的旧状态名。 内部订单使用 `payment_status` + `fulfillment_status` 两组状态;源头侧为了兼容对接,返回的是更贴近发货系统的旧状态名。
+3 -3
View File
@@ -22,7 +22,7 @@
"order_no": "O202607240733306742", "order_no": "O202607240733306742",
"ship_status": "success", "ship_status": "success",
"provider_order_no": "6a6322a81b3b421994137260", "provider_order_no": "6a6322a81b3b421994137260",
"shipped_at": "2026-07-24T08:30:33.000Z", "shipped_at": "2026-07-24T16:30:33+08:00",
"fail_reason": "", "fail_reason": "",
"game_uid": "4808146277", "game_uid": "4808146277",
"role_name": "巫师哈丁12", "role_name": "巫师哈丁12",
@@ -38,7 +38,7 @@
"order_no": "O202607240733306742", "order_no": "O202607240733306742",
"ship_status": "failed", "ship_status": "failed",
"provider_order_no": "6a6322a81b3b421994137260", "provider_order_no": "6a6322a81b3b421994137260",
"shipped_at": "2026-07-24T08:30:33.000Z", "shipped_at": "2026-07-24T16:30:33+08:00",
"fail_reason": "角色名不存在,渠道服校验失败", "fail_reason": "角色名不存在,渠道服校验失败",
"game_uid": "4808146277", "game_uid": "4808146277",
"role_name": "巫师哈丁12", "role_name": "巫师哈丁12",
@@ -54,7 +54,7 @@
| `order_no` | 是 | 店铺订单号 | | `order_no` | 是 | 店铺订单号 |
| `ship_status` | 是 | 仅使用 `success` / `failed` | | `ship_status` | 是 | 仅使用 `success` / `failed` |
| `provider_order_no` | 否 | 上游发货单号 | | `provider_order_no` | 否 | 上游发货单号 |
| `shipped_at` | 否 | RFC3339 时间;`success` 未传时可由服务端补时间 | | `shipped_at` | 否 | RFC3339 时间,建议秒级北京时间`success` 未传时可由服务端补时间 |
| `fail_reason` | 失败时是 | `failed` 时必须填详细原因 | | `fail_reason` | 失败时是 | `failed` 时必须填详细原因 |
| `game_channel` | 否 | 账号区服 | | `game_channel` | 否 | 账号区服 |
| `game_uid` | 否 | 游戏角色 UUID | | `game_uid` | 否 | 游戏角色 UUID |
+2 -1
View File
@@ -37,6 +37,7 @@
- `code = 0`:成功 - `code = 0`:成功
- `code != 0`:失败,看 `message` - `code != 0`:失败,看 `message`
- 时间字段统一使用 RFC3339 秒级北京时间,例如 `2026-07-30T17:53:58+08:00`;请求侧仍接受合法 RFC3339 时间。
--- ---
@@ -239,7 +240,7 @@ Content-Type: application/json
| `order_no` | 是 | 店铺订单号 | | `order_no` | 是 | 店铺订单号 |
| `ship_status` | 是 | 仅 `success` / `failed` | | `ship_status` | 是 | 仅 `success` / `failed` |
| `provider_order_no` | 否 | 你们系统的发货单号 | | `provider_order_no` | 否 | 你们系统的发货单号 |
| `shipped_at` | 否 | RFC3339success 未传则用服务端时间 | | `shipped_at` | 否 | RFC3339 时间,建议秒级北京时间;success 未传则用服务端时间 |
| `fail_reason` | 失败时是 | `failed` 时必填,填写详细失败原因 | | `fail_reason` | 失败时是 | `failed` 时必填,填写详细失败原因 |
### 4.2 ship_status → 我们订单状态 ### 4.2 ship_status → 我们订单状态
+1 -1
View File
@@ -113,7 +113,7 @@ const deliveryLinkResponseExample = `{
"data": { "data": {
"order_no": "FO20260730000123", "order_no": "FO20260730000123",
"delivery_url": "https://shop.example/delivery/dlc/FO20260730000123?exp=1753934400&sign=...", "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, "exp": 1753934400,
"sign": "..." "sign": "..."
} }
+2 -6
View File
@@ -21,9 +21,9 @@ import {
QrcodeOutlined, QrcodeOutlined,
SendOutlined, SendOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import dayjs from 'dayjs'
import { deliveryApi } from '../api' import { deliveryApi } from '../api'
import type { DeliveryBindResult, DeliveryOrderInfo, DeliverySubmitResult } from '../types' import type { DeliveryBindResult, DeliveryOrderInfo, DeliverySubmitResult } from '../types'
import { formatDateTime } from '../utils/time'
const statusMap: Record<string, { color: string; text: string }> = { const statusMap: Record<string, { color: string; text: string }> = {
paid: { color: 'orange', text: '待发货' }, paid: { color: 'orange', text: '待发货' },
@@ -150,7 +150,7 @@ export default function Delivery() {
<Descriptions column={{ xs: 1, sm: 2 }} size="small" bordered> <Descriptions column={{ xs: 1, sm: 2 }} size="small" bordered>
<Descriptions.Item label="买家">{order.buyer_name || '-'}</Descriptions.Item> <Descriptions.Item label="买家">{order.buyer_name || '-'}</Descriptions.Item>
<Descriptions.Item label="金额">{order.amount} </Descriptions.Item> <Descriptions.Item label="金额">{order.amount} </Descriptions.Item>
<Descriptions.Item label="创建时间">{formatTime(order.created_at)}</Descriptions.Item> <Descriptions.Item label="创建时间">{formatDateTime(order.created_at)}</Descriptions.Item>
<Descriptions.Item label="渠道">{order.product?.game || channel.toUpperCase()}</Descriptions.Item> <Descriptions.Item label="渠道">{order.product?.game || channel.toUpperCase()}</Descriptions.Item>
</Descriptions> </Descriptions>
@@ -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 = { const panelStyle: CSSProperties = {
background: '#fff', background: '#fff',
border: '1px solid #e5e7eb', border: '1px solid #e5e7eb',
+7 -11
View File
@@ -23,8 +23,8 @@ import {
WalletOutlined, WalletOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table' import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import { merchantApi } from '../api' import { merchantApi } from '../api'
import { formatDateTime } from '../utils/time'
import type { import type {
ApiClient, ApiClient,
ApiCredential, ApiCredential,
@@ -473,9 +473,9 @@ export default function MerchantCenter() {
title: '链接有效期', title: '链接有效期',
dataIndex: 'delivery_link_expires_at', dataIndex: 'delivery_link_expires_at',
width: 160, width: 160,
render: (_, record) => record.delivery_link_revoked_at ? <Tag color="red"></Tag> : formatTime(record.delivery_link_expires_at), render: (_, record) => record.delivery_link_revoked_at ? <Tag color="red"></Tag> : formatDateTime(record.delivery_link_expires_at),
}, },
{ title: '时间', dataIndex: 'created_at', width: 160, render: formatTime }, { title: '时间', dataIndex: 'created_at', width: 180, render: formatDateTime },
{ {
title: '发货链接', title: '发货链接',
key: 'delivery_link', key: 'delivery_link',
@@ -499,7 +499,7 @@ export default function MerchantCenter() {
{ title: '余额', dataIndex: 'balance_after', width: 110, render: money }, { title: '余额', dataIndex: 'balance_after', width: 110, render: money },
{ title: '关联单号', dataIndex: 'reference_no', ellipsis: true }, { title: '关联单号', dataIndex: 'reference_no', ellipsis: true },
{ title: '备注', dataIndex: 'note', ellipsis: true, render: (v) => v || '-' }, { 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<ApiClient> = [ const apiClientColumns: ColumnsType<ApiClient> = [
@@ -508,7 +508,7 @@ export default function MerchantCenter() {
{ title: '签名', dataIndex: 'signature_version', width: 110, render: (v) => <Tag>{v}</Tag> }, { title: '签名', dataIndex: 'signature_version', width: 110, render: (v) => <Tag>{v}</Tag> },
{ title: '权限', dataIndex: 'scopes', ellipsis: true }, { title: '权限', dataIndex: 'scopes', ellipsis: true },
{ title: '状态', dataIndex: 'status', width: 90, render: activeStatusTag }, { 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: '操作', title: '操作',
key: 'action', key: 'action',
@@ -526,7 +526,7 @@ export default function MerchantCenter() {
{ title: 'URL', dataIndex: 'url', ellipsis: true }, { title: 'URL', dataIndex: 'url', ellipsis: true },
{ title: '事件', dataIndex: 'events', width: 260, ellipsis: true }, { title: '事件', dataIndex: 'events', width: 260, ellipsis: true },
{ title: '状态', dataIndex: 'status', width: 90, render: activeStatusTag }, { title: '状态', dataIndex: 'status', width: 90, render: activeStatusTag },
{ title: '创建时间', dataIndex: 'created_at', width: 160, render: formatTime }, { title: '创建时间', dataIndex: 'created_at', width: 180, render: formatDateTime },
{ {
title: '操作', title: '操作',
key: 'action', key: 'action',
@@ -824,7 +824,7 @@ export default function MerchantCenter() {
</Tag> </Tag>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="链接有效期"> <Descriptions.Item label="链接有效期">
{testOrderResult.order.delivery_link_revoked_at ? <Tag color="red"></Tag> : formatTime(testOrderResult.order.delivery_link_expires_at)} {testOrderResult.order.delivery_link_revoked_at ? <Tag color="red"></Tag> : formatDateTime(testOrderResult.order.delivery_link_expires_at)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="链接状态"> <Descriptions.Item label="链接状态">
<Typography.Text type="secondary"></Typography.Text> <Typography.Text type="secondary"></Typography.Text>
@@ -969,10 +969,6 @@ function moneyWithSign(value?: number | null) {
return `${prefix}${money(amount)}` return `${prefix}${money(amount)}`
} }
function formatTime(value?: string | null) {
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'
}
function productStatusTag(value: string) { function productStatusTag(value: string) {
return value === 'active' ? <Tag color="green"></Tag> : <Tag></Tag> return value === 'active' ? <Tag color="green"></Tag> : <Tag></Tag>
} }
+3
View File
@@ -134,6 +134,9 @@ function OverviewTab() {
<Text code>X-App-Key</Text><Text code>X-Timestamp</Text><Text code>X-Nonce</Text><Text code>X-Sign</Text> <Text code>X-App-Key</Text><Text code>X-Timestamp</Text><Text code>X-Nonce</Text><Text code>X-Sign</Text>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="时间偏差"> ±300 </Descriptions.Item> <Descriptions.Item label="时间偏差"> ±300 </Descriptions.Item>
<Descriptions.Item label="时间格式">
RFC3339 <Text code>2026-07-30T17:53:58+08:00</Text>
</Descriptions.Item>
<Descriptions.Item label="请求体">POST 使 <Text code>application/json</Text></Descriptions.Item> <Descriptions.Item label="请求体">POST 使 <Text code>application/json</Text></Descriptions.Item>
</Descriptions> </Descriptions>
</Card> </Card>
+2 -2
View File
@@ -16,10 +16,10 @@ import {
} from 'antd' } from 'antd'
import { GiftOutlined, PlusOutlined, ReloadOutlined, TeamOutlined } from '@ant-design/icons' import { GiftOutlined, PlusOutlined, ReloadOutlined, TeamOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table' import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { platformApi } from '../api' import { platformApi } from '../api'
import type { Merchant, MerchantMember, PageResult, ProductCatalogItem } from '../types' import type { Merchant, MerchantMember, PageResult, ProductCatalogItem } from '../types'
import { formatDateTime } from '../utils/time'
const memberRoleOptions = [ const memberRoleOptions = [
{ value: 'owner', label: '负责人' }, { value: 'owner', label: '负责人' },
@@ -195,7 +195,7 @@ export default function PlatformMerchants() {
), ),
}, },
{ title: '状态', dataIndex: 'status', width: 90, render: (v) => v === 'active' ? <Tag color="green"></Tag> : <Tag></Tag> }, { title: '状态', dataIndex: 'status', width: 90, render: (v) => v === 'active' ? <Tag color="green"></Tag> : <Tag></Tag> },
{ 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: '操作', title: '操作',
key: 'action', key: 'action',
+5
View File
@@ -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') : '-'
}
+30 -5
View File
@@ -26,8 +26,10 @@ POSTGRES_DB="${POSTGRES_DB:-affiliate_dash}"
POSTGRES_PORT="${POSTGRES_PORT:-5432}" POSTGRES_PORT="${POSTGRES_PORT:-5432}"
POSTGRES_IMAGE="${POSTGRES_IMAGE:-docker.m.daocloud.io/library/postgres:16-alpine}" POSTGRES_IMAGE="${POSTGRES_IMAGE:-docker.m.daocloud.io/library/postgres:16-alpine}"
DEV_DB_CONTAINER="${DEV_DB_CONTAINER:-affiliate_dash_postgres_dev}" 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 DATABASE_URL
export TZ
# 结束指定端口上的进程(避免残留占用) # 结束指定端口上的进程(避免残留占用)
free_port() { free_port() {
@@ -47,8 +49,28 @@ free_port() {
fi 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() { 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}" log "检测到本机 PostgreSQL 端口已可访问: ${POSTGRES_PORT}"
return 0 return 0
fi fi
@@ -56,9 +78,9 @@ ensure_postgres() {
log "未找到 Docker,请确认本机 PostgreSQL 已可通过 DATABASE_URL 访问" log "未找到 Docker,请确认本机 PostgreSQL 已可通过 DATABASE_URL 访问"
return 0 return 0
fi fi
if docker ps --format '{{.Names}}' | grep -qx "$DEV_DB_CONTAINER"; then if postgres_container_running; then
log "PostgreSQL 开发容器已运行: ${DEV_DB_CONTAINER}" 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}" log "启动 PostgreSQL 开发容器: ${DEV_DB_CONTAINER}"
docker start "$DEV_DB_CONTAINER" >/dev/null docker start "$DEV_DB_CONTAINER" >/dev/null
else else
@@ -69,8 +91,11 @@ ensure_postgres() {
-e POSTGRES_USER="$POSTGRES_USER" \ -e POSTGRES_USER="$POSTGRES_USER" \
-e POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \ -e POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \
-e POSTGRES_DB="$POSTGRES_DB" \ -e POSTGRES_DB="$POSTGRES_DB" \
-e TZ="$TZ" \
-e PGTZ="$TZ" \
-v "${DEV_DB_CONTAINER}-data:/var/lib/postgresql/data" \ -v "${DEV_DB_CONTAINER}-data:/var/lib/postgresql/data" \
"$POSTGRES_IMAGE" >/dev/null "$POSTGRES_IMAGE" \
-c timezone="$TZ" >/dev/null
fi fi
for i in $(seq 1 40); do for i in $(seq 1 40); do