fix: 修复跨时区用户日期范围查询不准确的问题
问题:当用户时区与服务器时区不同时,日期范围查询使用服务器时区解析, 导致用户看到的数据与预期不符。 修复方案: - 前端:所有 GET 请求自动携带用户时区参数 - 后端:新增时区辅助函数,所有日期解析和默认日期范围计算都使用用户时区 - 当用户时区为空或无效时,自动回退到服务器时区 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -26,31 +26,33 @@ func NewDashboardHandler(dashboardService *service.DashboardService) *DashboardH
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseTimeRange parses start_date, end_date query parameters
|
// parseTimeRange parses start_date, end_date query parameters
|
||||||
|
// Uses user's timezone if provided, otherwise falls back to server timezone
|
||||||
func parseTimeRange(c *gin.Context) (time.Time, time.Time) {
|
func parseTimeRange(c *gin.Context) (time.Time, time.Time) {
|
||||||
now := timezone.Now()
|
userTZ := c.Query("timezone") // Get user's timezone from request
|
||||||
|
now := timezone.NowInUserLocation(userTZ)
|
||||||
startDate := c.Query("start_date")
|
startDate := c.Query("start_date")
|
||||||
endDate := c.Query("end_date")
|
endDate := c.Query("end_date")
|
||||||
|
|
||||||
var startTime, endTime time.Time
|
var startTime, endTime time.Time
|
||||||
|
|
||||||
if startDate != "" {
|
if startDate != "" {
|
||||||
if t, err := timezone.ParseInLocation("2006-01-02", startDate); err == nil {
|
if t, err := timezone.ParseInUserLocation("2006-01-02", startDate, userTZ); err == nil {
|
||||||
startTime = t
|
startTime = t
|
||||||
} else {
|
} else {
|
||||||
startTime = timezone.StartOfDay(now.AddDate(0, 0, -7))
|
startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
startTime = timezone.StartOfDay(now.AddDate(0, 0, -7))
|
startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ)
|
||||||
}
|
}
|
||||||
|
|
||||||
if endDate != "" {
|
if endDate != "" {
|
||||||
if t, err := timezone.ParseInLocation("2006-01-02", endDate); err == nil {
|
if t, err := timezone.ParseInUserLocation("2006-01-02", endDate, userTZ); err == nil {
|
||||||
endTime = t.Add(24 * time.Hour) // Include the end date
|
endTime = t.Add(24 * time.Hour) // Include the end date
|
||||||
} else {
|
} else {
|
||||||
endTime = timezone.StartOfDay(now.AddDate(0, 0, 1))
|
endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
endTime = timezone.StartOfDay(now.AddDate(0, 0, 1))
|
endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ)
|
||||||
}
|
}
|
||||||
|
|
||||||
return startTime, endTime
|
return startTime, endTime
|
||||||
|
|||||||
@@ -102,8 +102,9 @@ func (h *UsageHandler) List(c *gin.Context) {
|
|||||||
|
|
||||||
// Parse date range
|
// Parse date range
|
||||||
var startTime, endTime *time.Time
|
var startTime, endTime *time.Time
|
||||||
|
userTZ := c.Query("timezone") // Get user's timezone from request
|
||||||
if startDateStr := c.Query("start_date"); startDateStr != "" {
|
if startDateStr := c.Query("start_date"); startDateStr != "" {
|
||||||
t, err := timezone.ParseInLocation("2006-01-02", startDateStr)
|
t, err := timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
|
response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
|
||||||
return
|
return
|
||||||
@@ -112,7 +113,7 @@ func (h *UsageHandler) List(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if endDateStr := c.Query("end_date"); endDateStr != "" {
|
if endDateStr := c.Query("end_date"); endDateStr != "" {
|
||||||
t, err := timezone.ParseInLocation("2006-01-02", endDateStr)
|
t, err := timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
|
response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
|
||||||
return
|
return
|
||||||
@@ -172,7 +173,8 @@ func (h *UsageHandler) Stats(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse date range
|
// Parse date range
|
||||||
now := timezone.Now()
|
userTZ := c.Query("timezone") // Get user's timezone from request
|
||||||
|
now := timezone.NowInUserLocation(userTZ)
|
||||||
var startTime, endTime time.Time
|
var startTime, endTime time.Time
|
||||||
|
|
||||||
startDateStr := c.Query("start_date")
|
startDateStr := c.Query("start_date")
|
||||||
@@ -180,12 +182,12 @@ func (h *UsageHandler) Stats(c *gin.Context) {
|
|||||||
|
|
||||||
if startDateStr != "" && endDateStr != "" {
|
if startDateStr != "" && endDateStr != "" {
|
||||||
var err error
|
var err error
|
||||||
startTime, err = timezone.ParseInLocation("2006-01-02", startDateStr)
|
startTime, err = timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
|
response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
endTime, err = timezone.ParseInLocation("2006-01-02", endDateStr)
|
endTime, err = timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
|
response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
|
||||||
return
|
return
|
||||||
@@ -195,13 +197,13 @@ func (h *UsageHandler) Stats(c *gin.Context) {
|
|||||||
period := c.DefaultQuery("period", "today")
|
period := c.DefaultQuery("period", "today")
|
||||||
switch period {
|
switch period {
|
||||||
case "today":
|
case "today":
|
||||||
startTime = timezone.StartOfDay(now)
|
startTime = timezone.StartOfDayInUserLocation(now, userTZ)
|
||||||
case "week":
|
case "week":
|
||||||
startTime = now.AddDate(0, 0, -7)
|
startTime = now.AddDate(0, 0, -7)
|
||||||
case "month":
|
case "month":
|
||||||
startTime = now.AddDate(0, -1, 0)
|
startTime = now.AddDate(0, -1, 0)
|
||||||
default:
|
default:
|
||||||
startTime = timezone.StartOfDay(now)
|
startTime = timezone.StartOfDayInUserLocation(now, userTZ)
|
||||||
}
|
}
|
||||||
endTime = now
|
endTime = now
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,8 +88,9 @@ func (h *UsageHandler) List(c *gin.Context) {
|
|||||||
|
|
||||||
// Parse date range
|
// Parse date range
|
||||||
var startTime, endTime *time.Time
|
var startTime, endTime *time.Time
|
||||||
|
userTZ := c.Query("timezone") // Get user's timezone from request
|
||||||
if startDateStr := c.Query("start_date"); startDateStr != "" {
|
if startDateStr := c.Query("start_date"); startDateStr != "" {
|
||||||
t, err := timezone.ParseInLocation("2006-01-02", startDateStr)
|
t, err := timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
|
response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
|
||||||
return
|
return
|
||||||
@@ -98,7 +99,7 @@ func (h *UsageHandler) List(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if endDateStr := c.Query("end_date"); endDateStr != "" {
|
if endDateStr := c.Query("end_date"); endDateStr != "" {
|
||||||
t, err := timezone.ParseInLocation("2006-01-02", endDateStr)
|
t, err := timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
|
response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
|
||||||
return
|
return
|
||||||
@@ -194,7 +195,8 @@ func (h *UsageHandler) Stats(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取时间范围参数
|
// 获取时间范围参数
|
||||||
now := timezone.Now()
|
userTZ := c.Query("timezone") // Get user's timezone from request
|
||||||
|
now := timezone.NowInUserLocation(userTZ)
|
||||||
var startTime, endTime time.Time
|
var startTime, endTime time.Time
|
||||||
|
|
||||||
// 优先使用 start_date 和 end_date 参数
|
// 优先使用 start_date 和 end_date 参数
|
||||||
@@ -204,12 +206,12 @@ func (h *UsageHandler) Stats(c *gin.Context) {
|
|||||||
if startDateStr != "" && endDateStr != "" {
|
if startDateStr != "" && endDateStr != "" {
|
||||||
// 使用自定义日期范围
|
// 使用自定义日期范围
|
||||||
var err error
|
var err error
|
||||||
startTime, err = timezone.ParseInLocation("2006-01-02", startDateStr)
|
startTime, err = timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
|
response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
endTime, err = timezone.ParseInLocation("2006-01-02", endDateStr)
|
endTime, err = timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
|
response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
|
||||||
return
|
return
|
||||||
@@ -221,13 +223,13 @@ func (h *UsageHandler) Stats(c *gin.Context) {
|
|||||||
period := c.DefaultQuery("period", "today")
|
period := c.DefaultQuery("period", "today")
|
||||||
switch period {
|
switch period {
|
||||||
case "today":
|
case "today":
|
||||||
startTime = timezone.StartOfDay(now)
|
startTime = timezone.StartOfDayInUserLocation(now, userTZ)
|
||||||
case "week":
|
case "week":
|
||||||
startTime = now.AddDate(0, 0, -7)
|
startTime = now.AddDate(0, 0, -7)
|
||||||
case "month":
|
case "month":
|
||||||
startTime = now.AddDate(0, -1, 0)
|
startTime = now.AddDate(0, -1, 0)
|
||||||
default:
|
default:
|
||||||
startTime = timezone.StartOfDay(now)
|
startTime = timezone.StartOfDayInUserLocation(now, userTZ)
|
||||||
}
|
}
|
||||||
endTime = now
|
endTime = now
|
||||||
}
|
}
|
||||||
@@ -248,31 +250,33 @@ func (h *UsageHandler) Stats(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseUserTimeRange parses start_date, end_date query parameters for user dashboard
|
// parseUserTimeRange parses start_date, end_date query parameters for user dashboard
|
||||||
|
// Uses user's timezone if provided, otherwise falls back to server timezone
|
||||||
func parseUserTimeRange(c *gin.Context) (time.Time, time.Time) {
|
func parseUserTimeRange(c *gin.Context) (time.Time, time.Time) {
|
||||||
now := timezone.Now()
|
userTZ := c.Query("timezone") // Get user's timezone from request
|
||||||
|
now := timezone.NowInUserLocation(userTZ)
|
||||||
startDate := c.Query("start_date")
|
startDate := c.Query("start_date")
|
||||||
endDate := c.Query("end_date")
|
endDate := c.Query("end_date")
|
||||||
|
|
||||||
var startTime, endTime time.Time
|
var startTime, endTime time.Time
|
||||||
|
|
||||||
if startDate != "" {
|
if startDate != "" {
|
||||||
if t, err := timezone.ParseInLocation("2006-01-02", startDate); err == nil {
|
if t, err := timezone.ParseInUserLocation("2006-01-02", startDate, userTZ); err == nil {
|
||||||
startTime = t
|
startTime = t
|
||||||
} else {
|
} else {
|
||||||
startTime = timezone.StartOfDay(now.AddDate(0, 0, -7))
|
startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
startTime = timezone.StartOfDay(now.AddDate(0, 0, -7))
|
startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ)
|
||||||
}
|
}
|
||||||
|
|
||||||
if endDate != "" {
|
if endDate != "" {
|
||||||
if t, err := timezone.ParseInLocation("2006-01-02", endDate); err == nil {
|
if t, err := timezone.ParseInUserLocation("2006-01-02", endDate, userTZ); err == nil {
|
||||||
endTime = t.Add(24 * time.Hour) // Include the end date
|
endTime = t.Add(24 * time.Hour) // Include the end date
|
||||||
} else {
|
} else {
|
||||||
endTime = timezone.StartOfDay(now.AddDate(0, 0, 1))
|
endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
endTime = timezone.StartOfDay(now.AddDate(0, 0, 1))
|
endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ)
|
||||||
}
|
}
|
||||||
|
|
||||||
return startTime, endTime
|
return startTime, endTime
|
||||||
|
|||||||
@@ -122,3 +122,40 @@ func StartOfMonth(t time.Time) time.Time {
|
|||||||
func ParseInLocation(layout, value string) (time.Time, error) {
|
func ParseInLocation(layout, value string) (time.Time, error) {
|
||||||
return time.ParseInLocation(layout, value, Location())
|
return time.ParseInLocation(layout, value, Location())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseInUserLocation parses a time string in the user's timezone.
|
||||||
|
// If userTZ is empty or invalid, falls back to the configured server timezone.
|
||||||
|
func ParseInUserLocation(layout, value, userTZ string) (time.Time, error) {
|
||||||
|
loc := Location() // default to server timezone
|
||||||
|
if userTZ != "" {
|
||||||
|
if userLoc, err := time.LoadLocation(userTZ); err == nil {
|
||||||
|
loc = userLoc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.ParseInLocation(layout, value, loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NowInUserLocation returns the current time in the user's timezone.
|
||||||
|
// If userTZ is empty or invalid, falls back to the configured server timezone.
|
||||||
|
func NowInUserLocation(userTZ string) time.Time {
|
||||||
|
if userTZ == "" {
|
||||||
|
return Now()
|
||||||
|
}
|
||||||
|
if userLoc, err := time.LoadLocation(userTZ); err == nil {
|
||||||
|
return time.Now().In(userLoc)
|
||||||
|
}
|
||||||
|
return Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartOfDayInUserLocation returns the start of the given day in the user's timezone.
|
||||||
|
// If userTZ is empty or invalid, falls back to the configured server timezone.
|
||||||
|
func StartOfDayInUserLocation(t time.Time, userTZ string) time.Time {
|
||||||
|
loc := Location()
|
||||||
|
if userTZ != "" {
|
||||||
|
if userLoc, err := time.LoadLocation(userTZ); err == nil {
|
||||||
|
loc = userLoc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t = t.In(loc)
|
||||||
|
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ export const apiClient: AxiosInstance = axios.create({
|
|||||||
|
|
||||||
// ==================== Request Interceptor ====================
|
// ==================== Request Interceptor ====================
|
||||||
|
|
||||||
|
// Get user's timezone
|
||||||
|
const getUserTimezone = (): string => {
|
||||||
|
try {
|
||||||
|
return Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
} catch {
|
||||||
|
return 'UTC'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
apiClient.interceptors.request.use(
|
apiClient.interceptors.request.use(
|
||||||
(config: InternalAxiosRequestConfig) => {
|
(config: InternalAxiosRequestConfig) => {
|
||||||
// Attach token from localStorage
|
// Attach token from localStorage
|
||||||
@@ -34,6 +43,14 @@ apiClient.interceptors.request.use(
|
|||||||
config.headers['Accept-Language'] = getLocale()
|
config.headers['Accept-Language'] = getLocale()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attach timezone for all GET requests (backend may use it for default date ranges)
|
||||||
|
if (config.method === 'get') {
|
||||||
|
if (!config.params) {
|
||||||
|
config.params = {}
|
||||||
|
}
|
||||||
|
config.params.timezone = getUserTimezone()
|
||||||
|
}
|
||||||
|
|
||||||
return config
|
return config
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user