From 1a5e1db2ec1c235885f82df24cbaaa2545afcf9f Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 6 Apr 2026 11:21:12 +0800 Subject: [PATCH] refactor(time): enhance timestamp handling with utility functions - Introduced NanoToTime and TimeToNano functions for converting between UnixNano and time.Time, improving clarity and consistency in timestamp management. - Updated Assistant model to utilize the new utility functions for created_at and updated_at fields. - Refactored Xun store methods to ensure UTC compatibility for timestamp serialization across different database drivers. - Added unit tests for the new conversion functions to validate their correctness and behavior. --- agent/assistant/assistant.go | 5 +-- agent/store/types/convert.go | 16 +++++++++ agent/store/xun/assistant.go | 16 ++++----- agent/store/xun/utils.go | 14 ++------ agent/store/xun/xun_internal_test.go | 23 ++++++++----- openapi/utils/convert.go | 18 ++++++++++ openapi/utils/convert_test.go | 51 ++++++++++++++++++++++++++++ 7 files changed, 112 insertions(+), 31 deletions(-) diff --git a/agent/assistant/assistant.go b/agent/assistant/assistant.go index 9b74ae58..5748b776 100644 --- a/agent/assistant/assistant.go +++ b/agent/assistant/assistant.go @@ -12,6 +12,7 @@ import ( "github.com/yaoapp/yao/agent/search" searchTypes "github.com/yaoapp/yao/agent/search/types" store "github.com/yaoapp/yao/agent/store/types" + "github.com/yaoapp/yao/openapi/utils" sui "github.com/yaoapp/yao/sui/core" ) @@ -147,8 +148,8 @@ func (ast *Assistant) Map() map[string]interface{} { "uses": ast.Uses, "search": ast.Search, "dependencies": ast.Dependencies, - "created_at": store.ToMySQLTime(ast.CreatedAt), - "updated_at": store.ToMySQLTime(ast.UpdatedAt), + "created_at": utils.NanoToTime(ast.CreatedAt), + "updated_at": utils.NanoToTime(ast.UpdatedAt), } } diff --git a/agent/store/types/convert.go b/agent/store/types/convert.go index 0713e0d8..d2977624 100644 --- a/agent/store/types/convert.go +++ b/agent/store/types/convert.go @@ -326,12 +326,28 @@ func ToAssistantModel(v interface{}) (*AssistantModel, error) { model.CreatedAt = createdAt } else if createdAt, ok := data["created_at"].(float64); ok { model.CreatedAt = int64(createdAt) + } else if createdAt, ok := data["created_at"].(time.Time); ok { + model.CreatedAt = createdAt.UnixNano() + } else if createdAt, ok := data["created_at"].(string); ok && createdAt != "" { + if ts, err := time.Parse(time.RFC3339Nano, createdAt); err == nil { + model.CreatedAt = ts.UnixNano() + } else if ts, err := time.Parse("2006-01-02 15:04:05", createdAt); err == nil { + model.CreatedAt = ts.UnixNano() + } } if updatedAt, ok := data["updated_at"].(int64); ok { model.UpdatedAt = updatedAt } else if updatedAt, ok := data["updated_at"].(float64); ok { model.UpdatedAt = int64(updatedAt) + } else if updatedAt, ok := data["updated_at"].(time.Time); ok { + model.UpdatedAt = updatedAt.UnixNano() + } else if updatedAt, ok := data["updated_at"].(string); ok && updatedAt != "" { + if ts, err := time.Parse(time.RFC3339Nano, updatedAt); err == nil { + model.UpdatedAt = ts.UnixNano() + } else if ts, err := time.Parse("2006-01-02 15:04:05", updatedAt); err == nil { + model.UpdatedAt = ts.UnixNano() + } } // Tags (string array) diff --git a/agent/store/xun/assistant.go b/agent/store/xun/assistant.go index 7279c6da..a50d6a2a 100644 --- a/agent/store/xun/assistant.go +++ b/agent/store/xun/assistant.go @@ -69,22 +69,20 @@ func (store *Xun) SaveAssistant(assistant *types.AssistantModel) (string, error) data["automated"] = assistant.Automated data["disable_global_prompts"] = assistant.DisableGlobalPrompts - // Set timestamps - now := time.Now().UnixNano() + // Use UTC time.Time so the DB driver serialises correctly for all dialects + // (PostgreSQL timestamptz, MySQL datetime, SQLite text) with no TZ ambiguity. + now := time.Now().UTC() if exists { - // Update: set updated_at, keep created_at unchanged if assistant.UpdatedAt == 0 { data["updated_at"] = now } else { - data["updated_at"] = assistant.UpdatedAt + data["updated_at"] = nanoToTime(assistant.UpdatedAt) } - // Don't modify created_at on update } else { - // Create: set created_at, updated_at is null if assistant.CreatedAt == 0 { data["created_at"] = now } else { - data["created_at"] = assistant.CreatedAt + data["created_at"] = nanoToTime(assistant.CreatedAt) } data["updated_at"] = nil } @@ -272,8 +270,8 @@ func (store *Xun) UpdateAssistant(assistantID string, updates map[string]interfa } } - // Always update updated_at timestamp - data["updated_at"] = types.ToMySQLTime(time.Now().UnixNano()) + // Always update updated_at timestamp (UTC time.Time for dialect portability) + data["updated_at"] = time.Now().UTC() if len(data) == 0 { return fmt.Errorf("no valid fields to update") diff --git a/agent/store/xun/utils.go b/agent/store/xun/utils.go index 084747bf..7ceca516 100644 --- a/agent/store/xun/utils.go +++ b/agent/store/xun/utils.go @@ -65,15 +65,5 @@ func getInt64(data map[string]interface{}, key string) int64 { return utils.ToInt64(v) } -// toDBTime converts UnixNano timestamp to database BIGINT format -func toDBTime(unixNano int64) int64 { - if unixNano == 0 { - return 0 - } - return unixNano -} - -// fromDBTime converts database BIGINT timestamp to UnixNano -func fromDBTime(dbTime int64) int64 { - return dbTime -} +func nanoToTime(ns int64) time.Time { return utils.NanoToTime(ns) } +func timeToNano(t time.Time) int64 { return utils.TimeToNano(t) } diff --git a/agent/store/xun/xun_internal_test.go b/agent/store/xun/xun_internal_test.go index 85ffea70..0bb7defb 100644 --- a/agent/store/xun/xun_internal_test.go +++ b/agent/store/xun/xun_internal_test.go @@ -2,6 +2,7 @@ package xun import ( "testing" + "time" "github.com/stretchr/testify/assert" "github.com/yaoapp/xun/capsule" @@ -105,16 +106,22 @@ func TestJsonContainsValueFallback(t *testing.T) { assert.Equal(t, `"test"`, val, "Default (mysql) strips % wrappers") } -func TestToDBTime(t *testing.T) { - assert.Equal(t, int64(0), toDBTime(0)) - assert.Equal(t, int64(1234567890), toDBTime(1234567890)) - assert.Equal(t, int64(-1), toDBTime(-1)) +func TestNanoToTime(t *testing.T) { + assert.True(t, nanoToTime(0).IsZero(), "zero input returns zero time") + + ns := int64(1609459200000000000) // 2021-01-01 00:00:00 UTC + got := nanoToTime(ns) + assert.Equal(t, 2021, got.Year()) + assert.Equal(t, time.January, got.Month()) + assert.Equal(t, 1, got.Day()) + assert.Equal(t, time.UTC, got.Location(), "must be UTC") } -func TestFromDBTime(t *testing.T) { - assert.Equal(t, int64(0), fromDBTime(0)) - assert.Equal(t, int64(1234567890), fromDBTime(1234567890)) - assert.Equal(t, int64(-1), fromDBTime(-1)) +func TestTimeToNano(t *testing.T) { + assert.Equal(t, int64(0), timeToNano(time.Time{}), "zero time returns 0") + + ts := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC) + assert.Equal(t, int64(1609459200000000000), timeToNano(ts)) } func init() { diff --git a/openapi/utils/convert.go b/openapi/utils/convert.go index 389da430..0a679ff6 100644 --- a/openapi/utils/convert.go +++ b/openapi/utils/convert.go @@ -214,6 +214,24 @@ func GetTimeFormat(locale string) string { } } +// NanoToTime converts a UnixNano int64 to UTC time.Time. +// Returns zero time for zero input. +func NanoToTime(ns int64) time.Time { + if ns == 0 { + return time.Time{} + } + return time.Unix(ns/1e9, ns%1e9).UTC() +} + +// TimeToNano converts time.Time to UnixNano int64. +// Returns 0 for zero time. +func TimeToNano(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.UnixNano() +} + // DBTimeFormats contains all time formats recognized by database drivers (MySQL, PostgreSQL, SQLite). // Ordered from most specific to least specific for efficient parsing. var DBTimeFormats = []string{ diff --git a/openapi/utils/convert_test.go b/openapi/utils/convert_test.go index 6fd088b3..0e0ea392 100644 --- a/openapi/utils/convert_test.go +++ b/openapi/utils/convert_test.go @@ -118,5 +118,56 @@ func TestToTimeStringWithPGFormats(t *testing.T) { } } +func TestNanoToTime(t *testing.T) { + t.Run("Zero", func(t *testing.T) { + assert.True(t, NanoToTime(0).IsZero()) + }) + + t.Run("ValidTimestamp", func(t *testing.T) { + ns := int64(1609459200000000000) // 2021-01-01 00:00:00 UTC + got := NanoToTime(ns) + assert.Equal(t, 2021, got.Year()) + assert.Equal(t, time.January, got.Month()) + assert.Equal(t, 1, got.Day()) + assert.Equal(t, 0, got.Hour()) + assert.Equal(t, time.UTC, got.Location()) + }) + + t.Run("PreservesNanoseconds", func(t *testing.T) { + ns := int64(1609459200123456789) + got := NanoToTime(ns) + assert.Equal(t, 123456789, got.Nanosecond()) + }) + + t.Run("Negative", func(t *testing.T) { + got := NanoToTime(-1) + assert.False(t, got.IsZero()) + assert.Equal(t, time.UTC, got.Location()) + }) +} + +func TestTimeToNano(t *testing.T) { + t.Run("Zero", func(t *testing.T) { + assert.Equal(t, int64(0), TimeToNano(time.Time{})) + }) + + t.Run("ValidTime", func(t *testing.T) { + ts := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC) + assert.Equal(t, int64(1609459200000000000), TimeToNano(ts)) + }) + + t.Run("WithNanoseconds", func(t *testing.T) { + ts := time.Date(2021, 1, 1, 0, 0, 0, 123456789, time.UTC) + assert.Equal(t, int64(1609459200123456789), TimeToNano(ts)) + }) + + t.Run("RoundTrip", func(t *testing.T) { + original := time.Date(2026, 3, 26, 15, 30, 45, 123456789, time.UTC) + ns := TimeToNano(original) + restored := NanoToTime(ns) + assert.True(t, original.Equal(restored)) + }) +} + func strPtr(s string) *string { return &s } func int64Ptr(i int64) *int64 { return &i }