fix(时区): 推送时间统一使用 UTC+8 而非服务器本地时区
Motivation: 推送时间原先依赖 time.Local,部署在不同时区的服务器会产生不一致的展示结果。统一为 UTC+8,确保所有订阅者看到的时间固定为北京时间。 Changes: * 新增 internal/tz 时区工具,集中定义 UTC+8 与格式化逻辑 * 通知、crypto 策略、交易信号的时间渲染统一改用 UTC+8 * 补充测试断言 pushedAt 为 UTC+8 当前时间 * 更新 README 中关于注入时间的说明 Breaking Changes: 所有推送时间字段将按 UTC+8 展示,若服务部署在非 UTC+8 时区且依赖原本地时间行为,输出会发生变化。
This commit is contained in:
@@ -312,7 +312,7 @@ Body 同创建。成功:`{"ok": true}`
|
|||||||
{{line "币种" .symbol}}{{line "周期" .period}}{{line "方向" .side}}{{line "价格" .price}}{{line "平均价" .totalAvgPx}}{{line "止盈价" .takeProfitPrice}}{{line "止损价" .stopLossPrice}}{{line "推送时间" .pushedAt}}
|
{{line "币种" .symbol}}{{line "周期" .period}}{{line "方向" .side}}{{line "价格" .price}}{{line "平均价" .totalAvgPx}}{{line "止盈价" .takeProfitPrice}}{{line "止损价" .stopLossPrice}}{{line "推送时间" .pushedAt}}
|
||||||
```
|
```
|
||||||
|
|
||||||
渲染时会自动注入 `event`、`pushedAt`(本地时间 `2006.01.02 15:04:05`)。
|
渲染时会自动注入 `event`、`pushedAt`(UTC+8,`2006.01.02 15:04:05`)。
|
||||||
|
|
||||||
也可用事件名:`{{case .event ".open" "开仓" ".close" "平仓"}}`
|
也可用事件名:`{{case .event ".open" "开仓" ".close" "平仓"}}`
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"aiaa-notification-service/internal/condition"
|
"aiaa-notification-service/internal/condition"
|
||||||
"aiaa-notification-service/internal/engine"
|
"aiaa-notification-service/internal/engine"
|
||||||
"aiaa-notification-service/internal/model"
|
"aiaa-notification-service/internal/model"
|
||||||
|
"aiaa-notification-service/internal/tz"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrUnprocessable = errors.New("unprocessable")
|
var ErrUnprocessable = errors.New("unprocessable")
|
||||||
@@ -71,7 +72,7 @@ func (s *Service) Process(ctx context.Context, req Request) (Result, error) {
|
|||||||
req.Data["event"] = req.Event
|
req.Data["event"] = req.Event
|
||||||
}
|
}
|
||||||
if _, ok := req.Data["pushedAt"]; !ok {
|
if _, ok := req.Data["pushedAt"]; !ok {
|
||||||
req.Data["pushedAt"] = time.Now().In(time.Local).Format("2006.01.02 15:04:05")
|
req.Data["pushedAt"] = tz.Format(time.Now(), "2006.01.02 15:04:05")
|
||||||
}
|
}
|
||||||
|
|
||||||
var channels []string
|
var channels []string
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"aiaa-notification-service/internal/engine"
|
"aiaa-notification-service/internal/engine"
|
||||||
"aiaa-notification-service/internal/model"
|
"aiaa-notification-service/internal/model"
|
||||||
@@ -153,6 +154,16 @@ func TestProcessInjectsPushedAt(t *testing.T) {
|
|||||||
if !strings.Contains(rt.content, "推送时间:") {
|
if !strings.Contains(rt.content, "推送时间:") {
|
||||||
t.Fatalf("content=%q", rt.content)
|
t.Fatalf("content=%q", rt.content)
|
||||||
}
|
}
|
||||||
|
got := strings.TrimPrefix(rt.content, "推送时间:")
|
||||||
|
got = strings.TrimSpace(got)
|
||||||
|
cst := time.FixedZone("CST", 8*3600)
|
||||||
|
parsed, err := time.ParseInLocation("2006.01.02 15:04:05", got, cst)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse %q: %v", got, err)
|
||||||
|
}
|
||||||
|
if d := time.Since(parsed); d < -2*time.Second || d > 2*time.Second {
|
||||||
|
t.Fatalf("pushedAt %q is not UTC+8 now, drift=%s", got, d)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcessInvalidConditions(t *testing.T) {
|
func TestProcessInvalidConditions(t *testing.T) {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"aiaa-notification-service/internal/tz"
|
||||||
)
|
)
|
||||||
|
|
||||||
type envelope struct {
|
type envelope struct {
|
||||||
@@ -95,7 +97,7 @@ func Convert(body []byte) (string, map[string]interface{}, error) {
|
|||||||
}
|
}
|
||||||
mergePayloadFields(data, payloadJSON)
|
mergePayloadFields(data, payloadJSON)
|
||||||
if env.EventTime > 0 {
|
if env.EventTime > 0 {
|
||||||
data["pushedAt"] = time.UnixMilli(env.EventTime).In(time.Local).Format("2006-01-02 15:04:05")
|
data["pushedAt"] = tz.Format(time.UnixMilli(env.EventTime), "2006-01-02 15:04:05")
|
||||||
}
|
}
|
||||||
if p.TotalGainTarget != 0 {
|
if p.TotalGainTarget != 0 {
|
||||||
data["totalGainTarget"] = p.TotalGainTarget
|
data["totalGainTarget"] = p.TotalGainTarget
|
||||||
@@ -260,7 +262,7 @@ func format(env envelope, p payload, action string) string {
|
|||||||
lines = append(lines, fmt.Sprintf("策略: %s", p.StrategyCode))
|
lines = append(lines, fmt.Sprintf("策略: %s", p.StrategyCode))
|
||||||
}
|
}
|
||||||
if env.EventTime > 0 {
|
if env.EventTime > 0 {
|
||||||
t := time.UnixMilli(env.EventTime).In(time.Local)
|
t := time.UnixMilli(env.EventTime).In(tz.CST)
|
||||||
lines = append(lines, fmt.Sprintf("Time: %s", t.Format("2006.01.02 15:04:05")))
|
lines = append(lines, fmt.Sprintf("Time: %s", t.Format("2006.01.02 15:04:05")))
|
||||||
}
|
}
|
||||||
return strings.Join(lines, "\n")
|
return strings.Join(lines, "\n")
|
||||||
|
|||||||
@@ -50,6 +50,25 @@ func TestConvertParsesNestedPayload(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConvertPushedAtIsUTC8(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"eventType":"SIGNAL_RECEIVED","symbol":"CRV","direction":"LONG",
|
||||||
|
"payload":"{\"strategyCode\":\"ai-crypto-signals\",\"isClose\":true,\"price\":0.2528}",
|
||||||
|
"eventTime":1786899539730
|
||||||
|
}`)
|
||||||
|
_, data, err := Convert(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if data["pushedAt"] != "2026-08-17 00:58:59" {
|
||||||
|
t.Fatalf("pushedAt=%v want UTC+8 2026-08-17 00:58:59", data["pushedAt"])
|
||||||
|
}
|
||||||
|
formatted, _ := data["formatted"].(string)
|
||||||
|
if !strings.Contains(formatted, "Time: 2026.08.17 00:58:59") {
|
||||||
|
t.Fatalf("formatted=%s", formatted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConvertCloseFlag(t *testing.T) {
|
func TestConvertCloseFlag(t *testing.T) {
|
||||||
body := []byte(`{
|
body := []byte(`{
|
||||||
"eventType":"SIGNAL_RECEIVED","symbol":"BTC","direction":"SHORT",
|
"eventType":"SIGNAL_RECEIVED","symbol":"BTC","direction":"SHORT",
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ package tradesignal
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
"aiaa-notification-service/internal/tz"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FormatOptions struct {
|
type FormatOptions struct {
|
||||||
@@ -85,7 +86,7 @@ func Format(signal *Signal, opts ...FormatOptions) string {
|
|||||||
lines = append(lines, fmt.Sprintf("策略: %s", signal.StrategyCode))
|
lines = append(lines, fmt.Sprintf("策略: %s", signal.StrategyCode))
|
||||||
}
|
}
|
||||||
|
|
||||||
eventTime := signal.ParsedEventTime().In(time.Local)
|
eventTime := signal.ParsedEventTime().In(tz.CST)
|
||||||
lines = append(lines, fmt.Sprintf("Time: %s", eventTime.Format("2006.01.02 15:04:05")))
|
lines = append(lines, fmt.Sprintf("Time: %s", eventTime.Format("2006.01.02 15:04:05")))
|
||||||
|
|
||||||
return strings.Join(lines, "\n")
|
return strings.Join(lines, "\n")
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package tz
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
var CST = time.FixedZone("CST", 8*3600)
|
||||||
|
|
||||||
|
func Format(t time.Time, layout string) string {
|
||||||
|
return t.In(CST).Format(layout)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user