Skip to content

Commit fce0764

Browse files
committed
fix: response timezone
Signed-off-by: E99p1ant <i@github.red>
1 parent 0347c06 commit fce0764

6 files changed

Lines changed: 155 additions & 27 deletions

File tree

internal/response/mine.go

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
11
package response
22

33
import (
4-
"time"
5-
64
"github.com/wuhan005/NekoBox/internal/db"
75
)
86

97
type MineQuestionsItem struct {
10-
ID uint `json:"id"`
11-
CreatedAt time.Time `json:"createdAt"`
12-
Content string `json:"content"`
13-
IsAnswered bool `json:"isAnswered"`
14-
IsPrivate bool `json:"isPrivate"`
8+
ID uint `json:"id"`
9+
CreatedAt Time `json:"createdAt"`
10+
Content string `json:"content"`
11+
IsAnswered bool `json:"isAnswered"`
12+
IsPrivate bool `json:"isPrivate"`
1513
}
1614

1715
type MineQuestions struct {

internal/response/time.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package response
2+
3+
import (
4+
"time"
5+
)
6+
7+
// shanghaiLoc holds the Asia/Shanghai location, falling back to a fixed
8+
// +08:00 zone if the system tzdata cannot be loaded (e.g. in a minimal
9+
// container without tzdata).
10+
var shanghaiLoc = func() *time.Location {
11+
if loc, err := time.LoadLocation("Asia/Shanghai"); err == nil {
12+
return loc
13+
}
14+
return time.FixedZone("CST", 8*60*60)
15+
}()
16+
17+
// Time wraps time.Time so JSON-marshalled values always use the
18+
// Asia/Shanghai (+08:00) offset, regardless of the runtime TZ or how the
19+
// underlying database column is typed.
20+
//
21+
// Why this exists:
22+
//
23+
// Production stores created_at / updated_at as `timestamp without time
24+
// zone`, with the wall-clock written in Asia/Shanghai. The pgx driver,
25+
// having no tz info for those columns, hands us a time.Time whose
26+
// Location is the package-level time.UTC sentinel. Marshalling that
27+
// straight to JSON yields a "Z" suffix; dayjs in the browser then
28+
// re-shifts by +8 hours and renders 10:09 instead of 02:09 — the bug
29+
// this type fixes.
30+
//
31+
// Locally (and in any future migration) the column is
32+
// `timestamp with time zone`. In that case pgx returns a time.Time
33+
// whose Location is a non-sentinel zone (Asia/Shanghai when the
34+
// container runs under TZ=Asia/Shanghai, or a freshly-built UTC zone
35+
// when the container runs under TZ=UTC). Either way it is *not* the
36+
// time.UTC sentinel.
37+
//
38+
// We exploit that subtle but reliable distinction:
39+
// - Location == time.UTC sentinel → treat as "naive Shanghai
40+
// wall-clock" and re-tag it with Asia/Shanghai (no offset shift).
41+
// - Anything else → it already encodes a real
42+
// instant, so just convert it into Asia/Shanghai before formatting.
43+
//
44+
// Both branches produce "+08:00" output, so the API contract is stable
45+
// no matter which database schema or container TZ is used.
46+
type Time time.Time
47+
48+
func (t Time) MarshalJSON() ([]byte, error) {
49+
tt := time.Time(t)
50+
if tt.IsZero() {
51+
return []byte(`"0001-01-01T00:00:00+08:00"`), nil
52+
}
53+
54+
var out time.Time
55+
if tt.Location() == time.UTC {
56+
out = time.Date(
57+
tt.Year(), tt.Month(), tt.Day(),
58+
tt.Hour(), tt.Minute(), tt.Second(), tt.Nanosecond(),
59+
shanghaiLoc,
60+
)
61+
} else {
62+
out = tt.In(shanghaiLoc)
63+
}
64+
65+
return []byte(`"` + out.Format(time.RFC3339Nano) + `"`), nil
66+
}

internal/response/time_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package response
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
"time"
7+
)
8+
9+
func TestTime_MarshalJSON(t *testing.T) {
10+
sh, err := time.LoadLocation("Asia/Shanghai")
11+
if err != nil {
12+
t.Fatalf("load Asia/Shanghai: %v", err)
13+
}
14+
ny, err := time.LoadLocation("America/New_York")
15+
if err != nil {
16+
t.Fatalf("load America/New_York: %v", err)
17+
}
18+
// fixedUTC emulates pgx's behaviour for `timestamp with time zone`
19+
// columns under TZ=UTC: the location prints as "UTC" but is *not*
20+
// the package-level time.UTC sentinel.
21+
fixedUTC := time.FixedZone("UTC", 0)
22+
23+
tests := []struct {
24+
name string
25+
in time.Time
26+
want string
27+
}{
28+
{
29+
name: "naive UTC from `timestamp` column (wall-clock is actually Asia/Shanghai)",
30+
in: time.Date(2026, 5, 2, 2, 9, 23, 243_000_000, time.UTC),
31+
want: `"2026-05-02T02:09:23.243+08:00"`,
32+
},
33+
{
34+
name: "tz-aware UTC instant from `timestamptz` column under TZ=UTC container",
35+
in: time.Date(2026, 5, 1, 18, 9, 23, 243_000_000, fixedUTC),
36+
want: `"2026-05-02T02:09:23.243+08:00"`,
37+
},
38+
{
39+
name: "tz-aware Asia/Shanghai value from `timestamptz` column under TZ=Asia/Shanghai container",
40+
in: time.Date(2026, 5, 2, 2, 9, 23, 243_000_000, sh),
41+
want: `"2026-05-02T02:09:23.243+08:00"`,
42+
},
43+
{
44+
name: "any other tz is converted to Asia/Shanghai (same instant)",
45+
in: time.Date(2026, 5, 1, 14, 9, 23, 0, ny),
46+
want: `"2026-05-02T02:09:23+08:00"`,
47+
},
48+
{
49+
name: "zero value",
50+
in: time.Time{},
51+
want: `"0001-01-01T00:00:00+08:00"`,
52+
},
53+
}
54+
55+
for _, tc := range tests {
56+
t.Run(tc.name, func(t *testing.T) {
57+
got, err := json.Marshal(Time(tc.in))
58+
if err != nil {
59+
t.Fatalf("marshal: %v", err)
60+
}
61+
if string(got) != tc.want {
62+
t.Errorf("got %s, want %s", got, tc.want)
63+
}
64+
})
65+
}
66+
}

internal/response/user.go

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
package response
22

3-
import "time"
4-
53
type UserProfile struct {
64
UID string `json:"uid"`
75
Name string `json:"name"`
@@ -13,10 +11,10 @@ type UserProfile struct {
1311
}
1412

1513
type PageQuestionsItem struct {
16-
ID uint `json:"id"`
17-
CreatedAt time.Time `json:"createdAt"`
18-
Content string `json:"content"`
19-
Answer string `json:"answer"`
14+
ID uint `json:"id"`
15+
CreatedAt Time `json:"createdAt"`
16+
Content string `json:"content"`
17+
Answer string `json:"answer"`
2018
}
2119

2220
type PageQuestions struct {
@@ -26,14 +24,14 @@ type PageQuestions struct {
2624
}
2725

2826
type PageQuestion struct {
29-
ID uint `json:"id"`
30-
IsOwner bool `json:"isOwner"`
31-
CreatedAt time.Time `json:"createdAt"`
32-
AnsweredAt time.Time `json:"answeredAt"`
33-
Content string `json:"content"`
34-
Answer string `json:"answer"`
35-
QuestionImageURLs []string `json:"questionImageURLs"`
36-
AnswerImageURLs []string `json:"answerImageURLs"`
37-
HasReplyEmail bool `json:"hasReplyEmail"`
38-
IsPrivate bool `json:"isPrivate"`
27+
ID uint `json:"id"`
28+
IsOwner bool `json:"isOwner"`
29+
CreatedAt Time `json:"createdAt"`
30+
AnsweredAt Time `json:"answeredAt"`
31+
Content string `json:"content"`
32+
Answer string `json:"answer"`
33+
QuestionImageURLs []string `json:"questionImageURLs"`
34+
AnswerImageURLs []string `json:"answerImageURLs"`
35+
HasReplyEmail bool `json:"hasReplyEmail"`
36+
IsPrivate bool `json:"isPrivate"`
3937
}

internal/route/mine.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ func (*MineHandler) ListQuestions(ctx context.Context) error {
6060
respQuestions := lo.Map(questions, func(question *db.Question, _ int) *response.MineQuestionsItem {
6161
return &response.MineQuestionsItem{
6262
ID: question.ID,
63-
CreatedAt: question.CreatedAt,
63+
CreatedAt: response.Time(question.CreatedAt),
6464
Content: question.Content,
6565
IsAnswered: question.Answer != "",
6666
IsPrivate: question.IsPrivate,

internal/route/user.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ func (*UserHandler) ListQuestions(ctx context.Context, pageUser *db.User) error
103103
respQuestions := lo.Map(questions, func(question *db.Question, _ int) *response.PageQuestionsItem {
104104
return &response.PageQuestionsItem{
105105
ID: question.ID,
106-
CreatedAt: question.CreatedAt,
106+
CreatedAt: response.Time(question.CreatedAt),
107107
Content: question.Content,
108108
Answer: question.Answer,
109109
}
@@ -371,8 +371,8 @@ func (*UserHandler) GetQuestion(ctx context.Context, pageUser *db.User) error {
371371
return ctx.Success(&response.PageQuestion{
372372
ID: question.ID,
373373
IsOwner: ctx.IsSignedIn && ctx.User.ID == question.UserID,
374-
CreatedAt: question.CreatedAt,
375-
AnsweredAt: question.UpdatedAt,
374+
CreatedAt: response.Time(question.CreatedAt),
375+
AnsweredAt: response.Time(question.UpdatedAt),
376376
Content: question.Content,
377377
Answer: question.Answer,
378378
QuestionImageURLs: questionImageURLs,

0 commit comments

Comments
 (0)