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.
This commit is contained in:
Max 2026-04-06 11:21:12 +08:00
parent 8d44ef31c1
commit 1a5e1db2ec
7 changed files with 112 additions and 31 deletions

View file

@ -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),
}
}

View file

@ -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)

View file

@ -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")

View file

@ -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) }

View file

@ -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() {

View file

@ -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{

View file

@ -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 }