Переглянути джерело

fix(infra): 固定进程与 DSN 时区为 +08:00(Asia/Shanghai)

## 背景:这是**既有缺陷**,不是增强
修复前:
- `internal/infra/config/config.go` 示例 DSN = `...&parseTime=true&loc=Local`;
- `cmd/*`、`internal/*` **零处**设置 `time.Local` / `TZ`。
→ `datetime(3)` 是无时区列,写入/读出都跟随**机器**时区:开发机(Asia/Shanghai)自测永远绿,
一上 UTC 容器(CI/生产镜像默认 UTC)时间字段整体**错 8 小时**,且两边都"看起来合理",极难排查。
财务账期按**本地日历日**结算,UTC 下 `2026-01-01 07:59:59+08:00` 会被算成 2025-12-31 → 跨日/跨月统计错位一天。
前端契约(与 identity/org 一致)也已定死时间字段为 **RFC3339 带 `+08:00`**。

## 三件套(缺一不可)
1. **进程时区**:新增 `internal/infra/tz`(内嵌 `time/tzdata`,容器缺 zoneinfo 也能加载;
   极端情况下回退**固定 +08:00 区**,绝不回退 UTC);`tz.Apply()` 在 `cmd/api`、`cmd/migrate`
   的 **main 最前面**调用(早于任何 `time.Now()` / DB 连接),日志打印 `timezone=Asia/Shanghai`。
2. **DSN 时区**:`tz.NormalizeDSN()` 把 `loc` **强制覆盖**为 `Asia%2FShanghai`(`loc=Local` 恰恰是漂移根因,
   不能只补不改)、`parseTime` 强制 `true`;收口在 `infra/db.NewDB`(唯一连接入口)与
   `migrate.NewSource`(迁移库同口径),**业务代码不得再各自拼 `loc=`**。
   IANA 名做 URL 编码(`/` → `%2F`),否则驱动按 URL 解析会把 loc 切断。
3. **验收**:`internal/infra/tz/tz_test.go` 6 个用例
   - `TestApplyFixesProcessZone`:`TZ=UTC` / `America/New_York` / `Europe/Berlin` 三种机器时区下
     Apply 后 `time.Local` 仍为 `Asia/Shanghai`、偏移恒 `+08:00`(**模拟 CI 容器/异地部署**);
   - `TestJSONOffsetAlwaysPlus8`:含 `time.Time` 的 JSON 匹配
     `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?\+08:00$`(覆盖 `time.Now()` 与"DB 读出后 In(Location())"两条路径)
     + 跨年边界 `2026-12-31T23:59:59+08:00`(UTC 下会退到 12-31 15:59:59);
   - `TestLocationNeverFallsBackToUTC`:Location 失败也只回退固定 +08:00;
   - `TestNormalizeDSN`:`loc=Local` / `loc=UTC` / 无查询串 / 已正确(幂等)/ 空 DSN 五种输入;
   - `TestApplyIdempotent`。

## 文档/模板同步
- `config.go` 的 DBCfg 注释与 `cmd/migrate`、`internal/migrate` 的示例 DSN 全部改为
  `parseTime=true&loc=Asia%2FShanghai`,并注明「不要写 loc=Local,写了也会被归一」。

## 与 DDL 默认值的关系(回应裁定 ①)
`default:CURRENT_TIMESTAMP` 取的是**数据库服务器**时区的 NOW(),而 Go 侧 `time.Now()` 取**进程**时区;
本提交把进程与 DSN 两侧都钉在 Asia/Shanghai,二者才一致(单独做一半会更难查)。
同时遵守裁定:**凡进入双跑比对的业务时间列,必须由 Service 经 Clock 显式赋值**,DB 默认只保证"非空且合理"。

## 与既有技术债的边界(未顺手改)
`internal/infra/db/db.go`、`internal/infra/config/config.go`、`internal/migrate/source.go`
在 HEAD 上**已是 `gofmt -l` 脏**(结构体对齐/注释缩进,属 T-CO-1 eol/gofmt 专项)。
按架构纪律**不对这些文件整体 `gofmt -w`**,本次只改与任务相关的最小片段,避免制造几百行空白噪声 diff。

`go build ./... && go vet ./... && go test ./... -short` 全绿;`TZ=UTC` 下复跑 academic + tz 同样全绿。
Q 19 годин тому
батько
коміт
2bdf598d1a

+ 8 - 1
cmd/api/main.go

@@ -47,6 +47,7 @@ import (
 	"yuansiwei/unified-backend/internal/infra/config"
 	"yuansiwei/unified-backend/internal/infra/db"
 	"yuansiwei/unified-backend/internal/infra/redis"
+	"yuansiwei/unified-backend/internal/infra/tz"
 	orghandler "yuansiwei/unified-backend/internal/org/handler"
 	orgmodel "yuansiwei/unified-backend/internal/org/model"
 	"yuansiwei/unified-backend/internal/org/rbac"
@@ -69,6 +70,11 @@ var _ service.MenuProvider = (*rbac.Resolver)(nil)
 //   - 配置缺失/必填字段未填:fail-fast 退出(config.Load 返回错误,输出到 stderr 后 exit 1);
 //   - SIGTERM/SIGINT:在 GracePeriodSec 内排空存量请求后退出(ADR-015 滚动重启语义)。
 func main() {
+	// ① 进程时区**最先固定**(任何 time.Now() / DB 连接之前):财务账期按本地日历日结算,
+	// UTC 容器会让跨日/跨月统计整体错位;前端契约也已定死时间字段为 RFC3339 带 +08:00。
+	// 与 DSN 的 loc=Asia%2FShanghai 由 infra/db 统一收口,二者必须同区(详见 internal/infra/tz)。
+	loc := tz.Apply()
+
 	env := flag.String("env", "dev", "运行环境(dev/prod),决定读取 config/{env}.json")
 	flag.Parse()
 
@@ -92,7 +98,8 @@ func main() {
 	logger.Info("统一 Go 后端启动",
 		zap.String("env", *env),
 		zap.String("instance", cfg.Server.InstanceID),
-		zap.String("addr", cfg.Server.Addr))
+		zap.String("addr", cfg.Server.Addr),
+		zap.String("timezone", loc.String()))
 
 	// ---- 基础设施装配(fail-fast:DB/Redis 连不上不允许启动)----
 	gdb, err := db.NewDB(cfg.DB)

+ 8 - 3
cmd/migrate/main.go

@@ -20,9 +20,9 @@
 //
 // 启动方式(dev):
 //
-//	export UNIFIED_MIGRATE_TEACHING_DSN='dev:***@tcp(127.0.0.1:3306)/yuansiwei_i?charset=utf8mb4&parseTime=True&loc=Local'
-//	export UNIFIED_MIGRATE_FINANCE_DSN='dev:***@tcp(127.0.0.1:3306)/finance_bk?charset=utf8mb4&parseTime=True&loc=Local'
-//	export UNIFIED_MIGRATE_TARGET_DSN='dev:***@tcp(127.0.0.1:3306)/yuansiwei_2026?charset=utf8mb4&parseTime=True&loc=Local'
+//	export UNIFIED_MIGRATE_TEACHING_DSN='dev:***@tcp(127.0.0.1:3306)/yuansiwei_i?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai'
+//	export UNIFIED_MIGRATE_FINANCE_DSN='dev:***@tcp(127.0.0.1:3306)/finance_bk?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai'
+//	export UNIFIED_MIGRATE_TARGET_DSN='dev:***@tcp(127.0.0.1:3306)/yuansiwei_2026?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai'
 //	go run ./cmd/migrate dry-run
 //	go run ./cmd/migrate run
 //	go run ./cmd/migrate verify
@@ -35,6 +35,7 @@ import (
 	"os"
 	"time"
 
+	"yuansiwei/unified-backend/internal/infra/tz"
 	"yuansiwei/unified-backend/internal/migrate"
 )
 
@@ -45,6 +46,10 @@ const (
 )
 
 func main() {
+	// 进程时区最先固定:迁移要把历史 datetime 按中国本地时区解释并回放,
+	// 与 API 进程(cmd/api)及 DSN loc 保持同一口径(详见 internal/infra/tz)。
+	tz.Apply()
+
 	if len(os.Args) < 2 {
 		usage()
 		os.Exit(2)

+ 3 - 1
internal/infra/config/config.go

@@ -70,7 +70,9 @@ type ServerCfg struct {
 //
 // 字段:
 //   - DSN string:连接串(**敏感**,走环境变量 CONFIG_DB_DSN;模板中置空)。
-//     形如 "user:pass@tcp(polarhost:3306)/unified?charset=utf8mb4&parseTime=true&loc=Local"。
+//     形如 "user:pass@tcp(polarhost:3306)/unified?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai"。
+//     **不要写 `loc=Local`**(它会跟随机器时区漂移 → 开发机自测绿、UTC 容器整体错 8 小时);
+//     即便写了也会被 infra/db 经 internal/infra/tz.NormalizeDSN 强制改写为 loc=Asia%2FShanghai。
 //   - MaxOpenConns int:最大连接数(双实例共享 PolarDB,建议单实例 ≤100)。
 //   - MaxIdleConns int:最大空闲连接(建议 10)。
 //   - ConnMaxLifetimeSec int:连接最长存活秒数(建议 1800,防连接池漂移)。

+ 6 - 1
internal/infra/db/db.go

@@ -34,6 +34,7 @@ import (
 	"gorm.io/gorm/logger"
 
 	"yuansiwei/unified-backend/internal/infra/config"
+	"yuansiwei/unified-backend/internal/infra/tz"
 )
 
 // NewDB 建立 MySQL 连接池并验证连通性(fail-fast:连不上库不允许启动,
@@ -61,7 +62,11 @@ func NewDB(cfg config.DBCfg) (*gorm.DB, error) {
 	if cfg.DSN == "" {
 		return nil, fmt.Errorf("db: DSN 为空(须经 config/dev.json 或环境变量 CONFIG_DB__DSN 提供)")
 	}
-	gdb, err := gorm.Open(mysql.Open(cfg.DSN), &gorm.Config{
+	// 时区/解析收口(**唯一允许写 loc 的地方**):无论配置或环境变量给的是什么,
+	// 一律归一为 parseTime=true + loc=Asia%2FShanghai,与进程时区(tz.Apply)保持一致。
+	// 反面:`loc=Local` 会随机器时区漂移,开发机自测绿、UTC 容器整体错 8 小时。
+	dsn := tz.NormalizeDSN(cfg.DSN)
+	gdb, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
 		// 与 DDL"不建物理外键"对齐:迁移时不自动生成 FK 约束
 		DisableForeignKeyConstraintWhenMigrating: true,
 		// 生产/双实例默认静默 SQL 日志(避免高频只读接口刷日志);联调期可换 logger.Warn

+ 118 - 0
internal/infra/tz/tz.go

@@ -0,0 +1,118 @@
+// Package tz 进程时区与 DSN 时区收口(**后端唯一时区权威**)。
+//
+// 为什么要固定 `+08:00`(不是"跟随机器时区"):
+//
+//	财务账期按**本地日历日**结算(充值/消费/退费/分成的"当日"、"当月"口径来自业务方的中国工作日),
+//	若进程跑在 UTC 容器里,`2026-01-01 07:59:59 +08:00` 会被算成 UTC 的 2025-12-31,
+//	跨日/跨月统计整体错位一天(对账、账单、报表全部连锁出错)。
+//	前端契约(与 identity/org 现状一致)也已定死时间字段为 **RFC3339 带 `+08:00`**,
+//	一旦偏移随机器漂移,前端解析会整体错 8 小时。
+//
+// 三件套**必须同时成立**(缺一即不可靠,见下方各自的函数):
+//
+//	① 进程时区  time.Local = Asia/Shanghai      → Apply()
+//	② DSN 时区   loc=Asia%2FShanghai           → NormalizeDSN()
+//	③ 验收       在 TZ=UTC 下仍输出 +08:00      → tz_test.go 的负例断言
+//
+// 反面教材(本项目修复前的现状):DSN 写 `loc=Local` 且进程未设时区 ——
+// `datetime(3)` 是无时区列,"写入按机器时区、读出按机器时区",开发机(Asia/Shanghai)自测永远绿,
+// 一上 UTC 容器就整体错 8 小时,且两边时间都"看起来合理",极难排查。
+package tz
+
+import (
+	"strings"
+	"time"
+
+	// 内嵌 IANA 时区库:容器/最小镜像(甚至 Windows 开发机)没有 /usr/share/zoneinfo 时,
+	// 仍能 LoadLocation("Asia/Shanghai"),避免"时区加载失败 → 静默回退 UTC"的隐性错位。
+	_ "time/tzdata"
+)
+
+// LocationName 进程与 DSN 统一使用的 IANA 时区名(中国标准时间,UTC+8,无夏令时)。
+const LocationName = "Asia/Shanghai"
+
+// dsnLocationParam DSN 中要写入的 `loc` 值(IANA 名做 URL 编码:`/` → `%2F`)。
+//
+// 为什么必须编码:go-sql-driver/mysql 把 DSN 当 URL 解析,未编码的 `Asia/Shanghai` 会被
+// 当作路径分隔符切断,最终 loc 变成非法值或空 → 回落到 UTC。
+const dsnLocationParam = "Asia%2FShanghai"
+
+// fixedZoneOffset 兜底时区偏移(秒)。中国无夏令时,固定 +08:00 与 Asia/Shanghai 等价。
+const fixedZoneOffset = 8 * 60 * 60
+
+// location 缓存的时区对象(LoadLocation 只做一次;并发安全:由 sync.Once 语义保证只写一次)。
+var location = loadLocation()
+
+// loadLocation 加载 Asia/Shanghai(失败时回退固定 +08:00 区,**绝不**回退 UTC)。
+//
+// 出参:*time.Location——名字恒为 "Asia/Shanghai"(便于断言与日志排查)。
+//
+// 说明:内嵌 tzdata 后几乎不会失败;兜底逻辑只为"二进制被裁剪 tzdata"的极端场景保命,
+// 保证偏移仍是 +08:00 而不是漂成 UTC。
+func loadLocation() *time.Location {
+	if loc, err := time.LoadLocation(LocationName); err == nil {
+		return loc
+	}
+	return time.FixedZone(LocationName, fixedZoneOffset)
+}
+
+// Location 返回统一的 Asia/Shanghai 时区对象(只读,可安全共享)。
+//
+// 典型用法:`row.CreateTime.In(tz.Location())`、`time.Now().In(tz.Location())`。
+func Location() *time.Location { return location }
+
+// Apply 把进程时区固定为 Asia/Shanghai(**必须在任何 time.Now() / DB 连接之前调用**)。
+//
+// 出参:*time.Location——已生效的时区(供调用方日志打印或断言)。
+//
+// 副作用:改写全局 `time.Local`。这是 Go 标准库唯一的进程级时区开关:
+// `time.Now()` 返回 Local 区时间,`time.Time` 默认 JSON 序列化按自身 Location 输出偏移,
+// 故改它即可让"未显式指定时区"的所有时间点统一带 `+08:00`。
+//
+// 幂等:重复调用无副作用(同值覆盖)。
+func Apply() *time.Location {
+	time.Local = location
+	return location
+}
+
+// NormalizeDSN 强制把 MySQL DSN 的时区与解析行为对齐到本包口径。
+//
+// 入参:dsn string——原始 DSN(形如 `user:pass@tcp(host:3306)/db?charset=utf8mb4&parseTime=True&loc=Local`)。
+//
+// 出参:string——归一后的 DSN:
+//   - `loc` 参数**覆盖**为 `Asia%2FShanghai`(无论原来是 `Local`/`UTC`/缺失):
+//     `loc=Local` 恰恰是"随机器时区漂移"的根因,不能只补不改;
+//   - `parseTime` 强制为 `true`:否则驱动把 DATETIME 扫成 `[]byte`/`string`,`time.Time` 字段扫描报错;
+//   - 无查询串时自动补 `?`,其余参数原样保留(顺序不重排,便于 diff/日志比对)。
+//
+// 空 DSN 原样返回(由调用方 fail-fast 校验,不在本函数报错)。
+//
+// 红线:本函数是**唯一**允许写 DSN 时区的地方;业务代码不得各自拼 `loc=`(否则又出现多口径)。
+func NormalizeDSN(dsn string) string {
+	dsn = strings.TrimSpace(dsn)
+	if dsn == "" {
+		return dsn
+	}
+	// 拆分 "前导部分?查询串"(DSN 内可能含 `?` 以外的特殊字符,故只按第一个 `?` 切)
+	head, query, hasQuery := strings.Cut(dsn, "?")
+	params := make([]string, 0, 8)
+	if hasQuery && query != "" {
+		params = strings.Split(query, "&")
+	}
+
+	// 逐参数覆盖(保留未知参数,保持原有顺序)
+	setParam := func(key, value string) {
+		for i, kv := range params {
+			k, _, _ := strings.Cut(kv, "=")
+			if strings.EqualFold(strings.TrimSpace(k), key) {
+				params[i] = key + "=" + value
+				return
+			}
+		}
+		params = append(params, key+"="+value)
+	}
+	setParam("parseTime", "true")
+	setParam("loc", dsnLocationParam)
+
+	return head + "?" + strings.Join(params, "&")
+}

+ 165 - 0
internal/infra/tz/tz_test.go

@@ -0,0 +1,165 @@
+// 时区固定验收(team-lead 口径):进程 Asia/Shanghai + DSN loc 一致 + JSON 恒 `+08:00`。
+//
+// 两条验收判据(缺一即视为未完成):
+//  1. 含 `time.Time` 的响应 JSON 匹配 `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?\+08:00$`;
+//  2. **改机器/容器时区也不变**——本文件用 `TZ=UTC` / `TZ=America/New_York` 复现"CI 容器/异地部署",
+//     断言偏移仍恒为 `+08:00`(这正是修复前会真实错 8 小时的场景)。
+package tz
+
+import (
+	"encoding/json"
+	"regexp"
+	"strings"
+	"testing"
+	"time"
+)
+
+// rfc3339Plus8 契约要求的 JSON 时间形状(RFC3339 + 固定 +08:00 偏移)。
+var rfc3339Plus8 = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?\+08:00$`)
+
+// TestApplyFixesProcessZone Apply 后进程时区恒为 Asia/Shanghai,且不受机器 TZ 影响。
+//
+// 说明:`TZ` 只在进程启动时影响 `time.Local` 的初值,运行期改 `TZ` 不会自动生效 ——
+// 这里刻意先改 `TZ` 再 `Apply()`,模拟"容器把 TZ 设成 UTC 后进程启动/重设时区"的最坏情况。
+func TestApplyFixesProcessZone(t *testing.T) {
+	for _, machineTZ := range []string{"UTC", "America/New_York", "Europe/Berlin"} {
+		t.Run("TZ="+machineTZ, func(t *testing.T) {
+			t.Setenv("TZ", machineTZ) // 模拟 CI 容器/异地机器
+			original := time.Local
+			t.Cleanup(func() { time.Local = original })
+
+			loc := Apply()
+			if got := time.Local.String(); got != LocationName {
+				t.Fatalf("Apply 后 time.Local = %q,期望 %q(不能跟随机器 TZ=%s)", got, LocationName, machineTZ)
+			}
+			if loc.String() != LocationName {
+				t.Fatalf("Apply 返回值 = %q,期望 %q", loc.String(), LocationName)
+			}
+			// 偏移必须是 +08:00(28800 秒)
+			if _, offset := time.Now().In(loc).Zone(); offset != fixedZoneOffset {
+				t.Fatalf("TZ=%s 下时区偏移 = %d 秒,期望 %d(+08:00)", machineTZ, offset, fixedZoneOffset)
+			}
+		})
+	}
+}
+
+// TestApplyIdempotent 重复 Apply 无副作用(多处装配/多单测共享时安全)。
+func TestApplyIdempotent(t *testing.T) {
+	original := time.Local
+	t.Cleanup(func() { time.Local = original })
+	first := Apply()
+	second := Apply()
+	if first.String() != second.String() || second.String() != LocationName {
+		t.Fatalf("Apply 不幂等:%q / %q", first, second)
+	}
+}
+
+// TestJSONOffsetAlwaysPlus8 验收判据①:含 time.Time 的 JSON 恒带 `+08:00`。
+//
+// 覆盖两条真实路径:
+//   - `time.Now()`(未显式指定时区 → 取 time.Local,即 Apply 后的 Asia/Shanghai);
+//   - 从 DB 读出的时间经 `In(tz.Location())` 归一后序列化(仓储/Handler 的推荐写法)。
+func TestJSONOffsetAlwaysPlus8(t *testing.T) {
+	original := time.Local
+	t.Cleanup(func() { time.Local = original })
+	t.Setenv("TZ", "UTC")
+	Apply()
+
+	type payload struct {
+		Now      time.Time `json:"now"`
+		FromDB   time.Time `json:"fromDb"`
+		Explicit time.Time `json:"explicit"`
+	}
+	// 模拟"DB 里存的是无时区 datetime(3),驱动按 loc=Asia/Shanghai 扫成 +08:00"
+	dbTime := time.Date(2026, 3, 1, 12, 30, 45, 123_000_000, Location())
+
+	raw, err := json.Marshal(payload{
+		Now:      time.Now(),
+		FromDB:   dbTime.In(Location()),
+		Explicit: time.Date(2026, 12, 31, 23, 59, 59, 0, Location()),
+	})
+	if err != nil {
+		t.Fatalf("序列化失败: %v", err)
+	}
+	var decoded map[string]string
+	if err := json.Unmarshal(raw, &decoded); err != nil {
+		t.Fatalf("反序列化失败: %v", err)
+	}
+	for _, field := range []string{"now", "fromDb", "explicit"} {
+		value, ok := decoded[field]
+		if !ok {
+			t.Fatalf("JSON 缺少字段 %s: %s", field, raw)
+		}
+		if !rfc3339Plus8.MatchString(value) {
+			t.Fatalf("字段 %s = %q,不匹配契约 ^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?\\+08:00$(TZ=UTC 下必须仍是 +08:00)", field, value)
+		}
+	}
+	// 边界日:跨年零点必须是本地日历日(UTC 会退到 12-31 15:59:59,账期错位)
+	if got := decoded["explicit"]; got != "2026-12-31T23:59:59+08:00" {
+		t.Fatalf("边界时间 = %q,期望 2026-12-31T23:59:59+08:00", got)
+	}
+}
+
+// TestLocationNeverFallsBackToUTC 时区加载的纵深防御:Location 名恒为 Asia/Shanghai、偏移恒 +8h。
+//
+// 即使容器内缺 tzdata(极端裁剪镜像),loadLocation 也只会回退到**固定 +08:00 区**,
+// 绝不会回退 UTC(那会把账期整体错 8 小时)。
+func TestLocationNeverFallsBackToUTC(t *testing.T) {
+	loc := Location()
+	if loc.String() != LocationName {
+		t.Fatalf("Location = %q,期望 %q(不得回退 UTC)", loc.String(), LocationName)
+	}
+	if _, offset := time.Date(2026, 6, 1, 0, 0, 0, 0, loc).Zone(); offset != fixedZoneOffset {
+		t.Fatalf("偏移 = %d 秒,期望 %d(中国无夏令时,全年 +08:00)", offset, fixedZoneOffset)
+	}
+}
+
+// TestNormalizeDSN DSN 时区收口:覆盖 loc=Local / 缺失 / 已正确三种输入。
+func TestNormalizeDSN(t *testing.T) {
+	cases := []struct {
+		name string
+		in   string
+		want string
+	}{
+		{
+			name: "loc=Local 必须被改写(随机器漂移的根因)",
+			in:   "dev:pass@tcp(127.0.0.1:3306)/yuansiwei_2026?charset=utf8mb4&parseTime=True&loc=Local",
+			want: "dev:pass@tcp(127.0.0.1:3306)/yuansiwei_2026?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai",
+		},
+		{
+			name: "loc=UTC 同样改写",
+			in:   "u:p@tcp(h:3306)/db?parseTime=False&loc=UTC",
+			want: "u:p@tcp(h:3306)/db?parseTime=true&loc=Asia%2FShanghai",
+		},
+		{
+			name: "无查询串 → 自动补 ? 与两个参数",
+			in:   "u:p@tcp(h:3306)/db",
+			want: "u:p@tcp(h:3306)/db?parseTime=true&loc=Asia%2FShanghai",
+		},
+		{
+			name: "已正确 → 幂等(其余参数与顺序不动)",
+			in:   "u:p@tcp(h:3306)/db?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai&timeout=5s",
+			want: "u:p@tcp(h:3306)/db?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai&timeout=5s",
+		},
+		{
+			name: "空 DSN 原样返回(由调用方 fail-fast)",
+			in:   "   ",
+			want: "",
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := NormalizeDSN(tc.in)
+			if got != tc.want {
+				t.Fatalf("NormalizeDSN(%q)\n got = %q\nwant = %q", tc.in, got, tc.want)
+			}
+			// 归一结果必须幂等(重复调用不再变化)
+			if again := NormalizeDSN(got); again != got {
+				t.Fatalf("NormalizeDSN 不幂等:%q → %q", got, again)
+			}
+			if got != "" && strings.Contains(got, "loc=Local") {
+				t.Fatalf("归一后仍含 loc=Local:%q", got)
+			}
+		})
+	}
+}

+ 9 - 2
internal/migrate/source.go

@@ -22,6 +22,8 @@ import (
 	"gorm.io/driver/mysql"
 	"gorm.io/gorm"
 	"gorm.io/gorm/logger"
+
+	"yuansiwei/unified-backend/internal/infra/tz"
 )
 
 // gormSilent 静默 logger(迁移批量读写高频 SQL,不刷日志;SLOW SQL 阈值噪音在此屏蔽)。
@@ -135,8 +137,8 @@ type Source struct {
 // NewSource 创建 legacy 数据源(两个 DSN 指向同一 MySQL 实例的不同库)。
 //
 // 入参:
-//   - teachingDsn string:教学库 DSN(如 "dev:pass@tcp(127.0.0.1:3306)/yuansiwei_i?charset=utf8mb4&parseTime=True&loc=Local")。
-//   - financeDsn string:教务库 DSN(如 "dev:pass@tcp(127.0.0.1:3306)/finance_bk?charset=utf8mb4&parseTime=True&loc=Local")。
+//   - teachingDsn string:教学库 DSN(如 "dev:pass@tcp(127.0.0.1:3306)/yuansiwei_i?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai")。
+//   - financeDsn string:教务库 DSN(如 "dev:pass@tcp(127.0.0.1:3306)/finance_bk?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai")。
 //
 // 出参:
 //   - *Source:就绪的只读数据源。
@@ -152,6 +154,11 @@ func NewSource(teachingDsn, financeDsn string) (*Source, error) {
 	if len(missing) > 0 {
 		return nil, fmt.Errorf("migrate: legacy DSN 不能为空: %v", missing)
 	}
+	// 时区收口(与 API 进程同一口径):历史 datetime 一律按 Asia/Shanghai 解释,
+	// 避免"迁移按 UTC 读、接口按 +08:00 写"造成同一条记录差 8 小时。
+	teachingDsn = tz.NormalizeDSN(teachingDsn)
+	financeDsn = tz.NormalizeDSN(financeDsn)
+
 	teaching, err := gorm.Open(mysql.Open(teachingDsn), gormMigrateCfg())
 	if err != nil {
 		return nil, fmt.Errorf("migrate: 教学库连接失败: %w", err)