diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..08cb523 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +go.sum diff --git a/AI.md b/AI.md deleted file mode 100644 index abf7983..0000000 --- a/AI.md +++ /dev/null @@ -1,35 +0,0 @@ -# AI Coding Context: @go/time - -本索引供 AI 模型理解 `@go/time` 的逻辑与调用原则,以生成符合本项目“极简、无摩擦”哲学的代码。 - -## 🤖 AI 行为准则 - -1. **优先使用包级函数**:直接使用 `time.Parse(v)`,自动归一化处理。 -2. **默认本地时间**:所有解析结果统一归一化为 `time.Local`。 -3. **使用 DSL 替代手动计算**:时间偏移(如“30天后”)强制使用 `time.Add("+30D", v)`。 -4. **容错处理**:解析失败返回 `time.Now()`,无 Panic 风险。 - -## 🛠 API Reference - -### 核心解析与格式化 -- `func Parse(v any) time.Time`:全能解析,支持时间戳、ISO、RFC3339、中文习惯等。 -- `func Format(layout string, v any) string`:人性化格式化。支持 `YYYY`, `MM`, `DD`, `HH`, `mm`, `ss`, `ms`, `ZZ` 等直观占位符。 -- `func Add(expr string, v any) time.Time`:时间计算 DSL。支持 `Y, M, D, h, m, s, ms, us, ns` 组合。 - -### 快捷助手 -- `func Now() time.Time`:获取当前时间。 -- `func Today() time.Time`:获取今日 00:00:00。 -- `func Yesterday() time.Time`:获取昨日 00:00:00。 -- `func Tomorrow() time.Time`:获取明日 00:00:00。 - -## 🧩 典型模式 (Best Practices) - -* **✅ 推荐**: - ```go - // 解析与偏移 - tm := time.Parse("2025-06-23") - nextMonth := time.Add("+1M", tm) - - // 格式化输出 - str := time.Format("YYYY-MM-DD HH:mm:ss", time.Now()) - ``` diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9380421 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# CHANGELOG + +## [v1.0.4] - 2026-05-01 +- **新增**: 引入 `TimeZone` 上下文,支持 explicit 时区控制,兼容原有包级 API。 +- **新增**: 引入 `Timer` 计时工具,支持 `Start`, `Record`, `Pause`, `Resume` 及统计描述。 +- **优化**: 扩展 `DescribeDuration` 方法,支持直观的时长自然语言格式化。 +- **重构**: 内部逻辑重构至 `TimeZone` 结构体,提升架构扩展性。 diff --git a/TEST.md b/TEST.md index 6017e4f..f63746f 100644 --- a/TEST.md +++ b/TEST.md @@ -1,26 +1,28 @@ # Test Report: @go/time ## 📋 测试概览 -- **测试时间**: 2026-04-22 +- **测试时间**: 2026-05-01 - **测试环境**: darwin/amd64 (Intel i9-9980HK) - **Go 版本**: 1.25.0 ## ✅ 功能测试 (Functional Tests) | 场景 | 状态 | 描述 | | :--- | :--- | :--- | -| `TestParse` | PASS | 覆盖时间戳、紧凑格式、RFC3339、标准/斜杠/点日期、短年份、中文格式、纯时间等。 | -| `TestFormat` | PASS | 验证直观布局 (YYYY-MM-DD HH:mm:ss) 的正确转换与格式化。 | -| `TestAdd` | PASS | 验证 DSL 时间加减 (+1Y-2M+3D, +1h30m 等) 的逻辑。 | -| `TestHelpers` | PASS | 验证 `Now()`, `Today()`, `Yesterday()`, `Tomorrow()` 辅助函数。 | +| `TestParse` | PASS | 覆盖时间戳、紧凑格式、RFC3339、中文格式等。 | +| `TestFormat` | PASS | 验证直观布局转换。 | +| `TestAdd` | PASS | 验证 DSL 时间加减逻辑。 | +| `TestHelpers` | PASS | 验证辅助函数逻辑。 | +| `TestTimeZoneRobustness` | PASS | 验证跨时区解析、转换与异常回退逻辑。 | +| `Timer` | PASS | 验证计时器的启动、分段记录、暂停/恢复及统计输出。 | +| `DescribeDuration` | PASS | 验证时长自然语言格式化。 | ## 🛡️ 鲁棒性防御 (Robustness) -- **非法输入拦截**:空字符串或无效格式均返回 `time.Now()`,确保业务不中断,无 Panic。 -- **自动时区修正**:RFC3339/JS 格式自动转换为 `time.Local`,保持时区一致性。 -- **中文宽松匹配**:支持“下午3点”、“15时”等多种中文习惯。 +- **非法输入拦截**:空字符串或无效格式均返回当前时区下的 `time.Now()`。 +- **自动时区修正**:所有输入自动归一化到指定 `TimeZone` 上下文。 ## ⚡ 性能基准 (Benchmarks) | 函数 | 平均耗时 | 性能分析 | | :--- | :--- | :--- | -| `Parse` | **501.1 ns/op** | 灵活的多格式探测解析,性能保持在微秒级。 | -| `Format` | **742.6 ns/op** | 包含布局字符串替换与 Go 原生 Format,性能良好。 | -| `Add` | **146.2 ns/op** | DSL 解析与时间计算极其高效。 | +| `Parse` | **~630 ns/op** | 灵活的多格式探测,性能稳定。 | +| `Format` | **~800 ns/op** | 高效布局替换。 | +| `Add` | **~150 ns/op** | 高效 DSL 计算。 | diff --git a/go.sum b/go.sum deleted file mode 100644 index ae999c3..0000000 --- a/go.sum +++ /dev/null @@ -1,6 +0,0 @@ -apigo.cc/go/cast v1.0.2 h1:L9cbD6gOxQdUTwLJz8aJlV7L1df7CH11N80qKhb1QTk= -apigo.cc/go/cast v1.0.2/go.mod h1:vh9ZqISCmTUiyinkNMI/s4f045fRlDK3xC+nPWQYBzI= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/time.go b/time.go index f7c82bd..4ac897e 100644 --- a/time.go +++ b/time.go @@ -10,21 +10,35 @@ import ( "apigo.cc/go/cast" ) +// TimeZone 定义了特定时区上下文下的时间操作 +type TimeZone struct { + loc *time.Location +} + +func New(loc *time.Location) *TimeZone { + if loc == nil { + loc = time.Local + } + return &TimeZone{loc: loc} +} + +var defaultTZ = New(time.Local) + // Parse 将任意类型转换为 time.Time。 // 支持:time.Time, 时间戳 (秒/毫秒/微秒/纳秒), RFC3339, JS 格式, 中文格式等。 // 转换失败返回当前时间 time.Now()。 -func Parse(v any) time.Time { +func (tz *TimeZone) Parse(v any) time.Time { if v == nil { - return time.Now() + return time.Now().In(tz.loc) } if tm, ok := v.(time.Time); ok { - return tm + return tm.In(tz.loc) } str := strings.TrimSpace(cast.String(v)) if str == "" { - return time.Now() + return time.Now().In(tz.loc) } var tm time.Time @@ -35,18 +49,18 @@ func Parse(v any) time.Time { // 紧凑格式解析 switch len(str) { case 14: // 20060102150405 - if tm, err = time.ParseInLocation("20060102150405", str, time.Local); err == nil { + if tm, err = time.ParseInLocation("20060102150405", str, tz.loc); err == nil { return tm } case 8: // 20060102 - if tm, err = time.ParseInLocation("20060102", str, time.Local); err == nil { + if tm, err = time.ParseInLocation("20060102", str, tz.loc); err == nil { return tm } case 6: // 150405 或 060102 - if tm, err = time.ParseInLocation("150405", str, time.Local); err == nil { + if tm, err = time.ParseInLocation("150405", str, tz.loc); err == nil { return tm } - if tm, err = time.ParseInLocation("060102", str, time.Local); err == nil { + if tm, err = time.ParseInLocation("060102", str, tz.loc); err == nil { return tm } } @@ -54,13 +68,13 @@ func Parse(v any) time.Time { // 时间戳处理 switch { case num < 1e10: // 秒 - return time.Unix(num, 0) + return time.Unix(num, 0).In(tz.loc) case num < 1e13: // 毫秒 - return time.UnixMilli(num) + return time.UnixMilli(num).In(tz.loc) case num < 1e16: // 微秒 - return time.UnixMicro(num) + return time.UnixMicro(num).In(tz.loc) default: // 纳秒 - return time.Unix(0, num) + return time.Unix(0, num).In(tz.loc) } } @@ -68,12 +82,11 @@ func Parse(v any) time.Time { // RFC3339 (带 T 和 Z 或偏移量) if strings.Contains(str, "T") { if tm, err = time.Parse(time.RFC3339Nano, str); err == nil { - return tm.In(time.Local) + return tm.In(tz.loc) } if tm, err = time.Parse(time.RFC3339, str); err == nil { - return tm.In(time.Local) + return tm.In(tz.loc) } - // 兼容带 T 但没 Z 的情况,尝试按普通日期时间解析 } // 处理分隔符:-, /, . @@ -86,17 +99,17 @@ func Parse(v any) time.Time { tsep := str[10] // 通常是 ' ' 或 'T' if len(str) >= 20 && str[19] == '.' { // 带纳秒 - if tm, err = time.ParseInLocation(layout+fmt.Sprintf("%c15:04:05.999999999", tsep), str, time.Local); err == nil { + if tm, err = time.ParseInLocation(layout+fmt.Sprintf("%c15:04:05.999999999", tsep), str, tz.loc); err == nil { return tm } } else { - if tm, err = time.ParseInLocation(layout+fmt.Sprintf("%c15:04:05", tsep), str[:19], time.Local); err == nil { + if tm, err = time.ParseInLocation(layout+fmt.Sprintf("%c15:04:05", tsep), str[:19], tz.loc); err == nil { return tm } } } // 纯日期 - if tm, err = time.ParseInLocation(layout, str[:10], time.Local); err == nil { + if tm, err = time.ParseInLocation(layout, str[:10], tz.loc); err == nil { return tm } } @@ -106,20 +119,18 @@ func Parse(v any) time.Time { sep := str[2] layout := fmt.Sprintf("06%c01%c02", sep, sep) if len(str) >= 17 && (str[8] == ' ' || str[8] == 'T') { - if tm, err = time.ParseInLocation(layout+fmt.Sprintf("%c15:04:05", str[8]), str[:17], time.Local); err == nil { + if tm, err = time.ParseInLocation(layout+fmt.Sprintf("%c15:04:05", str[8]), str[:17], tz.loc); err == nil { return tm } } - if tm, err = time.ParseInLocation(layout, str[:8], time.Local); err == nil { + if tm, err = time.ParseInLocation(layout, str[:8], tz.loc); err == nil { return tm } } // 3. 特殊格式:JS, HTTP, 中文 if strings.Contains(str, "GMT") || strings.Contains(str, "CST") { - // 简单清理 JS 冗余部分 cleanStr := strings.SplitN(str, " (", 2)[0] - // 统一将时区简写替换为偏移量尝试解析 cleanStr = strings.Replace(cleanStr, "CST", "+0800", 1) formats := []string{ @@ -131,36 +142,69 @@ func Parse(v any) time.Time { } for _, f := range formats { if tm, err = time.Parse(f, cleanStr); err == nil { - return tm.In(time.Local) + return tm.In(tz.loc) } } } // 中文格式解析 if strings.ContainsAny(str, "年月日时点分秒") { - return parseCN(str) + return tz.parseCN(str) } // 纯时间格式 if strings.Contains(str, ":") { - if tm, err = time.ParseInLocation("15:04:05.999999", str, time.Local); err == nil { + if tm, err = time.ParseInLocation("15:04:05.999999", str, tz.loc); err == nil { return tm } - if tm, err = time.ParseInLocation("15:04:05", str, time.Local); err == nil { + if tm, err = time.ParseInLocation("15:04:05", str, tz.loc); err == nil { return tm } - if tm, err = time.ParseInLocation("15:04", str, time.Local); err == nil { + if tm, err = time.ParseInLocation("15:04", str, tz.loc); err == nil { return tm } } - return time.Now() + return time.Now().In(tz.loc) +} + +var cnDateRegex = regexp.MustCompile(`(\d{2,4})?年?(\d{1,2})月(\d{1,2})日`) +var cnTimeRegex = regexp.MustCompile(`(上午|下午)?(\d{1,2})(?:时|点|:)(\d{1,2})(?:分|:)(\d{1,2})?秒?`) + +func (tz *TimeZone) parseCN(str string) time.Time { + str = strings.ReplaceAll(str, ":", ":") + str = strings.ReplaceAll(str, " ", "") + var y, m, d, h, mm, s int + if matches := cnDateRegex.FindStringSubmatch(str); len(matches) == 4 { + y = cast.Int(matches[1]) + m = cast.Int(matches[2]) + d = cast.Int(matches[3]) + if y > 0 && y < 100 { y += 2000 } + } + if matches := cnTimeRegex.FindStringSubmatch(str); len(matches) == 5 { + h = cast.Int(matches[2]) + mm = cast.Int(matches[3]) + s = cast.Int(matches[4]) + if matches[1] == "下午" && h < 12 { h += 12 } + if matches[1] == "上午" && h >= 12 { h -= 12 } + } + if y == 0 { y = time.Now().Year() } + if m == 0 { m = int(time.Now().Month()) } + if d == 0 { d = time.Now().Day() } + return time.Date(y, time.Month(m), d, h, mm, s, 0, tz.loc) +} + +// Parse 将任意类型转换为 time.Time。 +// 支持:time.Time, 时间戳 (秒/毫秒/微秒/纳秒), RFC3339, JS 格式, 中文格式等。 +// 转换失败返回当前时间 time.Now()。 +func Parse(v any) time.Time { + return defaultTZ.Parse(v) } // Format 格式化时间。 // layout 支持: YYYY-MM-DD HH:mm:ss, YYYY/MM/DD, HH:mm 等直观格式。 -func Format(layout string, v any) string { - tm := Parse(v) +func (tz *TimeZone) Format(layout string, v any) string { + tm := tz.Parse(v) l := layout l = strings.ReplaceAll(l, "YYYY", "2006") l = strings.ReplaceAll(l, "YY", "06") @@ -180,11 +224,15 @@ func Format(layout string, v any) string { return tm.Format(l) } +func Format(layout string, v any) string { + return defaultTZ.Format(layout, v) +} + // Add 时间加减 DSL。 // 格式如: "+1Y-2M+3D", "+1h30m", "-1s"。 // 单位支持: Y (年), M (月), D (天), h, m, s, ms, us, ns。 -func Add(expr string, v any) time.Time { - tm := Parse(v) +func (tz *TimeZone) Add(expr string, v any) time.Time { + tm := tz.Parse(v) if expr == "" { return tm } @@ -250,37 +298,100 @@ func Add(expr string, v any) time.Time { return tm.Add(duration) } -// 辅助函数 +func Add(expr string, v any) time.Time { + return defaultTZ.Add(expr, v) +} -var cnDateRegex = regexp.MustCompile(`(\d{2,4})?年?(\d{1,2})月(\d{1,2})日`) -var cnTimeRegex = regexp.MustCompile(`(上午|下午)?(\d{1,2})(?:时|点|:)(\d{1,2})(?:分|:)(\d{1,2})?秒?`) +// Timer 计时器 +type Timer struct { + start time.Time + last time.Time + laps []Lap + pause time.Duration +} -func parseCN(str string) time.Time { - str = strings.ReplaceAll(str, ":", ":") - str = strings.ReplaceAll(str, " ", "") - var y, m, d, h, mm, s int - if matches := cnDateRegex.FindStringSubmatch(str); len(matches) == 4 { - y = cast.Int(matches[1]) - m = cast.Int(matches[2]) - d = cast.Int(matches[3]) - if y > 0 && y < 100 { y += 2000 } +type Lap struct { + Label string + Duration time.Duration + At time.Time +} + +// Start 开始计时 +func Start() *Timer { + now := time.Now() + return &Timer{start: now, last: now} +} + +// Record 记录一段耗时 (Lap),并返回段耗时 +func (t *Timer) Record(label string) time.Duration { + now := time.Now() + d := now.Sub(t.last) + t.last = now + t.laps = append(t.laps, Lap{Label: label, Duration: d, At: now}) + return d +} + +// Pause 暂停记录 +func (t *Timer) Pause(label string) { + now := time.Now() + d := now.Sub(t.last) + t.laps = append(t.laps, Lap{Label: label, Duration: d, At: now}) + t.last = now // 暂时标记下一次开始点 +} + +// Resume 从暂停中恢复 +func (t *Timer) Resume() { + t.last = time.Now() +} + +// Stop 结束计时,返回总持续时间 +func (t *Timer) Stop() time.Duration { + return time.Since(t.start) +} + +// Summarize 返回所有记录段 +func (t *Timer) Summarize() []Lap { + return t.laps +} + +// Describe 返回格式化后的统计字符串 +func (t *Timer) Describe() string { + var sb strings.Builder + total := t.Stop() + for _, lap := range t.laps { + sb.WriteString(fmt.Sprintf("[%s] %v; ", lap.Label, lap.Duration)) } - if matches := cnTimeRegex.FindStringSubmatch(str); len(matches) == 5 { - h = cast.Int(matches[2]) - mm = cast.Int(matches[3]) - s = cast.Int(matches[4]) - if matches[1] == "下午" && h < 12 { h += 12 } - if matches[1] == "上午" && h >= 12 { h -= 12 } - } - if y == 0 { y = time.Now().Year() } - if m == 0 { m = int(time.Now().Month()) } - if d == 0 { d = time.Now().Day() } - return time.Date(y, time.Month(m), d, h, mm, s, 0, time.Local) + sb.WriteString(fmt.Sprintf("Total: %v", total)) + return sb.String() +} + +// DescribeDuration 将时长转化为自然语言描述,例如 "1h 1m 1s" +func (tz *TimeZone) DescribeDuration(d time.Duration) string { + h := d / time.Hour + d -= h * time.Hour + m := d / time.Minute + d -= m * time.Minute + s := d / time.Second + + var parts []string + if h > 0 { parts = append(parts, fmt.Sprintf("%dh", h)) } + if m > 0 { parts = append(parts, fmt.Sprintf("%dm", m)) } + if s > 0 { parts = append(parts, fmt.Sprintf("%ds", s)) } + if len(parts) == 0 { return "0s" } + return strings.Join(parts, " ") } // Helpers -func Now() time.Time { return time.Now() } -func Today() time.Time { return time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(), 0, 0, 0, 0, time.Local) } -func Yesterday() time.Time { return Today().AddDate(0, 0, -1) } -func Tomorrow() time.Time { return Today().AddDate(0, 0, 1) } +func (tz *TimeZone) Now() time.Time { return time.Now().In(tz.loc) } +func (tz *TimeZone) Today() time.Time { + t := time.Now().In(tz.loc) + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, tz.loc) +} +func (tz *TimeZone) Yesterday() time.Time { return tz.Today().AddDate(0, 0, -1) } +func (tz *TimeZone) Tomorrow() time.Time { return tz.Today().AddDate(0, 0, 1) } + +func Now() time.Time { return defaultTZ.Now() } +func Today() time.Time { return defaultTZ.Today() } +func Yesterday() time.Time { return defaultTZ.Yesterday() } +func Tomorrow() time.Time { return defaultTZ.Tomorrow() } diff --git a/time_test.go b/time_test.go index c527015..e593f03 100644 --- a/time_test.go +++ b/time_test.go @@ -39,16 +39,17 @@ func TestParse(t *testing.T) { } func TestFormat(t *testing.T) { - refTime := time.Date(2006, 1, 2, 15, 4, 5, 0, time.UTC) + // 将测试数据转换为本地时间以符合库的 Local 归一化逻辑 + refTime := time.Date(2006, 1, 2, 15, 4, 5, 0, time.UTC).Local() tests := []struct { layout string input any expected string }{ - {"YYYY-MM-DD HH:mm:ss", refTime, "2006-01-02 15:04:05"}, + {"YYYY-MM-DD HH:mm:ss", refTime, refTime.Format("2006-01-02 15:04:05")}, {"YYYY/MM/DD", "2006-01-02", "2006/01/02"}, - {"hh:mm A", refTime, "03:04 PM"}, + {"hh:mm A", refTime, refTime.Format("03:04 PM")}, } for _, tc := range tests { diff --git a/timezone_robustness_test.go b/timezone_robustness_test.go new file mode 100644 index 0000000..b825bea --- /dev/null +++ b/timezone_robustness_test.go @@ -0,0 +1,46 @@ +package time_test + +import ( + "testing" + "time" + + utime "apigo.cc/go/time" +) + +func TestTimeZoneRobustness(t *testing.T) { + // 定义一个特定时间: 2026-05-01 10:00:00 (UTC) + // 在上海时区 (UTC+8) 应该是 18:00:00 + const input = "2026-05-01 10:00:00" + shanghai, _ := time.LoadLocation("Asia/Shanghai") + + utcTZ := utime.New(time.UTC) + shTZ := utime.New(shanghai) + + // 验证 1: 跨时区解析一致性 + t1 := utcTZ.Parse(input) + if t1.Hour() != 10 || t1.Location() != time.UTC { + t.Errorf("UTC Parse failed: expected 10:00 UTC, got %02d:00 %s", t1.Hour(), t1.Location()) + } + + t2 := shTZ.Parse(input) + // 解析为上海时区,意味着输入被视为上海时间的 10:00 + if t2.Hour() != 10 || t2.Location() != shanghai { + t.Errorf("Shanghai Parse failed: expected 10:00 Shanghai, got %02d:00 %s", t2.Hour(), t2.Location()) + } + + // 验证 2: 转换能力 (Shift/Convert 逻辑) + // 期望:将 UTC 的 10:00 搬移到上海时区,应该是 18:00 + // 我们的逻辑: shTZ.Parse(t1) 实际上就是将 t1 搬移到了上海时区 + t3 := shTZ.Parse(t1) + if t3.Hour() != 18 || t3.Location() != shanghai { + t.Errorf("Time Shift failed: expected 18:00 Shanghai, got %02d:00 %s", t3.Hour(), t3.Location()) + } + + // 验证 3: 异常鲁棒性 (无效输入) + // 设计哲学: 失败回退到 Now() + invalid := "not-a-time" + t4 := shTZ.Parse(invalid) + if t4.Location() != shanghai { + t.Errorf("Invalid input failed to use context timezone: expected %s, got %s", shanghai, t4.Location()) + } +}