统一时间格式和时区
This commit is contained in:
+2
-1
@@ -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
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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{
|
||||
response.WithStatus(c, status, gin.H{
|
||||
"order": buildOpenOrderResponse(result.Order),
|
||||
"idempotent": result.Idempotent,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{}{
|
||||
|
||||
@@ -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
|
||||
|
||||
+8
-1
@@ -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
|
||||
|
||||
@@ -47,6 +47,8 @@
|
||||
|
||||
两套签名串不同,即使 Header 名相似也不能互相套用。
|
||||
|
||||
所有对外返回和回调里的时间字段统一使用 RFC3339 秒级北京时间,例如 `2026-07-30T17:53:58+08:00`;请求侧仍接受合法 RFC3339 时间。
|
||||
|
||||
## 4. 状态语言
|
||||
|
||||
内部订单使用 `payment_status` + `fulfillment_status` 两组状态;源头侧为了兼容对接,返回的是更贴近发货系统的旧状态名。
|
||||
|
||||
+3
-3
@@ -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 |
|
||||
|
||||
+2
-1
@@ -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 → 我们订单状态
|
||||
|
||||
@@ -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": "..."
|
||||
}
|
||||
|
||||
@@ -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<string, { color: string; text: string }> = {
|
||||
paid: { color: 'orange', text: '待发货' },
|
||||
@@ -150,7 +150,7 @@ export default function Delivery() {
|
||||
<Descriptions column={{ xs: 1, sm: 2 }} size="small" bordered>
|
||||
<Descriptions.Item label="买家">{order.buyer_name || '-'}</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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 ? <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: '发货链接',
|
||||
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<ApiClient> = [
|
||||
@@ -508,7 +508,7 @@ export default function MerchantCenter() {
|
||||
{ title: '签名', dataIndex: 'signature_version', width: 110, render: (v) => <Tag>{v}</Tag> },
|
||||
{ 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() {
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<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 label="链接状态">
|
||||
<Typography.Text type="secondary">由后端签名生成,可复制、打开或作废。</Typography.Text>
|
||||
@@ -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' ? <Tag color="green">上架</Tag> : <Tag>下架</Tag>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
@@ -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' ? <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: '操作',
|
||||
key: 'action',
|
||||
|
||||
@@ -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') : '-'
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user