Bläddra i källkod

feat(db): T2 GORM Model 与目标态 DDL 逐列对齐 + infra/db 装配 + AutoMigrate 对账测试

- identity/model:User/Identity/LegacyMapping/AuditLog 四表,对齐 core-model-ddl.sql
  · D1 用户类主键/外键 = string(ULID, size:26)
  · D3 关系/日志表主键 = int64 自增,持 canonical_user_id 外键列
  · string 全带 size:N(GORM 漏 size 默认建 longtext,对账实证漂移)
  · tinyint(1)→bool / int→int32 / tinyint→int8 / JSON→*string 直存 longtext
- org/model:Institution/Campus/Membership/RoleModel/GuardianLink 五表
  · D2 机构/校区主键 = int64 自增(作用域根)
  · RoleModel 主键 role_code 为 string 字典码(非自增),t_role 只读字典
- infra/db:NewDB(连接池+Ping fail-fast) / Migrate(通用 AutoMigrate,model 由调用方传入,
  保持 infra 零业务依赖) / Close;DisableForeignKeyConstraintWhenMigrating
- infra/db/db_test.go:TestAutoMigrateReconcile,AutoMigrate 9 表后读 information_schema
  逐列核对 类型/可空/主键 与 DDL 一致(26 项断言,UNIFIED_TEST_DSN 经环境变量注入,未设置自动 skip)
- go.mod:引入 gorm.io/driver/mysql v1.6.0(go mod tidy 归位直接依赖)
Q 3 dagar sedan
förälder
incheckning
ffadaf75ea
6 ändrade filer med 657 tillägg och 0 borttagningar
  1. 3 0
      go.mod
  2. 6 0
      go.sum
  3. 173 0
      internal/identity/model/models.go
  4. 133 0
      internal/infra/db/db.go
  5. 174 0
      internal/infra/db/db_test.go
  6. 168 0
      internal/org/model/models.go

+ 3 - 0
go.mod

@@ -9,14 +9,17 @@ require (
 	github.com/oklog/ulid v1.3.1
 	go.uber.org/zap v1.28.0
 	golang.org/x/crypto v0.33.0
+	gorm.io/driver/mysql v1.6.0
 	gorm.io/gorm v1.31.2
 )
 
 require (
+	filippo.io/edwards25519 v1.1.0 // indirect
 	github.com/andybalholm/brotli v1.1.0 // indirect
 	github.com/gabriel-vasile/mimetype v1.4.8 // indirect
 	github.com/go-playground/locales v0.14.1 // indirect
 	github.com/go-playground/universal-translator v0.18.1 // indirect
+	github.com/go-sql-driver/mysql v1.8.1 // indirect
 	github.com/google/uuid v1.6.0 // indirect
 	github.com/jinzhu/inflection v1.0.0 // indirect
 	github.com/jinzhu/now v1.1.5 // indirect

+ 6 - 0
go.sum

@@ -1,3 +1,5 @@
+filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
+filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
 github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
 github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -12,6 +14,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
 github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
 github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
 github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
+github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
+github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
 github.com/gofiber/fiber/v2 v2.52.16 h1:lBemN7sjeTPwEdK/9AIAQ3YpYpzEZefiWI/vQT/6dFU=
 github.com/gofiber/fiber/v2 v2.52.16/go.mod h1:dFf1PKExrrSfg/hvoP2yXL35W3bGlzZf71KX+IYzkpg=
 github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
@@ -69,6 +73,8 @@ golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
 golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
+gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
 gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
 gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
 gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=

+ 173 - 0
internal/identity/model/models.go

@@ -0,0 +1,173 @@
+// Package model 身份域 GORM 领域模型(与 unified/02-data-model/core-model-ddl.sql 逐列对齐)。
+//
+// 设计决策(T1 D1/D2/D3,2026-11-06 用户拍板):
+//   - D1 用户类主键 = canonical_user_id string(ULID 26 位,VARCHAR(26),ADR-013)——
+//     t_user 主键;t_identity/t_membership/t_guardian_link 中"引用人"的外键列一律 string。
+//   - D2 机构/校区主键 = int64 自增(org/model,非 ULID,对齐 scope.Scope 的 int64 作用域字段)。
+//   - D3 关系/日志表(t_identity/t_legacy_mapping/t_audit_log)主键 = int64 自增,
+//     持有 canonical_user_id string 外键列。
+//
+// GORM 类型映射纪律(架构规格 §3.6,T2 验收"DDL 逐表与 GORM Model 对齐"的硬要求):
+//   - **string 必须带 size:N**:GORM MySQL 驱动对未指定 size 的 string 字段默认建 **longtext**,
+//     与 DDL 的 varchar(N) 严重漂移(T2 对账测试实证:漏 size 导致 26 列全变 longtext)。
+//     每个 string/*string 字段的 size 严格等于 DDL 的 varchar(N) 的 N。
+//   - **tinyint(1) 用 bool**:GORM bool → tinyint(1);int64 会建 bigint(与 DDL 不符)。
+//   - **int(11) 用 int32**:GORM int32 → int;int64 会建 bigint(legacy_*_user_id 列对齐 DDL int)。
+//   - **tinyint(非 1)用 int8**:GORM int8 → tinyint(match_confidence 置信度列)。
+//   - **JSON 列用 string 直存 → longtext**:对齐 task.Payload 纪律,避免 GORM 自定义类型序列化坑
+//     (DDL t_audit_log.detail 已同步为 longtext)。
+//   - 可空列(IS_NULLABLE=YES)一律指针化(*string/*int64/*time.Time);写边界 nil 归一由 Service 负责,
+//     model 只如实映射列,不在 model 里做业务归一(防"converting NULL" 500 + 防字段语义漂移)。
+//   - 列名显式:所有字段都写 `gorm:"column:xxx"`,不依赖 GORM 默认命名。
+//   - 时间列 CreatedAt/UpdatedAt:GORM 按字段名自动管理(GORM v1.31 MySQL 默认 datetime(3),
+//     与 DDL 的 DEFAULT/ON UPDATE CURRENT_TIMESTAMP(3) 语义一致)。
+//
+// 依赖方向铁律:本包属 identity 域 model,只被 identity 域 service/repo 依赖;
+// 不依赖 handler/service,不碰 DB 句柄(纯实体 + 列映射)。
+package model
+
+import "time"
+
+// PersonType User 的身份类型(t_user.person_type,规划 §5.1:User 级区分"人"的业务身份)。
+// 与 Membership.Role(运营角色 7 类)是**两个维度**:person_type 是"这什么人",role 是"在哪个机构/校区担什么职"。
+const (
+	PersonTypeStudent = "student" // 学生
+	PersonTypeTeacher = "teacher" // 老师
+	PersonTypeParent  = "parent"  // 家长
+	PersonTypeStaff   = "staff"   // 员工(教务/财务/管理等运营角色,默认值)
+)
+
+// UserStatus 用户全局状态(t_user.status,平台级,跨机构)。
+const (
+	UserStatusActive   = "active"   // 正常
+	UserStatusDisabled = "disabled" // 禁用(账号封禁,全机构不可登录)
+	UserStatusMerged   = "merged"   // 已合并(归一 pipeline 软合并,不物理删,数据保留供审计)
+)
+
+// HashVersion 密码哈希版本(t_user.hash_version,ADR-016 惰性升级路由依据)。
+// 登录时先按版本路由比对,避免双算法误判;命中旧版本即升级 argon2。
+const (
+	HashVersionMd5Salt = "md5_salt" // 旧教务:md5(password + salt)
+	HashVersionShaSalt = "sha_salt" // 旧教学:sha1(password + salt)
+	HashVersionArgon2  = "argon2"   // 目标态:argon2id(惰性升级终态)
+)
+
+// User 用户主表 t_user(人,平台级全局唯一,跨机构共享)。
+//
+// D1:主键 CanonicalUserID 为 string(ULID),size:26 对齐 varchar(26);
+// GORM string 主键用 primaryKey tag 标注(非自增)。
+type User struct {
+	CanonicalUserID string    `gorm:"column:canonical_user_id;primaryKey;size:26" json:"canonicalUserId"`
+	Username        *string   `gorm:"column:username;size:64" json:"username,omitempty"`
+	DisplayName     *string   `gorm:"column:display_name;size:64" json:"displayName,omitempty"`
+	AvatarURL       *string   `gorm:"column:avatar_url;size:512" json:"avatarUrl,omitempty"`
+	PersonType      string    `gorm:"column:person_type;size:16;not null" json:"personType"`
+	PasswordHash    *string   `gorm:"column:password_hash;size:255" json:"-"` // 密码哈希永不出 JSON(敏感,脱敏纪律 §3.3)
+	PasswordSalt    *string   `gorm:"column:password_salt;size:128" json:"-"` // 盐永不出 JSON
+	HashVersion     *string   `gorm:"column:hash_version;size:16" json:"-"`   // 版本永不出 JSON
+	Status          string    `gorm:"column:status;size:16;not null" json:"status"`
+	CreatedAt       time.Time `gorm:"column:created_at" json:"createdAt"`
+	UpdatedAt       time.Time `gorm:"column:updated_at" json:"updatedAt"`
+}
+
+// TableName 指定表名(GORM 默认会把 User → users 复数,与 DDL 单数 t_user 不符,必须显式)。
+func (User) TableName() string { return "t_user" }
+
+// IdentityType 登录身份类型(t_identity.type,身份归一后的"登录方式"枚举)。
+const (
+	IdentityTypePhone          = "phone"           // 手机号(11 位归一)
+	IdentityTypeEmail          = "email"           // 邮箱(小写归一)
+	IdentityTypeWechat         = "wechat"          // 微信 open_id(per-appid 天然唯一)
+	IdentityTypeUsername       = "username"        // 用户名(账密登录账号)
+	IdentityTypeLegacyAcademic = "legacy_academic" // 教务旧 user 记录(迁移种子)
+	IdentityTypeLegacyTeaching = "legacy_teaching" // 教学旧 user 记录(迁移种子)
+)
+
+// Identity 登录身份表 t_identity(多对一挂 User,一人多种登录方式)。
+//
+// D3:主键 IdentityID int64 自增;CanonicalUserID string(ULID,size:26)。
+// (Type, Identifier) 联合唯一——phone/email 全局唯一,wechat open_id 天然 per-appid 唯一。
+type Identity struct {
+	IdentityID      int64     `gorm:"column:identity_id;primaryKey" json:"identityId"`
+	CanonicalUserID string    `gorm:"column:canonical_user_id;size:26;not null" json:"canonicalUserId"`
+	Type            string    `gorm:"column:type;size:32;not null" json:"type"`
+	Identifier      string    `gorm:"column:identifier;size:128;not null" json:"identifier"`
+	AppID           *string   `gorm:"column:appid;size:64" json:"appId,omitempty"`
+	Verified        bool      `gorm:"column:verified;not null" json:"verified"` // tinyint(1) → bool
+	CreatedAt       time.Time `gorm:"column:created_at" json:"createdAt"`
+}
+
+// TableName 指定表名(GORM 默认 Identity → identities,与 DDL 单数 t_identity 不符)。
+func (Identity) TableName() string { return "t_identity" }
+
+// LegacyMapping 遗留映射表 t_legacy_mapping(三源旧 int user_id → ULID,永久保留,审计/回滚底线)。
+//
+// D3:主键 ID int64 自增;CanonicalUserID string(ULID,size:26,UNIQUE)。
+// 旧 int 只存这里(ADR-013);legacy_*_user_id 用 *int32 对齐 DDL 的 int(非 bigint)。
+type LegacyMapping struct {
+	ID                   int64      `gorm:"column:id;primaryKey" json:"id"`
+	CanonicalUserID      string     `gorm:"column:canonical_user_id;size:26;not null" json:"canonicalUserId"`
+	LegacyAcademicUserID *int32     `gorm:"column:legacy_academic_user_id" json:"legacyAcademicUserId,omitempty"` // int → int32
+	LegacyTeachingUserID *int32     `gorm:"column:legacy_teaching_user_id" json:"legacyTeachingUserId,omitempty"`  // int → int32
+	LegacyIUserID        *int32     `gorm:"column:legacy_i_user_id" json:"legacyIUserId,omitempty"`               // int → int32
+	InstitutionID        int64      `gorm:"column:institution_id;not null" json:"institutionId"`
+	CampusID             int64      `gorm:"column:campus_id;not null" json:"campusId"`
+	Role                 *string    `gorm:"column:role;size:32" json:"role,omitempty"`
+	MatchSource          *string    `gorm:"column:match_source;size:32" json:"matchSource,omitempty"`
+	MatchConfidence      int8       `gorm:"column:match_confidence;not null" json:"matchConfidence"` // tinyint → int8
+	AuditRef             *string    `gorm:"column:audit_ref;size:64" json:"auditRef,omitempty"`
+	MergedAt             *time.Time `gorm:"column:merged_at" json:"mergedAt,omitempty"`
+	CreatedAt            time.Time  `gorm:"column:created_at" json:"createdAt"`
+	UpdatedAt            time.Time  `gorm:"column:updated_at" json:"updatedAt"`
+}
+
+// TableName 指定表名(GORM 默认 LegacyMapping → legacy_mappings,与 DDL 单数 t_legacy_mapping 不符)。
+func (LegacyMapping) TableName() string { return "t_legacy_mapping" }
+
+// AuditAction 审计动作枚举(t_audit_log.action,登录/合并/权限变更全量,ADR-016 §6.4 安全要点)。
+const (
+	AuditActionLogin          = "login"           // 登录成功
+	AuditActionLogout         = "logout"          // 登出
+	AuditActionRefresh        = "refresh"         // refresh 轮换
+	AuditActionMerge          = "merge"           // 用户归一合并
+	AuditActionRoleGrant      = "role_grant"      // 授权
+	AuditActionRoleRevoke     = "role_revoke"     // 撤权
+	AuditActionScopeChange    = "scope_change"    // 作用域变更(换机构/校区)
+	AuditActionPasswordChange = "password_change" // 改密(含惰性升级)
+)
+
+// AuditLog 审计日志表 t_audit_log(登录/合并/权限变更全量留痕)。
+//
+// D3:主键 ID int64 自增;ActorUserID string(ULID,size:26,可空——系统操作 NULL)。
+// Detail 用 *string 直存 JSON(GORM → longtext;DDL detail 列已同步为 longtext,对齐 task.Payload 纪律)。
+// 审计表只增不改(DDL 无 updated_at 列,故 model 不设 UpdatedAt,GORM 不为无字段建列)。
+type AuditLog struct {
+	ID            int64      `gorm:"column:id;primaryKey" json:"id"`
+	ActorUserID   *string    `gorm:"column:actor_user_id;size:26" json:"actorUserId,omitempty"`
+	Action        string     `gorm:"column:action;size:64;not null" json:"action"`
+	EntityType    *string    `gorm:"column:entity_type;size:32" json:"entityType,omitempty"`
+	EntityID      *string    `gorm:"column:entity_id;size:64" json:"entityId,omitempty"`
+	InstitutionID *int64     `gorm:"column:institution_id" json:"institutionId,omitempty"`
+	CampusID      *int64     `gorm:"column:campus_id" json:"campusId,omitempty"`
+	Detail        *string    `gorm:"column:detail" json:"detail,omitempty"` // JSON → longtext(string 直存)
+	TraceID       *string    `gorm:"column:trace_id;size:64" json:"traceId,omitempty"`
+	IP            *string    `gorm:"column:ip;size:64" json:"ip,omitempty"`
+	CreatedAt     time.Time  `gorm:"column:created_at" json:"createdAt"`
+}
+
+// TableName 指定表名(GORM 默认 AuditLog → audit_logs,与 DDL 单数 t_audit_log 不符)。
+func (AuditLog) TableName() string { return "t_audit_log" }
+
+// AllTables 返回本域全部 model 指针切片(供装配层/集成测试合并后传入 infra/db.Migrate)。
+//
+// 入参:无。
+// 出参:[]any——identity 域 4 表(User/Identity/LegacyMapping/AuditLog)。
+// 说明:返回新切片(非共享底层数组),调用方可安全 append。
+func AllTables() []any {
+	return []any{
+		&User{},
+		&Identity{},
+		&LegacyMapping{},
+		&AuditLog{},
+	}
+}

+ 133 - 0
internal/infra/db/db.go

@@ -0,0 +1,133 @@
+// Package db 数据库连接池装配 + AutoMigrate 收口(架构规格 §3.5/§3.6,GORM + Repository 分层)。
+//
+// 职责边界(依赖方向铁律:infra 不依赖任何业务域):
+//   - 本包**只提供**"建连接池 + 执行 AutoMigrate"两个通用能力,**不**内置任何业务 model;
+//     具体要迁哪些表由**调用方**(cmd/api 装配层 / 集成测试)传入 model 列表——
+//     这样 infra 保持零业务域依赖(若在本包 import identity/model、org/model 会违反铁律)。
+//   - 业务域 model 的"全表清单"由各域 model 包提供(identity/model.AllTables / org/model.AllTables),
+//     装配层合并后传入 Migrate。
+//
+// 与 DDL 的关系(unified/02-data-model/core-model-ddl.sql):
+//   - 生产环境割接以 **DDL 文件为准**(PolarDB 建表,information_schema 核对);
+//   - AutoMigrate 用于 **dev 环境快速建表 + e2e 测试隔离建表**(幂等:表存在则对齐,不存在则建);
+//   - 两者必须逐列一致(T2 验收:GORM model tag 与 DDL 列名/类型/可空性对齐,单测对账)。
+//
+// 典型使用(cmd/api/main.go,T4 接线后):
+//
+//	db, err := db.NewDB(cfg.DB)                 // 建连接池 + ping
+//	all := append(append(identitymodel.AllTables(), orgmodel.AllTables()...), task.Task{})
+//	if err := db.Migrate(db, all...); err != nil { log.Fatalf("migrate: %v", err) }
+//	defer db.Close(db)
+//
+// 双实例(ADR-015):两个 Go 进程各调一次 NewDB(同一 DSN 连同一库),instanceId 只影响
+// 日志/临时目录,不影响连接池——DB 连接池天然共享同一个 PolarDB,无需"双 DSN"。
+package db
+
+import (
+	"fmt"
+	"time"
+
+	"gorm.io/driver/mysql"
+	"gorm.io/gorm"
+	"gorm.io/gorm/logger"
+
+	"yuansiwei/unified-backend/internal/infra/config"
+)
+
+// NewDB 建立 MySQL 连接池并验证连通性(fail-fast:连不上库不允许启动,
+// 避免运行到首个查询才暴露"DSN 配错/库没起")。
+//
+// 入参:
+//   - cfg config.DBCfg:PolarDB/MySQL 配置(DSN + 连接池参数,见 config.DBCfg)。
+//
+// 出参:
+//   - *gorm.DB:就绪的 GORM 句柄(连接池已按 cfg 调参、已 ping 成功)。
+//   - error:以下场景返回错误:
+//     - DSN 为空(fail-fast,配置缺失);
+//     - gorm.Open 失败(驱动初始化/连接失败);
+//     - Ping 失败(网络不通/账密错/库不存在)。
+//
+// 连接池参数(§3.6 数据访问纪律):
+//   - MaxOpenConns:限制并发连接,防双实例合计打满 PolarDB max_connections;
+//   - MaxIdleConns:空闲连接复用,降低握手开销;
+//   - ConnMaxLifetimeSec:连接最长存活,防连接池漂移(DB 侧 wait_timeout 与客户端对齐)。
+//
+// 外键约束:gorm.Config 显式 DisableForeignKeyConstraintWhenMigrating=true——
+// 本项目"外键不建物理 FK,应用层 Repository 强制作用域保证"(P0-11 收敛规则 7 / DDL 注释),
+// AutoMigrate 不得自动补 FK 约束(与 DDL 的 FOREIGN_KEY_CHECKS=0 语义一致)。
+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{
+		// 与 DDL"不建物理外键"对齐:迁移时不自动生成 FK 约束
+		DisableForeignKeyConstraintWhenMigrating: true,
+		// 生产/双实例默认静默 SQL 日志(避免高频只读接口刷日志);联调期可换 logger.Warn
+		Logger: logger.Default.LogMode(logger.Silent),
+	})
+	if err != nil {
+		return nil, fmt.Errorf("db: gorm.Open 失败: %w", err)
+	}
+	sqlDB, err := gdb.DB()
+	if err != nil {
+		return nil, fmt.Errorf("db: 取底层 *sql.DB 失败: %w", err)
+	}
+	// 连接池调参(0 值表示用 driver 默认;显式设置保证行为可预期)
+	if cfg.MaxOpenConns > 0 {
+		sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
+	}
+	if cfg.MaxIdleConns > 0 {
+		sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
+	}
+	if cfg.ConnMaxLifetimeSec > 0 {
+		sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetimeSec) * time.Second)
+	}
+	// 连通性验证(fail-fast)
+	if err := sqlDB.Ping(); err != nil {
+		return nil, fmt.Errorf("db: Ping 失败(网络/账密/库名): %w", err)
+	}
+	return gdb, nil
+}
+
+// Migrate 对给定模型执行 AutoMigrate(幂等建表/对齐,dev + e2e 用)。
+//
+// 入参:
+//   - gdb *gorm.DB:NewDB 返回的句柄。
+//   - models ...any:要迁移的模型(*struct 指针,如 &model.User{};由调用方合并各域 AllTables 传入)。
+//
+// 出参:
+//   - error:AutoMigrate 失败时返回(DDL 语法错/权限不足/表结构冲突)。
+//
+// 幂等语义:表不存在则按 model 建;已存在则逐列比对,缺列补列、类型不符告警
+// (GORM AutoMigrate 不删多余列、不强改已有列类型——避免误删生产数据,对齐"迁移最易失真处"纪律)。
+//
+// 说明:本函数**不硬编码**任何 model(保持 infra 零业务依赖);
+// 传入空 models 时为无操作(不报错,便于条件迁移)。
+func Migrate(gdb *gorm.DB, models ...any) error {
+	if len(models) == 0 {
+		return nil
+	}
+	if err := gdb.AutoMigrate(models...); err != nil {
+		return fmt.Errorf("db: AutoMigrate 失败(%d 个模型): %w", len(models), err)
+	}
+	return nil
+}
+
+// Close 关闭连接池(优雅退出路径:cmd/api 收到 SIGTERM 后先排空在途任务再 Close,
+// 防连接泄漏——GORM 底层 *sql.DB 不显式 Close 会残留连接占用 PolarDB max_connections)。
+//
+// 入参:
+//   - gdb *gorm.DB:NewDB 返回的句柄(nil 安全)。
+//
+// 出参:
+//   - error:底层 sqlDB.Close 失败时返回(通常仅进程即将退出时调用,错误可忽略)。
+func Close(gdb *gorm.DB) error {
+	if gdb == nil {
+		return nil
+	}
+	sqlDB, err := gdb.DB()
+	if err != nil {
+		return fmt.Errorf("db: 取底层 *sql.DB 失败: %w", err)
+	}
+	return sqlDB.Close()
+}

+ 174 - 0
internal/infra/db/db_test.go

@@ -0,0 +1,174 @@
+// 集成测试:GORM model 与 core-model-ddl.sql 逐列对账(T2 验收判据)。
+//
+// 运行前提(DSN 含密码,不入库,经环境变量注入):
+//
+//	# Windows
+//	set UNIFIED_TEST_DSN=dev:xxxx@tcp(127.0.0.1:3306)/e2e_test?charset=utf8mb4&parseTime=True&loc=Local
+//	go test ./internal/infra/db/ -run TestAutoMigrateReconcile -v
+//
+// 未设置 UNIFIED_TEST_DSN 时本测试自动跳过(CI / 无库环境 go test ./... 不受影响)。
+// 测试目标库用 e2e_test(dev 账号有建表/DROP 表权限),**不污染** dev 主库 yuansiwei_2026;
+// 测后 DropTable 清理 9 张表(保留 e2e_test 库本身,供 W3 e2e 复用)。
+//
+// 对账逻辑:AutoMigrate 按 model tag 建表 → 读 information_schema → 断言关键列的
+// 类型/可空性/主键与 T1 DDL 一致。任一不符说明 model tag 写错(列名/类型/指针化漂移),fail。
+package db
+
+import (
+	"fmt"
+	"os"
+	"strings"
+	"testing"
+
+	"gorm.io/gorm"
+
+	identitymodel "yuansiwei/unified-backend/internal/identity/model"
+	"yuansiwei/unified-backend/internal/infra/config"
+	orgmodel "yuansiwei/unified-backend/internal/org/model"
+)
+
+// expectedColumn 单列对账预期(表名 → 列名 → 预期)。
+type expectedColumn struct {
+	table  string
+	column string
+	// typePrefix 期望 COLUMN_TYPE 前缀(如 "varchar(26)"/"bigint"/"int"/"tinyint(1)"/"datetime");
+	// 空串表示只核对可空性/主键、不核对类型。
+	typePrefix string
+	// nullable 期望 IS_NULLABLE("YES"/"NO")
+	nullable string
+	// primaryKey 期望是否主键(COLUMN_KEY 含 "PRI")
+	primaryKey bool
+}
+
+// TestAutoMigrateReconcile GORM model 全表 AutoMigrate 后逐列核对(T2 验收)。
+//
+// 覆盖(9 表关键列,对齐 T1 DDL 决策 D1/D2/D3):
+//   - D1 用户类主键/外键 = varchar(26):t_user.canonical_user_id(PRI) + 各表引用人列
+//   - D2 机构/校区主键 = bigint:t_institution.institution_id / t_campus.campus_id
+//   - D3 关系/日志表主键 = bigint 自增:t_identity / t_membership / t_guardian_link / t_legacy_mapping / t_audit_log
+//   - 可空列指针化核对(*字段 ↔ IS_NULLABLE=YES;非 *字段 ↔ NO)
+func TestAutoMigrateReconcile(t *testing.T) {
+	dsn := os.Getenv("UNIFIED_TEST_DSN")
+	if dsn == "" {
+		t.Skip("未设置 UNIFIED_TEST_DSN,跳过 AutoMigrate 对账集成测试")
+	}
+
+	cfg := config.DBCfg{DSN: dsn, MaxOpenConns: 5, MaxIdleConns: 2, ConnMaxLifetimeSec: 60}
+	gdb, err := NewDB(cfg)
+	if err != nil {
+		t.Fatalf("NewDB: %v", err)
+	}
+	t.Cleanup(func() { _ = Close(gdb) })
+
+	// 全表清单 = identity 4 表 + org 5 表(task 表 T2 不涉及,T4 接入)
+	all := append(identitymodel.AllTables(), orgmodel.AllTables()...)
+	if err := Migrate(gdb, all...); err != nil {
+		t.Fatalf("Migrate: %v", err)
+	}
+
+	// 测后清理(保留库,只 drop 表;e2e_test 供 W3 复用)
+	t.Cleanup(func() {
+		m := gdb.Migrator()
+		_ = m.DropTable("t_user", "t_identity", "t_legacy_mapping", "t_audit_log",
+			"t_institution", "t_campus", "t_membership", "t_role", "t_guardian_link")
+	})
+
+	// 逐列对账预期(与 T1 core-model-ddl.sql 一致)
+	expects := []expectedColumn{
+		// D1:t_user 主键 ULID varchar(26)
+		{"t_user", "canonical_user_id", "varchar(26)", "NO", true},
+		{"t_user", "username", "varchar(64)", "YES", false},
+		{"t_user", "person_type", "varchar(16)", "NO", false},
+		{"t_user", "password_hash", "varchar(255)", "YES", false},
+		{"t_user", "status", "varchar(16)", "NO", false},
+		// D2:机构/校区主键 bigint
+		{"t_institution", "institution_id", "bigint", "NO", true},
+		{"t_institution", "institution_name", "varchar(128)", "NO", false},
+		{"t_campus", "campus_id", "bigint", "NO", true},
+		{"t_campus", "institution_id", "bigint", "NO", false},
+		// D3:关系/日志表主键 bigint + 引用人列 varchar(26)
+		{"t_identity", "identity_id", "bigint", "NO", true},
+		{"t_identity", "canonical_user_id", "varchar(26)", "NO", false},
+		{"t_identity", "type", "varchar(32)", "NO", false},
+		{"t_identity", "verified", "tinyint(1)", "NO", false},
+		{"t_membership", "membership_id", "bigint", "NO", true},
+		{"t_membership", "canonical_user_id", "varchar(26)", "NO", false},
+		{"t_membership", "role", "varchar(32)", "NO", false},
+		{"t_role", "role_code", "varchar(32)", "NO", true},
+		{"t_role", "login_allowed", "tinyint(1)", "NO", false},
+		{"t_guardian_link", "link_id", "bigint", "NO", true},
+		{"t_guardian_link", "guardian_id", "varchar(26)", "NO", false},
+		{"t_guardian_link", "student_id", "varchar(26)", "NO", false},
+		{"t_legacy_mapping", "id", "bigint", "NO", true},
+		{"t_legacy_mapping", "canonical_user_id", "varchar(26)", "NO", false},
+		{"t_legacy_mapping", "legacy_academic_user_id", "int", "YES", false},
+		{"t_audit_log", "id", "bigint", "NO", true},
+		{"t_audit_log", "actor_user_id", "varchar(26)", "YES", false},
+		{"t_audit_log", "action", "varchar(64)", "NO", false},
+	}
+
+	var failed []string
+	for _, e := range expects {
+		got := queryColumn(t, gdb, e.table, e.column)
+		if got == nil {
+			failed = append(failed, fmt.Sprintf("%s.%s: 列不存在", e.table, e.column))
+			continue
+		}
+		if e.typePrefix != "" && !strings.HasPrefix(got.Coltype, e.typePrefix) {
+			failed = append(failed, fmt.Sprintf("%s.%s: 类型=%q 期望前缀=%q", e.table, e.column, got.Coltype, e.typePrefix))
+		}
+		if got.Nullable != e.nullable {
+			failed = append(failed, fmt.Sprintf("%s.%s: IS_NULLABLE=%q 期望=%q", e.table, e.column, got.Nullable, e.nullable))
+		}
+		hasPri := strings.Contains(got.Colkey, "PRI")
+		if hasPri != e.primaryKey {
+			failed = append(failed, fmt.Sprintf("%s.%s: 主键=%v 期望=%v", e.table, e.column, hasPri, e.primaryKey))
+		}
+	}
+
+	if len(failed) > 0 {
+		t.Errorf("AutoMigrate 对账发现 %d 处 model tag 与 DDL 不符:\n  %s", len(failed), strings.Join(failed, "\n  "))
+	}
+}
+
+// columnInfo 单列核对结果(来自 information_schema.COLUMNS,列名经 alias 对齐字段名供 GORM Scan 映射)。
+// 字段必须**导出**(首字母大写)——GORM 靠反射赋值,未导出字段(小写开头)会被静默跳过,
+// 导致全留零值、误判"列不存在"(T2 实踩)。GORM 命名策略把 Coltype→coltype / Nullable→nullable /
+// Colkey→colkey,与下方 SQL alias 对齐。
+type columnInfo struct {
+	Coltype  string // COLUMN_TYPE(如 varchar(26)/bigint/int/tinyint(1))
+	Nullable string // IS_NULLABLE(YES/NO)
+	Colkey   string // COLUMN_KEY(PRI/UNI/MUL/"")
+}
+
+// queryColumn 读 information_schema 取单列元信息(列不存在返回 nil,由调用方判 fail)。
+//
+// 入参:
+//   - t   *testing.T:测试上下文。
+//   - gdb *gorm.DB:连接句柄。
+//   - table / column string:目标表/列。
+//
+// 出参:*columnInfo;列不存在(表不存在或列名错)返回 nil。
+// 说明:SELECT 列名用 alias 对齐 columnInfo 字段名(coltype/nullable/colkey),
+// GORM Scan 按"列名==字段名(不区分大小写)"映射,避免 COLUMN_TYPE→columnType 映射不上。
+func queryColumn(t *testing.T, gdb *gorm.DB, table, column string) *columnInfo {
+	t.Helper()
+	// 取当前连接的 database 名(information_schema 限定 schema)
+	var schema string
+	if err := gdb.Raw("SELECT DATABASE()").Scan(&schema).Error; err != nil {
+		t.Fatalf("取 database 名失败: %v", err)
+	}
+	var c columnInfo
+	if err := gdb.Raw(`
+		SELECT COLUMN_TYPE AS coltype, IS_NULLABLE AS nullable, COLUMN_KEY AS colkey
+		FROM information_schema.COLUMNS
+		WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?
+	`, schema, table, column).Scan(&c).Error; err != nil {
+		t.Fatalf("查 information_schema %s.%s 失败: %v", table, column, err)
+	}
+	// COLUMN_TYPE 恒非空;空值代表"无匹配行"(表/列不存在)→ 返回 nil 由调用方 fail
+	if c.Coltype == "" {
+		return nil
+	}
+	return &c
+}

+ 168 - 0
internal/org/model/models.go

@@ -0,0 +1,168 @@
+// Package model 组织域 GORM 领域模型(与 unified/02-data-model/core-model-ddl.sql 逐列对齐)。
+//
+// 设计决策(T1 D1/D2/D3,2026-11-06 用户拍板):
+//   - D2 机构/校区主键 = int64 自增(InstitutionID/CampusID,非 ULID;对齐 scope.Scope 作用域字段 int64)。
+//   - D1 引用人的外键列(Membership.CanonicalUserID / GuardianLink.GuardianID / StudentID)= string(ULID,size:26)。
+//   - D3 关系表(Membership/GuardianLink)主键 = int64 自增。
+//   - Role 为平台级字典表(不带作用域),role_code 为主键(字典码,非自增)。
+//
+// GORM 类型映射纪律(架构规格 §3.6,T2 验收"DDL 逐表与 GORM Model 对齐"的硬要求):
+//   - **string 必须带 size:N**(GORM 未指定 size 的 string 默认 longtext,T2 对账实证漂移):
+//     每个 string/*string 字段的 size 严格等于 DDL 的 varchar(N) 的 N。
+//   - **tinyint(1) 用 bool**:GORM bool → tinyint(1)(t_role.login_allowed / t_membership 无)。
+//   - 列名显式 `gorm:"column:xxx"`,不依赖默认命名。
+//   - 时间列 CreatedAt/UpdatedAt:GORM 自动管理(GORM v1.31 MySQL 默认 datetime(3),与 DDL 一致)。
+//
+// 多租户作用域(架构规格 §3.5):
+//   - Institution/Campus/Membership/GuardianLink 四表均带 institution_id / campus_id 列,
+//     Repository 强制 scoping(跨校区越权防线);Role 是平台级字典,Repository 不继承 BaseRepo。
+package model
+
+import "time"
+
+// InstitutionStatus 机构/校区状态。
+const (
+	StatusActive   = "active"   // 正常
+	StatusDisabled = "disabled" // 停用
+)
+
+// Institution 机构表 t_institution(多租户根·品牌级)。
+//
+// D2:主键 InstitutionID int64 自增(作用域根,scope.Scope.InstitutionID 引用此值)。
+type Institution struct {
+	InstitutionID   int64     `gorm:"column:institution_id;primaryKey" json:"institutionId"`
+	InstitutionName string    `gorm:"column:institution_name;size:128;not null" json:"institutionName"`
+	InstitutionCode *string   `gorm:"column:institution_code;size:32" json:"institutionCode,omitempty"`
+	Address         *string   `gorm:"column:address;size:255" json:"address,omitempty"`
+	Phone           *string   `gorm:"column:phone;size:20" json:"phone,omitempty"`
+	Status          string    `gorm:"column:status;size:16;not null" json:"status"`
+	CreatedAt       time.Time `gorm:"column:created_at" json:"createdAt"`
+	UpdatedAt       time.Time `gorm:"column:updated_at" json:"updatedAt"`
+}
+
+// TableName 指定表名(GORM 默认 Institution → institutions,与 DDL 单数 t_institution 不符)。
+func (Institution) TableName() string { return "t_institution" }
+
+// Campus 校区表 t_campus(隶属机构)。
+//
+// D2:主键 CampusID int64 自增;InstitutionID int64(→ t_institution,作用域字段)。
+type Campus struct {
+	CampusID      int64     `gorm:"column:campus_id;primaryKey" json:"campusId"`
+	InstitutionID int64     `gorm:"column:institution_id;not null" json:"institutionId"`
+	CampusName    string    `gorm:"column:campus_name;size:128;not null" json:"campusName"`
+	CampusCode    *string   `gorm:"column:campus_code;size:32" json:"campusCode,omitempty"`
+	Address       *string   `gorm:"column:address;size:255" json:"address,omitempty"`
+	Phone         *string   `gorm:"column:phone;size:20" json:"phone,omitempty"`
+	Status        string    `gorm:"column:status;size:16;not null" json:"status"`
+	CreatedAt     time.Time `gorm:"column:created_at" json:"createdAt"`
+	UpdatedAt     time.Time `gorm:"column:updated_at" json:"updatedAt"`
+}
+
+// TableName 指定表名(GORM 默认 Campus → campuses,与 DDL 单数 t_campus 不符)。
+func (Campus) TableName() string { return "t_campus" }
+
+// Role 运营角色(7 类,ADR-007 / 2026-11-05 拍板)。
+// role_code 与 Membership.Role 列取值一致(跨包引用时 import 本包常量,避免字符串散落)。
+const (
+	RoleSuperAdmin = "super_admin" // 超级管理员(平台级,跨机构)
+	RoleAcademic   = "academic"    // 教务(机构/校区级)
+	RoleFinance    = "finance"     // 财务(机构/校区级)
+	RoleTeacher    = "teacher"     // 老师(校区级)
+	RoleStudent    = "student"     // 学生(业务级)
+	RoleParent     = "parent"      // 家长(业务级,按 GuardianLink 绑定)
+	RoleMarket     = "market"      // 市场(数据保留、登录禁用,login_allowed=0)
+)
+
+// ScopeLevel 角色权限层级(t_role.scope_level)。
+const (
+	ScopeLevelPlatform    = "platform"    // 平台级(超管,跨机构)
+	ScopeLevelInstitution = "institution" // 机构/校区级(教务/财务/市场)
+	ScopeLevelCampus      = "campus"      // 校区级(老师)
+	ScopeLevelBusiness    = "business"    // 业务级(学生/家长,按报名/绑定归属)
+)
+
+// RoleModel 角色字典表 t_role(平台级元数据·不带作用域)。
+//
+// 主键 RoleCode 为 string(字典码,size:32),非自增——GORM primaryKey tag 标注(string 主键不触发自增)。
+// 种子数据由 core-model-ddl.sql 内置 INSERT IGNORE;Go 侧只读字典,故无 UpdatedAt 列。
+// LoginAllowed 为 bool 对齐 DDL tinyint(1)。
+type RoleModel struct {
+	RoleCode     string    `gorm:"column:role_code;primaryKey;size:32" json:"roleCode"`
+	RoleName     string    `gorm:"column:role_name;size:32;not null" json:"roleName"`
+	ScopeLevel   string    `gorm:"column:scope_level;size:16;not null" json:"scopeLevel"`
+	LoginAllowed bool      `gorm:"column:login_allowed;not null" json:"loginAllowed"` // tinyint(1) → bool
+	Description  *string   `gorm:"column:description;size:255" json:"description,omitempty"`
+	CreatedAt    time.Time `gorm:"column:created_at" json:"createdAt"`
+}
+
+// TableName 指定表名(GORM 默认 RoleModel → role_models,与 DDL 单数 t_role 不符;
+// 类型名加 Model 后缀避免与常量 Role* 混淆,表名显式锁定 t_role)。
+func (RoleModel) TableName() string { return "t_role" }
+
+// MembershipStatus 成员关系状态。
+const (
+	MembershipStatusActive   = "active"   // 在籍
+	MembershipStatusInactive = "inactive" // 离开(离职/退学/解绑,left_at 填值)
+)
+
+// Membership 成员关系表 t_membership(人 ↔ 机构/校区 + 角色,多对多,多租户核心,ADR-007)。
+//
+// D3:主键 MembershipID int64 自增;CanonicalUserID string(ULID,size:26,→ t_user)。
+// UNIQUE(canonical_user_id, campus_id, role)——campus 隐含 institution。
+// campus_id=0 表示平台级(如超管,跨机构)。DDL 无 updated_at 列,故 model 不设 UpdatedAt。
+type Membership struct {
+	MembershipID    int64      `gorm:"column:membership_id;primaryKey" json:"membershipId"`
+	CanonicalUserID string     `gorm:"column:canonical_user_id;size:26;not null" json:"canonicalUserId"`
+	InstitutionID   int64      `gorm:"column:institution_id;not null" json:"institutionId"` // 0=平台级
+	CampusID        int64      `gorm:"column:campus_id;not null" json:"campusId"`           // 0=平台级/机构级
+	Role            string     `gorm:"column:role;size:32;not null" json:"role"`
+	Status          string     `gorm:"column:status;size:16;not null" json:"status"`
+	JoinedAt        *time.Time `gorm:"column:joined_at" json:"joinedAt,omitempty"`
+	LeftAt          *time.Time `gorm:"column:left_at" json:"leftAt,omitempty"`
+	CreatedAt       time.Time  `gorm:"column:created_at" json:"createdAt"`
+}
+
+// TableName 指定表名(GORM 默认 Membership → memberships,与 DDL 单数 t_membership 不符)。
+func (Membership) TableName() string { return "t_membership" }
+
+// GuardianRelation 家长-学生关系类型(t_guardian_link.relation,可空)。
+const (
+	GuardianRelationFather   = "father"   // 父
+	GuardianRelationMother   = "mother"   // 母
+	GuardianRelationGuardian = "guardian" // 监护人
+	GuardianRelationOther    = "other"    // 其他
+)
+
+// GuardianLink 家长-学生绑定表 t_guardian_link(带作用域,多对多)。
+//
+// D3:主键 LinkID int64 自增;GuardianID / StudentID 均 string(ULID,size:26,→ t_user,D1)。
+// 一个学生多家长、一个家长多学生;家长只能看绑定的学生(跨家长越权防线,§3.5)。
+type GuardianLink struct {
+	LinkID        int64     `gorm:"column:link_id;primaryKey" json:"linkId"`
+	GuardianID    string    `gorm:"column:guardian_id;size:26;not null" json:"guardianId"`
+	StudentID     string    `gorm:"column:student_id;size:26;not null" json:"studentId"`
+	InstitutionID int64     `gorm:"column:institution_id;not null" json:"institutionId"`
+	CampusID      int64     `gorm:"column:campus_id;not null" json:"campusId"`
+	Relation      *string   `gorm:"column:relation;size:32" json:"relation,omitempty"`
+	Status        string    `gorm:"column:status;size:16;not null" json:"status"`
+	CreatedAt     time.Time `gorm:"column:created_at" json:"createdAt"`
+	UpdatedAt     time.Time `gorm:"column:updated_at" json:"updatedAt"`
+}
+
+// TableName 指定表名(GORM 默认 GuardianLink → guardian_links,与 DDL 单数 t_guardian_link 不符)。
+func (GuardianLink) TableName() string { return "t_guardian_link" }
+
+// AllTables 返回本域全部 model 指针切片(供装配层/集成测试合并后传入 infra/db.Migrate)。
+//
+// 入参:无。
+// 出参:[]any——org 域 5 表(Institution/Campus/Membership/Role/GuardianLink)。
+// 说明:返回新切片,调用方可安全 append。
+func AllTables() []any {
+	return []any{
+		&Institution{},
+		&Campus{},
+		&Membership{},
+		&RoleModel{},
+		&GuardianLink{},
+	}
+}