第一章:Go json转map时时间字段异常的典型现象
当使用 json.Unmarshal 将 JSON 字符串解析为 map[string]interface{} 时,时间字段(如 "2024-03-15T10:30:45Z")不会被自动识别为 time.Time 类型,而是以原始字符串形式存入 map 中。这是 Go 标准库 encoding/json 的默认行为——它仅对结构体字段上的 time.Time 类型标签(如 json:"created_at,time")生效,而对 interface{} 类型无任何类型推断能力。
常见异常表现
- 时间字段在 map 中表现为
string类型,而非预期的time.Time; - 后续调用
time.Parse时因格式不匹配或时区处理缺失导致parsing time错误; - 若 JSON 中时间含毫秒(如
"2024-03-15T10:30:45.123Z"),标准 RFC3339 解析会失败,需显式指定布局。
复现示例
jsonStr := `{"event": "login", "timestamp": "2024-03-15T10:30:45.123Z"}`
var data map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
log.Fatal(err) // 不会报错,但 timestamp 是 string 类型
}
fmt.Printf("Type of timestamp: %T\n", data["timestamp"]) // 输出: string
fmt.Printf("Value: %v\n", data["timestamp"]) // 输出: "2024-03-15T10:30:45.123Z"
关键差异对比
| 解析目标 | 时间字段类型 | 是否自动解析 | 说明 |
|---|---|---|---|
struct{ Timestamp time.Time } |
time.Time |
✅ 是 | 需配合 time tag 或自定义 UnmarshalJSON |
map[string]interface{} |
string |
❌ 否 | 完全依赖开发者手动转换 |
手动修复建议
若必须使用 map[string]interface{},应在取值后显式转换:
tsStr, ok := data["timestamp"].(string)
if !ok {
return errors.New("timestamp is not a string")
}
t, err := time.Parse(time.RFC3339Nano, tsStr) // 支持纳秒精度
if err != nil {
t, err = time.Parse(time.RFC3339, tsStr) // 回退到秒级精度
}
if err != nil {
return fmt.Errorf("failed to parse timestamp: %w", err)
}
该流程确保兼容带/不带毫秒的时间字符串,并避免 panic。
第二章:JSON解析机制与时间字段类型退化原理
2.1 Go标准库json.Unmarshal对interface{}的默认类型推导规则
当 json.Unmarshal 解析 JSON 到 interface{} 时,Go 采用静态类型映射策略,而非运行时动态推断:
默认类型映射表
| JSON 值类型 | Go interface{} 中的实际底层类型 |
|---|---|
null |
nil |
boolean |
bool |
number(无小数点) |
float64(⚠️ 注意:非 int) |
number(含小数点) |
float64 |
string |
string |
array |
[]interface{} |
object |
map[string]interface{} |
关键代码示例
var data interface{}
json.Unmarshal([]byte(`[1, "hello", true]`), &data)
// data 实际类型为 []interface{},其中:
// data.([]interface{})[0] 是 float64(1)
// data.([]interface{})[1] 是 string("hello")
// data.([]interface{})[2] 是 bool(true)
逻辑说明:
Unmarshal不查看上下文或目标结构体标签,仅依据 JSON 语法字面量决定interface{}的具体类型;整数1被统一转为float64,因 JSON 规范未区分整型与浮点型。
graph TD
A[JSON 字符串] --> B{解析字面量}
B -->|number| C[float64]
B -->|string| D[string]
B -->|object| E[map[string]interface{}]
B -->|array| F[[]interface{}]
2.2 RFC 3339时间字符串在map[string]interface{}中被误判为float64的底层原因分析
JSON 解析的类型推断机制
Go 标准库 encoding/json 在解码为 map[string]interface{} 时,对数字字面量无上下文感知:"2023-09-15T12:34:56Z" 若被错误地识别为纯数字(如含连字符但未引号包裹),实际不会发生;但更常见的是——当上游系统(如某些 Python/JS 库)将时间序列化为毫秒级 Unix 时间戳(如 1694781296123)且未加双引号时,JSON 解析器将其视为 float64。
类型映射规则表
| JSON 原始值 | json.Unmarshal → interface{} 类型 |
|---|---|
1694781296.123 |
float64 |
"1694781296.123" |
string |
"2023-09-15T12:34:56Z" |
string(正确) |
关键代码示例
var data map[string]interface{}
json.Unmarshal([]byte(`{"ts": 1694781296.123}`), &data)
// data["ts"] 的类型是 float64,而非预期 string
fmt.Printf("%T\n", data["ts"]) // → float64
逻辑分析:
json包默认将无引号数字字面量统一转为float64(即使整数),因 IEEE 754 双精度可精确表示 ≤2⁵³ 的整数,但丢失了语义意图。RFC 3339 字符串若被上游误作数字序列化(如时间戳毫秒值),在此环节即永久失去格式信息。
graph TD
A[JSON bytes] --> B{Has quotes around ts?}
B -->|Yes| C[string → interface{}]
B -->|No| D[float64 → interface{}]
D --> E[Loss of RFC 3339 semantics]
2.3 JSON数字精度丢失与time.Time零值覆盖的双重陷阱复现与验证
数据同步机制
Go 中 json.Marshal 对 float64 默认使用 strconv.FormatFloat,在科学计数法下可能截断尾部有效位;而 time.Time 若未显式初始化,在 JSON 序列化时会输出 "0001-01-01T00:00:00Z" —— 即零值被“合法化”写入。
复现代码
type Order struct {
ID int `json:"id"`
Amount float64 `json:"amount"`
At time.Time `json:"at"`
}
o := Order{ID: 1, Amount: 999999999999999.999, At: time.Time{}}
b, _ := json.Marshal(o)
fmt.Println(string(b))
// 输出:{"id":1,"amount":1e+15,"at":"0001-01-01T00:00:00Z"}
Amount被格式化为1e+15(丢失.999),At零值未被忽略,直接暴露。json包默认不校验零值语义,亦无精度控制钩子。
关键差异对比
| 场景 | JSON 输出 | 实际含义 |
|---|---|---|
float64(1e15 + 0.9) |
"1e+15" |
精度丢失 ≥0.9 |
time.Time{} |
"0001-01-01T00:00:00Z" |
业务上常表示“未设置” |
防御路径
- 使用
json.Marshaler接口定制Amount(如转字符串) - 用
*time.Time+omitempty避免零值序列化
2.4 不同Go版本(1.19–1.23)对ISO8601时间字符串解析行为的兼容性差异实测
Go 标准库 time.Parse 对 ISO8601 字符串(如 "2023-10-05T14:30:45Z")的容忍度在 1.19–1.23 间发生关键演进:1.19–1.21 严格要求时区偏移格式(如 +0000),而 1.22+ 开始支持 Z 和 ±HH:MM(RFC 3339 子集)。
关键测试用例
t, err := time.Parse(time.RFC3339, "2023-10-05T14:30:45+01:00") // ✅ 全版本支持
t, err := time.Parse(time.RFC3339, "2023-10-05T14:30:45Z") // ❌ Go 1.19–1.21 报错;✅ 1.22+
t, err := time.Parse(time.RFC3339, "2023-10-05T14:30:45+01:30") // ✅ 仅 1.22+ 支持(含分偏移)
time.RFC3339 在 1.22 中被增强为兼容 RFC 3339 完整 时区语法,此前仅解析 ±HHMM 形式。
版本兼容性对照表
| Go 版本 | "2023-10-05T14:30:45Z" |
"2023-10-05T14:30:45+01:30" |
"2023-10-05T14:30:45+0100" |
|---|---|---|---|
| 1.19–1.21 | ❌ parsing time error |
❌ | ✅ |
| 1.22–1.23 | ✅ | ✅ | ✅ |
⚠️ 生产环境若需跨版本兼容,建议统一使用
time.Parse(time.RFC3339Nano, ...)或预处理Z→+0000。
2.5 前端序列化、网络传输、中间件透传等多环节导致时间格式污染的链路追踪
时间在跨系统流转中极易发生格式失真:前端 Date 对象序列化为 ISO 字符串时丢失时区上下文,HTTP 请求头或 JSON body 中混用 Unix 时间戳与字符串,网关中间件未统一解析策略,最终导致下游服务解析出错。
常见污染场景
JSON.stringify(new Date())→"2024-06-15T08:30:00.000Z"(UTC)- 后端误按本地时区解析该字符串,产生 8 小时偏移
- 中间件透传时对
X-Request-Time头未做标准化校验
典型污染链路
graph TD
A[前端 new Date()] -->|JSON.stringify| B[ISO 8601字符串]
B --> C[HTTP Body/Headers]
C --> D[API 网关:未做时区归一化]
D --> E[微服务:new Date(str) 本地解析]
E --> F[数据库写入错误时间]
关键修复代码示例
// 统一序列化为带时区的 ISO 字符串(保留原始时区语义)
function serializeTime(date) {
return date.toLocaleString('sv-SE', {
timeZone: date.getTimezoneOffset() === 0 ? 'UTC' : undefined
}).replace(',', 'T') + '.000'; // 示例:2024-06-15T16:30:00.000+08:00
}
该函数显式保留客户端本地时区偏移(如 +08:00),避免强制转 UTC;toLocaleString('sv-SE') 确保分隔符符合 ISO 标准,.replace(',', 'T') 修正部分浏览器逗号分隔问题。
第三章:绕过标准Unmarshal的临时解决方案对比
3.1 使用json.RawMessage延迟解析并手动转换time.Time的工程实践
在微服务间时间字段格式不一致(如 2024-01-01T12:00:00Z vs "2024-01-01 12:00:00")时,直接绑定 time.Time 易触发 parsing time 错误。
核心策略:延迟解析 + 柔性转换
使用 json.RawMessage 暂存原始字节,避开 JSON 解码器的默认时间解析逻辑:
type Event struct {
ID int `json:"id"`
AtRaw json.RawMessage `json:"at"` // 延迟解析占位
At time.Time `json:"-"` // 运行时赋值
}
✅
json.RawMessage本质是[]byte,零拷贝保留原始 JSON 字符串;"-"标签跳过结构体字段的自动 JSON 映射。
手动解析流程
func (e *Event) UnmarshalJSON(data []byte) error {
type Alias Event // 防止递归调用
aux := &struct {
AtRaw json.RawMessage `json:"at"`
*Alias
}{
Alias: (*Alias)(e),
}
if err := json.Unmarshal(data, aux); err != nil {
return err
}
// 支持多格式解析
for _, layout := range []string{
time.RFC3339,
"2006-01-02 15:04:05",
"2006-01-02T15:04:05",
} {
if t, err := time.ParseInLocation(layout, string(aux.AtRaw), time.UTC); err == nil {
e.At = t
return nil
}
}
return fmt.Errorf("cannot parse time from %s", string(aux.AtRaw))
}
🔍
UnmarshalJSON自定义实现中:先通过匿名嵌套结构体完成基础字段解码,再对AtRaw尝试多种布局解析——兼顾兼容性与可控性。
| 方案 | 性能开销 | 格式容错 | 维护成本 |
|---|---|---|---|
直接 time.Time |
低 | ❌ 严格 | 低 |
json.RawMessage + 手动解析 |
中 | ✅ 多格式 | 中 |
string 字段 + 后处理 |
高(内存复制) | ✅ | 高 |
graph TD
A[收到JSON字节] --> B[跳过At字段解析]
B --> C[存入json.RawMessage]
C --> D[UnmarshalJSON中遍历layout]
D --> E{匹配成功?}
E -->|是| F[赋值e.At]
E -->|否| G[返回解析错误]
3.2 预定义struct + json.Number规避float64转换的边界条件与性能权衡
JSON解析默认将数字转为float64,导致整数精度丢失(如9007199254740992 → 9007199254740992.0)和大整数截断。
核心策略:结构体字段显式绑定json.Number
type Order struct {
ID json.Number `json:"id"`
Name string `json:"name"`
}
json.Number是字符串类型别名,延迟解析,避免float64中间表示。调用ID.Int64()或ID.Float64()按需转换,零拷贝字符串引用,无精度损失。
性能与安全权衡对比
| 场景 | float64 默认解析 | json.Number + 显式转换 |
|---|---|---|
| 10万次整数ID解析 | 82ms,精度风险 | 115ms,100%保真 |
| 内存分配次数 | 2×/字段 | 0×(复用原始字节切片) |
边界条件处理流程
graph TD
A[收到JSON字节流] --> B{含数字字段?}
B -->|是| C[保留原始字符串形式]
B -->|否| D[常规解码]
C --> E[调用Int64/Uint64/Float64按需解析]
E --> F[panic if overflow]
3.3 第三方库(如go-json、easyjson)在map场景下对时间字段的处理能力评测
时间字段在 map[string]interface{} 中的典型困境
当 JSON 中的时间字段(如 "created_at": "2024-03-15T08:30:00Z")被 json.Unmarshal 解析为 map[string]interface{} 时,其值默认为 string 类型,而非 time.Time,导致后续需手动转换且易出错。
各库对 map 场景的支持对比
| 库名 | 原生支持 time.Time in map |
需注册自定义解码器 | 性能开销(相对标准库) |
|---|---|---|---|
encoding/json |
❌(仅 string/float64) | ✅(需 UnmarshalJSON) |
— |
go-json |
❌ | ✅(RegisterTypeDecoder) |
+12% |
easyjson |
❌(不支持 map 动态时间解析) | ❌(仅结构体生成) | -8%(但无 map 时间能力) |
go-json 手动注入时间解码器示例
// 注册全局 decoder:将 map 中特定 key 的 string 自动转为 time.Time
gojson.RegisterTypeDecoder(reflect.TypeOf(time.Time{}),
func(d *gojson.Decoder, v interface{}) error {
s, err := d.ReadString() // 读取原始字符串
if err != nil { return err }
t, err := time.Parse(time.RFC3339, s)
*(v.(*time.Time)) = t
return err
})
该逻辑仅在 d.Decode(&m) 且 m 为结构体时生效;对纯 map[string]interface{} 仍需后处理——暴露了第三方库在动态 schema 下的固有局限。
graph TD
A[JSON input] --> B{是否为 struct?}
B -->|Yes| C[调用注册decoder]
B -->|No| D[保留为 string]
C --> E[→ time.Time]
D --> F[需显式转换]
第四章:构建健壮的自定义TimeUnmarshaler体系
4.1 实现通用TimeUnmarshaler接口:支持RFC 3339、Unix、ISO 8601多格式自动识别
为统一处理异构时间输入,定义 TimeUnmarshaler 接口并实现智能解析逻辑:
type TimeUnmarshaler time.Time
func (t *TimeUnmarshaler) UnmarshalJSON(data []byte) error {
s := strings.Trim(string(data), `"`)
if ts, err := parseRFC3339(s); err == nil {
*t = TimeUnmarshaler(ts)
return nil
}
if ts, err := parseUnixTimestamp(s); err == nil {
*t = TimeUnmarshaler(ts)
return nil
}
if ts, err := time.Parse("2006-01-02T15:04:05", s); err == nil {
*t = TimeUnmarshaler(ts)
return nil
}
return fmt.Errorf("unrecognized time format: %s", s)
}
逻辑分析:按优先级依次尝试 RFC 3339(含时区)、Unix 秒/毫秒整数、ISO 8601 简化格式;
parseRFC3339内部兼容2006-01-02T15:04:05Z和2006-01-02T15:04:05-07:00;parseUnixTimestamp自动检测并转换 10/13 位数字。
支持的格式覆盖范围
| 格式类型 | 示例 | 解析能力 |
|---|---|---|
| RFC 3339 | "2023-04-15T13:30:45Z" |
✅ 全时区 |
| Unix (秒) | "1681565445" |
✅ |
| ISO 8601 | "2023-04-15T13:30:45" |
✅ 无时区 |
解析流程示意
graph TD
A[输入字符串] --> B{是否含引号?}
B -->|是| C[去引号]
B -->|否| D[直接解析]
C --> E[尝试 RFC 3339]
E -->|成功| F[赋值返回]
E -->|失败| G[尝试 Unix]
G -->|成功| F
G -->|失败| H[尝试 ISO 8601]
H -->|成功| F
H -->|失败| I[报错]
4.2 基于json.Unmarshaler定制map[string]interface{}中嵌套时间字段的递归修复逻辑
核心挑战
json.Unmarshal 默认将 ISO 时间字符串解析为 string,而非 time.Time,尤其在 map[string]interface{} 这类动态结构中,类型信息完全丢失。
修复策略
- 遍历 map 的所有键值对,识别形如
"2024-03-15T08:30:00Z"的字符串 - 对匹配项尝试
time.Parse(time.RFC3339, s) - 递归进入 slice/map 子结构
func fixTimeRecursively(v interface{}) interface{} {
if m, ok := v.(map[string]interface{}); ok {
for k, val := range m {
if s, isStr := val.(string); isStr && isISO8601(s) {
if t, err := time.Parse(time.RFC3339, s); err == nil {
m[k] = t // 原地替换为 time.Time
}
} else {
m[k] = fixTimeRecursively(val) // 递归处理
}
}
}
if s, ok := v.([]interface{}); ok {
for i, item := range s {
s[i] = fixTimeRecursively(item)
}
}
return v
}
逻辑分析:该函数采用深度优先遍历,仅当字符串通过
isISO8601()(正则校验)且time.Parse成功时才替换;避免误判数字字符串或空值。参数v为任意嵌套层级的interface{},返回同构但时间字段已强化的结构。
| 场景 | 输入示例 | 修复后类型 |
|---|---|---|
| 顶层时间字段 | "created_at": "2024-03-15T08:30:00Z" |
time.Time |
| 嵌套 map 中 | "meta": {"updated": "2024-03-16T12:00:00Z"} |
map[string]time.Time |
graph TD
A[输入 interface{}] --> B{是否 map?}
B -->|是| C[遍历 key/val]
B -->|否| D{是否 slice?}
C --> E[字符串匹配 ISO8601?]
E -->|是| F[Parse → time.Time]
E -->|否| G[递归处理子值]
D -->|是| H[递归遍历元素]
D -->|否| I[原样返回]
4.3 在gin/echo等Web框架中全局注入TimeUnmarshaler的Middleware式集成方案
核心设计思想
将 time.UnmarshalText 的自定义解析逻辑封装为可复用中间件,在请求生命周期早期统一劫持 json.Unmarshal 行为,避免各结构体重复实现 UnmarshalJSON。
Gin 中间件实现示例
func TimeUnmarshalerMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 替换默认 JSON 解析器,注入自定义 time.Unmarshaller
c.Request.Header.Set("X-Time-Format", "2006-01-02T15:04:05Z07:00")
c.Next()
}
}
该中间件不直接修改
json.Decoder,而是通过gin.Context注入上下文感知的time.Parse配置,供后续binding.MustBind阶段消费;X-Time-Format作为运行时格式提示,支持多时区动态切换。
框架适配对比
| 框架 | 注入点 | 是否需重写 Binding |
|---|---|---|
| Gin | c.Request 上下文 |
否(利用 ShouldBindWith) |
| Echo | echo.HTTPError 前 |
是(需包装 Binder 接口) |
流程示意
graph TD
A[HTTP Request] --> B{Middleware Chain}
B --> C[TimeUnmarshalerMiddleware]
C --> D[Binding Layer]
D --> E[调用自定义 UnmarshalText]
4.4 单元测试覆盖率设计:覆盖时区偏移、毫秒级精度、空字符串、null值等12类边界用例
为保障时间处理模块的鲁棒性,需系统性覆盖12类典型边界场景,包括:
- 时区偏移(如
+14:00/-12:00) - 毫秒级精度(
2023-01-01T00:00:00.999Zvs...1000Z) - 空字符串、
null、undefined、空白符(\t\n) - 超长毫秒数(
new Date(8640000000000000))、闰秒临界点、ISO格式缺失部件等
时间解析异常捕获示例
@Test
void testParseWithInvalidZone() {
assertThrows(DateTimeParseException.class,
() -> LocalDateTime.parse("2023-01-01T00:00:00+15:00")); // 无效偏移:±14为极限
}
逻辑分析:JDK DateTimeFormatter 对时区偏移严格校验,+15:00 超出 IANA TZDB 允许范围(−12:00 至 +14:00),触发 DateTimeParseException;参数 parse() 未指定 ZoneId,默认按无时区上下文解析,凸显格式合法性优先级。
边界用例覆盖矩阵(部分)
| 用例类型 | 输入示例 | 期望行为 |
|---|---|---|
| null值 | parse(null) |
抛 NullPointerException |
| 毫秒溢出 | "2023-01-01T00:00:00.1000Z" |
拒绝解析(非法毫秒位数) |
graph TD
A[输入字符串] --> B{是否为空/null?}
B -->|是| C[立即抛NPE或IllegalArgumentException]
B -->|否| D[正则预检ISO结构]
D --> E[委托DateTimeFormatter.parse]
E --> F[时区偏移有效性校验]
第五章:从坑到规范:Go JSON时间处理的最佳实践演进路线
初期踩坑:time.Time 默认序列化引发的线上故障
某支付系统上线后,前端频繁报“Invalid date”错误。排查发现 Go 后端返回的 JSON 时间字段为 "2023-10-15T08:22:34.123456789Z",而部分 iOS WebView 的 Date 构造器无法解析纳秒级精度(9位小数)。更严重的是,MySQL DATETIME 字段仅支持微秒(6位),导致 INSERT 时被截断并触发非预期的时区偏移。
标准化尝试:全局注册自定义 JSON marshaler
团队曾试图通过 json.Marshaler 接口统一格式:
func (t MyTime) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprintf(`"%s"`, t.UTC().Format("2006-01-02T15:04:05Z"))), nil
}
但该方案在嵌套结构体中失效——当 MyTime 作为匿名字段或嵌入 sql.NullTime 时,Go 的反射机制跳过自定义方法,回归默认纳秒序列化。
关键转折:使用 time.RFC3339Nano 的隐式陷阱
文档宣称 RFC3339Nano 是标准格式,但实际测试发现:
| 时区类型 | 序列化结果示例 | 前端兼容性 |
|---|---|---|
time.UTC |
"2023-10-15T08:22:34.123456789Z" |
Safari 15+ ✅,Android 8.0 WebView ❌ |
time.Local |
"2023-10-15T16:22:34.123456789+08:00" |
Chrome ✅,旧版 Edge ❌ |
问题根源在于 RFC3339Nano 允许纳秒精度,而 W3C HTML5 规范明确要求 input[type=datetime-local] 仅接受最多毫秒(3位小数)。
终极解法:基于 json.RawMessage 的零拷贝时间封装
我们构建了轻量级 JSONTime 类型,避免反射开销:
type JSONTime struct {
time.Time
}
func (jt JSONTime) MarshalJSON() ([]byte, error) {
b := make([]byte, 0, 24)
b = append(b, '"')
b = jt.Time.UTC().AppendFormat(b, "2006-01-02T15:04:05.000Z")
b = append(b, '"')
return b, nil
}
func (jt *JSONTime) UnmarshalJSON(data []byte) error {
if len(data) < 2 || data[0] != '"' || data[len(data)-1] != '"' {
return errors.New("invalid JSON time format")
}
t, err := time.ParseInLocation("2006-01-02T15:04:05.000Z", string(data[1:len(data)-1]), time.UTC)
if err != nil {
// 回退解析带毫秒/无毫秒的多种格式
for _, layout := range []string{
"2006-01-02T15:04:05Z",
"2006-01-02T15:04:05.000000Z",
"2006-01-02T15:04:05.000000000Z",
} {
if t, err = time.ParseInLocation(layout, string(data[1:len(data)-1]), time.UTC); err == nil {
jt.Time = t
return nil
}
}
return err
}
jt.Time = t
return nil
}
生产验证:灰度发布与双向兼容策略
在订单服务中采用渐进式替换:
- v1.2.0:新增
created_at_v2字段使用JSONTime,保留旧created_at字段; - v1.3.0:Nginx 层注入
X-Time-Format: rfc3339-msHeader,由网关统一重写响应; - v1.4.0:全量切换,并通过 Prometheus 监控
json_time_parse_errors_total指标,连续7天归零后下线旧逻辑。
工程规范沉淀:go-critic + 自定义 linter 强制约束
在 CI 流程中集成静态检查规则:
- 禁止直接使用
time.Time作为导出结构体字段; - 要求所有
time.Time字段必须标注// json:"xxx,omitempty" format:"rfc3339-ms"注释; - 使用
golangci-lint插件扫描未实现UnmarshalJSON的时间包装类型。
flowchart TD
A[HTTP Request] --> B{是否含 X-Time-Format Header?}
B -->|是| C[调用 Format-aware Encoder]
B -->|否| D[默认 RFC3339Nano Encoder]
C --> E[截断纳秒至毫秒<br/>强制 UTC 时区<br/>补零至三位小数]
D --> F[原始 time.Time Marshal]
E --> G[JSON 响应]
F --> G 