217 lines
5.6 KiB
Go
217 lines
5.6 KiB
Go
package utils
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"go-taobao/config"
|
||
)
|
||
|
||
// TimeSync 时间同步器,计算本地时钟与服务器的时间偏移
|
||
type TimeSync struct {
|
||
// offset = 服务器时间 - 本地时间
|
||
Offset time.Duration
|
||
// 使用的时间源名称
|
||
Source string
|
||
}
|
||
|
||
// taobaoH5Resp 淘宝 H5 时间 API 响应
|
||
type taobaoH5Resp struct {
|
||
Data struct {
|
||
T string `json:"t"`
|
||
} `json:"data"`
|
||
}
|
||
|
||
// suningTimeResp 苏宁时间 API 响应
|
||
type suningTimeResp struct {
|
||
SysTime2 string `json:"sysTime2"` // 格式: 2026-02-09 14:30:00
|
||
}
|
||
|
||
// NewTimeSync 创建时间同步器,依次尝试多个时间源
|
||
func NewTimeSync(samples int) (*TimeSync, error) {
|
||
if samples < 1 {
|
||
samples = 5
|
||
}
|
||
|
||
// 依次尝试每个时间源
|
||
for _, api := range config.TimeAPIs {
|
||
log.Printf("[时间同步] 尝试时间源: %s (%s)", api.Name, api.URL)
|
||
|
||
offsets := make([]time.Duration, 0, samples)
|
||
for i := 0; i < samples; i++ {
|
||
offset, err := fetchOffset(api)
|
||
if err != nil {
|
||
log.Printf("[时间同步] %s 第 %d 次采样失败: %v", api.Name, i+1, err)
|
||
continue
|
||
}
|
||
offsets = append(offsets, offset)
|
||
log.Printf("[时间同步] %s 第 %d 次采样: 偏移 = %v", api.Name, i+1, offset)
|
||
|
||
if i < samples-1 {
|
||
time.Sleep(200 * time.Millisecond)
|
||
}
|
||
}
|
||
|
||
if len(offsets) == 0 {
|
||
log.Printf("[时间同步] %s 所有采样失败,尝试下一个时间源", api.Name)
|
||
continue
|
||
}
|
||
|
||
// 取中位数
|
||
sort.Slice(offsets, func(i, j int) bool {
|
||
return offsets[i] < offsets[j]
|
||
})
|
||
median := offsets[len(offsets)/2]
|
||
|
||
ts := &TimeSync{Offset: median, Source: api.Name}
|
||
log.Printf("[时间同步] ✅ 使用 %s, 偏移量: %v (%d 次有效采样)",
|
||
api.Name, median, len(offsets))
|
||
return ts, nil
|
||
}
|
||
|
||
// 所有时间源都失败,降级到本地时间
|
||
log.Println("[时间同步] ⚠️ 所有时间源均失败,降级使用本地时间 (偏移=0)")
|
||
return &TimeSync{Offset: 0, Source: "本地时间(降级)"}, nil
|
||
}
|
||
|
||
// fetchOffset 根据 API 类型获取单次时间偏移
|
||
func fetchOffset(api config.TimeAPIConfig) (time.Duration, error) {
|
||
client := &http.Client{Timeout: 5 * time.Second}
|
||
|
||
localBefore := time.Now()
|
||
|
||
req, err := http.NewRequest("GET", api.URL, nil)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("创建请求失败: %w", err)
|
||
}
|
||
// 模拟浏览器请求头
|
||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("请求失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
localAfter := time.Now()
|
||
rtt := localAfter.Sub(localBefore)
|
||
localEstimate := localBefore.Add(rtt / 2)
|
||
|
||
var serverTime time.Time
|
||
|
||
switch api.Type {
|
||
case config.TimeAPITypeTaobaoH5:
|
||
serverTime, err = parseTaobaoH5(resp)
|
||
case config.TimeAPITypeSuning:
|
||
serverTime, err = parseSuning(resp)
|
||
case config.TimeAPITypeHTTPHeader:
|
||
serverTime, err = parseHTTPDate(resp)
|
||
default:
|
||
return 0, fmt.Errorf("未知 API 类型")
|
||
}
|
||
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
offset := serverTime.Sub(localEstimate)
|
||
log.Printf("[时间同步] RTT: %v, 服务器时间: %s", rtt, serverTime.Format("15:04:05.000"))
|
||
return offset, nil
|
||
}
|
||
|
||
// parseTaobaoH5 解析淘宝 H5 API 响应
|
||
func parseTaobaoH5(resp *http.Response) (time.Time, error) {
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return time.Time{}, fmt.Errorf("读取响应失败: %w", err)
|
||
}
|
||
|
||
// 淘宝 H5 API 可能返回 JSONP,需要提取 JSON 部分
|
||
bodyStr := string(body)
|
||
jsonStr := bodyStr
|
||
if idx := strings.Index(bodyStr, "("); idx != -1 {
|
||
jsonStr = bodyStr[idx+1:]
|
||
if endIdx := strings.LastIndex(jsonStr, ")"); endIdx != -1 {
|
||
jsonStr = jsonStr[:endIdx]
|
||
}
|
||
}
|
||
|
||
var result taobaoH5Resp
|
||
if err := json.Unmarshal([]byte(jsonStr), &result); err != nil {
|
||
return time.Time{}, fmt.Errorf("解析 JSON 失败: %w (body: %s)", err, bodyStr[:min(len(bodyStr), 200)])
|
||
}
|
||
|
||
if result.Data.T == "" {
|
||
return time.Time{}, fmt.Errorf("时间戳为空 (body: %s)", bodyStr[:min(len(bodyStr), 200)])
|
||
}
|
||
|
||
ms, err := strconv.ParseInt(result.Data.T, 10, 64)
|
||
if err != nil {
|
||
return time.Time{}, fmt.Errorf("解析时间戳失败: %w", err)
|
||
}
|
||
|
||
return time.UnixMilli(ms), nil
|
||
}
|
||
|
||
// parseSuning 解析苏宁时间 API 响应
|
||
func parseSuning(resp *http.Response) (time.Time, error) {
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return time.Time{}, fmt.Errorf("读取响应失败: %w", err)
|
||
}
|
||
|
||
var result suningTimeResp
|
||
if err := json.Unmarshal(body, &result); err != nil {
|
||
return time.Time{}, fmt.Errorf("解析 JSON 失败: %w", err)
|
||
}
|
||
|
||
if result.SysTime2 == "" {
|
||
return time.Time{}, fmt.Errorf("时间字段为空")
|
||
}
|
||
|
||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||
t, err := time.ParseInLocation("2006-01-02 15:04:05", result.SysTime2, loc)
|
||
if err != nil {
|
||
return time.Time{}, fmt.Errorf("解析时间失败: %w", err)
|
||
}
|
||
|
||
return t, nil
|
||
}
|
||
|
||
// parseHTTPDate 从 HTTP 响应头 Date 字段解析服务器时间
|
||
func parseHTTPDate(resp *http.Response) (time.Time, error) {
|
||
dateStr := resp.Header.Get("Date")
|
||
if dateStr == "" {
|
||
return time.Time{}, fmt.Errorf("响应头无 Date 字段")
|
||
}
|
||
|
||
// HTTP Date 格式: Thu, 09 Feb 2026 06:30:00 GMT
|
||
t, err := time.Parse(time.RFC1123, dateStr)
|
||
if err != nil {
|
||
// 尝试备用格式
|
||
t, err = time.Parse(time.RFC1123Z, dateStr)
|
||
if err != nil {
|
||
return time.Time{}, fmt.Errorf("解析 Date 头失败: %w (值: %s)", err, dateStr)
|
||
}
|
||
}
|
||
|
||
return t, nil
|
||
}
|
||
|
||
// Now 返回校准后的当前时间
|
||
func (ts *TimeSync) Now() time.Time {
|
||
return time.Now().Add(ts.Offset)
|
||
}
|
||
|
||
// UntilTarget 计算距离目标时间还有多久
|
||
func (ts *TimeSync) UntilTarget(target time.Time) time.Duration {
|
||
return target.Sub(ts.Now())
|
||
}
|