Browse Source

feat(identity): T4-2 登录主链路仓储 + 修复 WithCause 解包链契约

新增 identity 域 3 仓储(平台级访问,不继承 scope.BaseRepo):
- UserRepo: GetByCanonicalUserID + UpdatePasswordLazyUpgrade(argon2 重哈希/salt 置 NULL)
- IdentityRepo: GetByTypeIdentifier((type,identifier) 唯一键)
- MembershipRepo: ListByCanonicalUserID(按 created_at 升序)+ GetByUserInstitutionCampus
  (同作用域多角色取最新);t_membership 实体跨域引用 org/model(orgmodel 别名)

错误转换纪律:gorm.ErrRecordNotFound→ErrNotFound,其余→ErrDBError(mapGormErr 统一出口)。

关键修复(infra/errors latent bug,由本批集成测试实证暴露):
- 旧 Unwrap() 只回 Cause,WithCause(ErrNotFound, gormErr) 的 sentinel 副本被拷贝后丢失,
  导致 errors.Is(err, ErrNotFound) 恒为 false——Service/Handler 层按 sentinel 分支会失效。
- 修复:AppError 增 base 回引用(非导出),Unwrap 改 Go 1.20+ 多错误解包 []error,
  同时解出 Cause 与 base;WithCause 回填 base。回归单测锁契约(4 用例)。

测试(UNIFIED_TEST_DSN 注入 e2e_test 库,未设 skip):
- 仓储 5 主测试 + 子用例全绿(命中/未命中 ErrNotFound/惰性升级/排序/多角色取最新)
- infra/errors 4 单测全绿(无 DB 依赖)
- 全模块 go build + go vet 全绿,go test ./... 无回归
Q 3 ngày trước cách đây
mục cha
commit
60338528d2

+ 55 - 0
internal/identity/repository/identity_repo.go

@@ -0,0 +1,55 @@
+// IdentityRepo 登录身份仓储(t_identity,一人多登录方式,(type,identifier) 唯一)。
+//
+// 平台级访问(同 user_repo.go 包注释):不继承 scope.BaseRepo,裸 *gorm.DB。
+// 登录主链路的核心查询:按 (type, identifier) 唯一定位 → 取 canonical_user_id → 再查 user。
+package repository
+
+import (
+	"context"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/identity/model"
+)
+
+// IdentityRepo 登录身份仓储。
+type IdentityRepo struct {
+	db *gorm.DB
+}
+
+// NewIdentityRepo 构造登录身份仓储。
+//
+// 入参:db *gorm.DB:数据库句柄(必填)。
+// 出参:*IdentityRepo:就绪仓储。
+func NewIdentityRepo(db *gorm.DB) *IdentityRepo {
+	return &IdentityRepo{db: db}
+}
+
+// GetByTypeIdentifier 按 (type, identifier) 唯一键取登录身份记录。
+//
+// 入参:
+//   - ctx context.Context:请求上下文。
+//   - typ string:身份类型(model.IdentityType* 之一:phone/email/wechat/username/legacy_academic/legacy_teaching)。
+//   - identifier string:归一后的标识值(phone 11 位 / email 小写 / wechat open_id / username 原值)。
+//
+// 出参:
+//   - *model.Identity:身份记录(含 CanonicalUserID,供 Service 再查 user)。
+//   - error:
+//     - errors.ErrNotFound:该 (type, identifier) 无身份记录(登录"账号不存在"路径之一,防枚举统一文案);
+//     - errors.ErrDBError:DB 异常。
+//
+// 归一纪律(调用方责任):
+//   - email 必须小写、phone 必须 11 位数字、wechat 必须 open_id 原值——Repository 不做归一,
+//     归一发生在 Service 入参处理(与旧系统数据归一规则一致,见 P0-02 盘点)。
+//
+// 实现说明:用 model(&model.Identity{}) 显式指定实体(GORM 自动映射表名 t_identity,TableName() 已锁定),
+// Where 用 (type, identifier) 联合条件 + First 取单条(UNIQUE 键保证最多 1 条,无需 Limit 但 First 自带取一)。
+func (r *IdentityRepo) GetByTypeIdentifier(ctx context.Context, typ, identifier string) (*model.Identity, error) {
+	var it model.Identity
+	if err := r.db.WithContext(ctx).
+		Where("type = ? AND identifier = ?", typ, identifier).
+		First(&it).Error; err != nil {
+		return nil, mapGormErr(err)
+	}
+	return &it, nil
+}

+ 54 - 0
internal/identity/repository/identity_repo_test.go

@@ -0,0 +1,54 @@
+// IdentityRepo 仓储集成测试(t_identity 登录身份,(type,identifier) 唯一键)。
+//
+// 依赖 test_helpers_test.go 的 setup/insertFixture;未设置 UNIFIED_TEST_DSN 时跳过。
+package repository
+
+import (
+	"context"
+	stderrors "errors"
+	"testing"
+
+	apperrors "yuansiwei/unified-backend/internal/infra/errors"
+	identitymodel "yuansiwei/unified-backend/internal/identity/model"
+)
+
+// TestIdentityRepo_GetByTypeIdentifier (type,identifier) 唯一键命中 / 未命中。
+func TestIdentityRepo_GetByTypeIdentifier(t *testing.T) {
+	gdb := setup(t)
+	ctx := context.Background()
+	fx := insertFixture(t, gdb, ctx)
+	repo := NewIdentityRepo(gdb)
+
+	t.Run("命中_phone", func(t *testing.T) {
+		it, err := repo.GetByTypeIdentifier(ctx, identitymodel.IdentityTypePhone, fx.phone)
+		if err != nil {
+			t.Fatalf("GetByTypeIdentifier phone: %v", err)
+		}
+		if it.CanonicalUserID != fx.canonical {
+			t.Errorf("返回 canonical_user_id = %q,期望 %q", it.CanonicalUserID, fx.canonical)
+		}
+		if !it.Verified {
+			t.Errorf("phone 身份应 verified=true")
+		}
+	})
+
+	t.Run("命中_email", func(t *testing.T) {
+		it, err := repo.GetByTypeIdentifier(ctx, identitymodel.IdentityTypeEmail, fx.email)
+		if err != nil {
+			t.Fatalf("GetByTypeIdentifier email: %v", err)
+		}
+		if it.CanonicalUserID != fx.canonical {
+			t.Errorf("返回 canonical_user_id = %q,期望 %q", it.CanonicalUserID, fx.canonical)
+		}
+		if it.Verified {
+			t.Errorf("email 身份应 verified=false")
+		}
+	})
+
+	t.Run("未命中_返回ErrNotFound", func(t *testing.T) {
+		_, err := repo.GetByTypeIdentifier(ctx, identitymodel.IdentityTypeWechat, "unknown_openid")
+		if !stderrors.Is(err, apperrors.ErrNotFound) {
+			t.Fatalf("期望 ErrNotFound,实际 %v", err)
+		}
+	})
+}

+ 97 - 0
internal/identity/repository/membership_repo.go

@@ -0,0 +1,97 @@
+// MembershipRepo 成员关系仓储(t_membership,人 ↔ 机构/校区 + 角色,多租户核心,ADR-007)。
+//
+// 平台级访问(同包注释):登录时取"这个用户有哪些机构/校区/角色"是全局查询,不做 scoping。
+// 多租户防线在后续业务域 Repository 才启动。
+//
+// 实体跨域引用说明:t_membership 的 GORM 实体定义在 **org 域 model 包**(internal/org/model.Membership,
+// 与 Institution/Campus/Role 同域)——登录域只"读"它取 role/作用域,不持有实体所有权。
+// 本文件故用别名 orgmodel 显式标注这一跨域引用,与本包内 identity/model(User/Identity)区分。
+//
+// 登录两步协议的支撑:
+//   - 第一步 POST /auth/login 验账密后,Service 调 ListByCanonicalUserID 取该用户全部 membership,
+//     返回给前端展示"机构/校区选择页";
+//   - 第二步 POST /auth/issue 选定 (institutionId, campusId) 后,Service 调
+//     GetByUserInstitutionCampus 取对应 membership 的 role,签发 token。
+package repository
+
+import (
+	"context"
+
+	"gorm.io/gorm"
+
+	orgmodel "yuansiwei/unified-backend/internal/org/model"
+)
+
+// MembershipRepo 成员关系仓储。
+type MembershipRepo struct {
+	db *gorm.DB
+}
+
+// NewMembershipRepo 构造成员关系仓储。
+//
+// 入参:db *gorm.DB:数据库句柄(必填)。
+// 出参:*MembershipRepo:就绪仓储。
+func NewMembershipRepo(db *gorm.DB) *MembershipRepo {
+	return &MembershipRepo{db: db}
+}
+
+// ListByCanonicalUserID 取某用户全部成员关系(登录第一步返回机构/校区选择列表用)。
+//
+// 入参:
+//   - ctx context.Context:请求上下文。
+//   - uid string:canonical_user_id(ULID)。
+//
+// 出参:
+//   - []orgmodel.Membership:该用户全部 membership(含 institution_id/campus_id/role/status;
+//     空切片表示该用户尚无任何机构归属——登录成功后无机构可选,前端提示"请联系管理员分配机构")。
+//     注意:返回非 nil 切片(空时为 []orgmodel.Membership{}),JSON 序列化为 [] 而非 null。
+//   - error:errors.ErrDBError(DB 异常);用户无 membership 不算错误(返回空切片,不 ErrNotFound)。
+//
+// 实现说明:Find 而非 First(一条用户可挂多机构/多角色,多对多);按 created_at 升序
+// (登录页展示按最早加入排前,用户习惯先选常用机构)。
+func (r *MembershipRepo) ListByCanonicalUserID(ctx context.Context, uid string) ([]orgmodel.Membership, error) {
+	var list []orgmodel.Membership
+	if err := r.db.WithContext(ctx).
+		Where("canonical_user_id = ?", uid).
+		Order("created_at ASC").
+		Find(&list).Error; err != nil {
+		return nil, mapGormErr(err)
+	}
+	if list == nil {
+		list = []orgmodel.Membership{} // 非 nil:JSON 序列化 [] 而非 null
+	}
+	return list, nil
+}
+
+// GetByUserInstitutionCampus 按 (canonical_user_id, institution_id, campus_id) 取唯一 membership
+// (登录第二步 /auth/issue 选定作用域后,取该条的 role 签发 token)。
+//
+// 入参:
+//   - ctx context.Context:请求上下文。
+//   - uid string:canonical_user_id(ULID)。
+//   - institutionID int64:机构 ID(scope 根;0 表示平台级超管,无机构过滤)。
+//   - campusID int64:校区 ID(0 表示机构级/不限校区,如超管视角)。
+//
+// 出参:
+//   - *orgmodel.Membership:对应 membership(含 Role,供签发 token 的 claims.Roles)。
+//   - error:
+//     - errors.ErrNotFound:该用户在此 (机构, 校区) 下无 membership(越权/数据不一致,登录第二步应报 2003 业务规则冲突);
+//     - errors.ErrDBError:DB 异常。
+//
+// 作用域语义(与 org 域 model 注释对齐):
+//   - institution_id=0:平台级(超管跨机构);campus_id=0:机构级/不限校区。
+//   - UNIQUE(canonical_user_id, campus_id, role) 保证同一 (用户, 校区, 角色) 唯一;
+//     但同一 (用户, 机构, 校区) 可有多个 role(如某人既是教务又是财务),此时取**最新一条**
+//     (created_at 最大)——多角色场景下登录默认进"最近使用的角色"。
+func (r *MembershipRepo) GetByUserInstitutionCampus(ctx context.Context, uid string, institutionID, campusID int64) (*orgmodel.Membership, error) {
+	var m orgmodel.Membership
+	q := r.db.WithContext(ctx).
+		Where("canonical_user_id = ?", uid).
+		Where("institution_id = ?", institutionID).
+		Where("campus_id = ?", campusID)
+	// 同一 (用户, 机构, 校区) 可能多角色,取 created_at 最新的一条(最近使用的角色)
+	if err := q.Order("created_at DESC").First(&m).Error; err != nil {
+		return nil, mapGormErr(err)
+	}
+	return &m, nil
+}

+ 92 - 0
internal/identity/repository/membership_repo_test.go

@@ -0,0 +1,92 @@
+// MembershipRepo 仓储集成测试(t_membership 成员关系,登录两步协议支撑)。
+//
+// 依赖 test_helpers_test.go 的 setup/insertFixture;未设置 UNIFIED_TEST_DSN 时跳过。
+package repository
+
+import (
+	"context"
+	stderrors "errors"
+	"testing"
+
+	apperrors "yuansiwei/unified-backend/internal/infra/errors"
+)
+
+// TestMembershipRepo_ListByCanonicalUserID 多 membership 按 created_at 升序 / 无 membership 返非 nil 空切片。
+func TestMembershipRepo_ListByCanonicalUserID(t *testing.T) {
+	gdb := setup(t)
+	ctx := context.Background()
+	fx := insertFixture(t, gdb, ctx)
+	repo := NewMembershipRepo(gdb)
+
+	t.Run("命中_按created_at升序", func(t *testing.T) {
+		list, err := repo.ListByCanonicalUserID(ctx, fx.canonical)
+		if err != nil {
+			t.Fatalf("ListByCanonicalUserID: %v", err)
+		}
+		if len(list) != 3 {
+			t.Fatalf("期望 3 条 membership,实际 %d", len(list))
+		}
+		// created_at 升序:instB(academic,-30s) → instA(teacher,-20s) → instA(finance,-10s)
+		if list[0].InstitutionID != fx.instB || list[0].Role != "academic" {
+			t.Errorf("第 1 条 = (%d, %s),期望 (%d, academic)", list[0].InstitutionID, list[0].Role, fx.instB)
+		}
+		if list[1].InstitutionID != fx.instA || list[1].Role != "teacher" {
+			t.Errorf("第 2 条 = (%d, %s),期望 (%d, teacher)", list[1].InstitutionID, list[1].Role, fx.instA)
+		}
+		if list[2].InstitutionID != fx.instA || list[2].Role != "finance" {
+			t.Errorf("第 3 条 = (%d, %s),期望 (%d, finance)", list[2].InstitutionID, list[2].Role, fx.instA)
+		}
+	})
+
+	t.Run("无membership_返回非nil空切片", func(t *testing.T) {
+		list, err := repo.ListByCanonicalUserID(ctx, fx.other)
+		if err != nil {
+			t.Fatalf("ListByCanonicalUserID(无 membership 用户): %v", err)
+		}
+		if list == nil {
+			t.Errorf("期望非 nil 空切片(JSON 序列化为 [] 而非 null),实际 nil")
+		}
+		if len(list) != 0 {
+			t.Errorf("期望 0 条,实际 %d", len(list))
+		}
+	})
+}
+
+// TestMembershipRepo_GetByUserInstitutionCampus 命中 / 同作用域多角色取最新 / 未命中。
+func TestMembershipRepo_GetByUserInstitutionCampus(t *testing.T) {
+	gdb := setup(t)
+	ctx := context.Background()
+	fx := insertFixture(t, gdb, ctx)
+	repo := NewMembershipRepo(gdb)
+
+	t.Run("命中_单角色", func(t *testing.T) {
+		// instB 下只有 academic 一条
+		m, err := repo.GetByUserInstitutionCampus(ctx, fx.canonical, fx.instB, 0)
+		if err != nil {
+			t.Fatalf("GetByUserInstitutionCampus(instB): %v", err)
+		}
+		if m.Role != "academic" {
+			t.Errorf("instB role = %q,期望 academic", m.Role)
+		}
+	})
+
+	t.Run("同作用域多角色_取created_at最新", func(t *testing.T) {
+		// instA 下有 teacher(-20s) + finance(-10s),finance 较新 → 应取 finance
+		m, err := repo.GetByUserInstitutionCampus(ctx, fx.canonical, fx.instA, 0)
+		if err != nil {
+			t.Fatalf("GetByUserInstitutionCampus(instA): %v", err)
+		}
+		if m.Role != "finance" {
+			t.Errorf("instA 多角色应取最新(finance),实际 %q", m.Role)
+		}
+	})
+
+	t.Run("未命中_返回ErrNotFound", func(t *testing.T) {
+		// 该用户在 instC(不存在的机构)下无 membership
+		instC := int64(999)
+		_, err := repo.GetByUserInstitutionCampus(ctx, fx.canonical, instC, 0)
+		if !stderrors.Is(err, apperrors.ErrNotFound) {
+			t.Fatalf("期望 ErrNotFound,实际 %v", err)
+		}
+	})
+}

+ 179 - 0
internal/identity/repository/test_helpers_test.go

@@ -0,0 +1,179 @@
+// Package repository 仓储层集成测试(登录主链路数据访问,对齐 T1 DDL + T2 GORM model)。
+//
+// 运行前提(DSN 含密码,不入库,经环境变量注入——同 db_test.go 纪律):
+//
+//	set UNIFIED_TEST_DSN=dev:xxxx@tcp(127.0.0.1:3306)/e2e_test?charset=utf8mb4&parseTime=True&loc=Local
+//	go test ./internal/identity/repository/ -v
+//
+// 未设置 UNIFIED_TEST_DSN 时自动跳过(CI / 无库环境 go test ./... 不受影响)。
+// 测试目标库用 e2e_test(dev 账号有建表/DROP 表权限);测后 DropTable 清理 4 张表
+// (t_user/t_identity/t_membership + 依赖的 t_institution,保留 e2e_test 库本身供 W3 e2e 复用)。
+//
+// 覆盖(User 主表 + 登录身份 + 成员关系,登录两步协议的仓储支撑):
+//   - UserRepo.GetByCanonicalUserID:命中 / 未命中(ErrNotFound);
+//   - UserRepo.UpdatePasswordLazyUpgrade:argon2 重哈希 + salt 置 NULL + hash_version 升级 / 未命中用户;
+//   - IdentityRepo.GetByTypeIdentifier:(type,identifier) 唯一键命中 / 未命中;
+//   - MembershipRepo.ListByCanonicalUserID:多 membership 按 created_at 升序 / 无 membership 返非 nil 空切片;
+//   - MembershipRepo.GetByUserInstitutionCampus:命中 / 同作用域多角色取最新 / 未命中。
+package repository
+
+import (
+	"context"
+	"os"
+	"testing"
+	"time"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/infra/config"
+	infra "yuansiwei/unified-backend/internal/infra/db"
+	"yuansiwei/unified-backend/internal/identity/id"
+	identitymodel "yuansiwei/unified-backend/internal/identity/model"
+	orgmodel "yuansiwei/unified-backend/internal/org/model"
+)
+
+// dsnOrSkip 读 UNIFIED_TEST_DSN,未设置时跳过(CI / 无库环境)。
+// 入参:t *testing.T。出参:string(DSN,非空)。
+func dsnOrSkip(t *testing.T) string {
+	t.Helper()
+	dsn := os.Getenv("UNIFIED_TEST_DSN")
+	if dsn == "" {
+		t.Skip("未设置 UNIFIED_TEST_DSN,跳过 identity 仓储集成测试")
+	}
+	return dsn
+}
+
+// strPtr 返回字符串指针(构造可空列测试数据用)。
+// 入参:s string。出参:*string。
+func strPtr(s string) *string { return &s }
+
+// setup 建 e2e_test 库连接 + AutoMigrate 4 表(幂等)+ 测后 DropTable 清理。
+// 入参:t *testing.T。出参:*gorm.DB(已就绪句柄;失败 Fatalf)。
+func setup(t *testing.T) *gorm.DB {
+	t.Helper()
+	dsn := dsnOrSkip(t)
+	cfg := config.DBCfg{DSN: dsn, MaxOpenConns: 5, MaxIdleConns: 2, ConnMaxLifetimeSec: 60}
+	gdb, err := infra.NewDB(cfg)
+	if err != nil {
+		t.Fatalf("NewDB: %v", err)
+	}
+	t.Cleanup(func() { _ = infra.Close(gdb) })
+	// 4 表:t_user / t_identity(identity 域)+ t_membership / t_institution(org 域,membership 引用机构)
+	if err := infra.Migrate(gdb, &identitymodel.User{}, &identitymodel.Identity{},
+		&orgmodel.Membership{}, &orgmodel.Institution{}); err != nil {
+		t.Fatalf("Migrate: %v", err)
+	}
+	t.Cleanup(func() {
+		m := gdb.Migrator()
+		_ = m.DropTable("t_user", "t_identity", "t_membership", "t_institution")
+	})
+	return gdb
+}
+
+// fixture 登录主链路测试数据(1 用户 + 2 身份 + 3 成员关系,含同作用域双角色场景)。
+// canonical 用 id 包生成合法 26 字符 ULID(非硬编码,规避 Validate 规则)。
+type fixture struct {
+	canonical string // 被测用户 ULID
+	other     string // 干扰用户 ULID(验证"无 membership 返空切片"路径)
+	hashOld   string // 旧 md5_salt 哈希(惰性升级前)
+	hashNew   string // argon2 新哈希(惰性升级后)
+	phone     string // phone 身份 identifier
+	email     string // email 身份 identifier
+	instA     int64  // 机构 A ID
+	instB     int64  // 机构 B ID
+}
+
+// insertFixture 在 setup 好的库中落 1 用户 + 2 身份 + 3 成员关系(raw SQL 显式 created_at 保证排序确定)。
+// 入参:t *testing.T;gdb *gorm.DB;ctx context.Context。出参:fixture(已插入,调用方可断言)。
+func insertFixture(t *testing.T, gdb *gorm.DB, ctx context.Context) fixture {
+	t.Helper()
+
+	canonical, err := id.NewCanonicalUserID()
+	if err != nil {
+		t.Fatalf("生成 canonical user id: %v", err)
+	}
+	other, err := id.NewCanonicalUserID()
+	if err != nil {
+		t.Fatalf("生成 other user id: %v", err)
+	}
+	now := time.Now()
+
+	hashOld := "0f8d5d58d0a21f8d5d58d0a21f8d5d58" // 模拟 md5(password+salt) 32 位
+	hashNew := "$argon2id$v=19$m=19,t=2,p=1$c29tZXNhbHR2YWx1ZQ$abcDEF123xyz" // 模拟 argon2 PHC 串
+	phone := "13800000001"
+	email := "TestUser@example.com"
+	instA := int64(1)
+	instB := int64(2)
+
+	// 用户主表(密码三列走 md5_salt 旧版本,供惰性升级测试)
+	if err := gdb.WithContext(ctx).
+		Create(&identitymodel.User{
+			CanonicalUserID: canonical,
+			PersonType:      identitymodel.PersonTypeStaff,
+			PasswordHash:    &hashOld,
+			PasswordSalt:    strPtr("legacy_salt"),
+			HashVersion:     strPtr(identitymodel.HashVersionMd5Salt),
+			Status:          identitymodel.UserStatusActive,
+			CreatedAt:       now,
+			UpdatedAt:       now,
+		}).Error; err != nil {
+		t.Fatalf("插入 t_user: %v", err)
+	}
+	// 干扰用户(验证 ListByCanonicalUserID 对"无 membership 用户"返非 nil 空切片)
+	if err := gdb.WithContext(ctx).
+		Create(&identitymodel.User{
+			CanonicalUserID: other,
+			PersonType:      identitymodel.PersonTypeStudent,
+			Status:          identitymodel.UserStatusActive,
+			CreatedAt:       now,
+			UpdatedAt:       now,
+		}).Error; err != nil {
+		t.Fatalf("插入干扰 t_user: %v", err)
+	}
+
+	// 身份表:同 canonical 挂 phone + email 两身份(一人多登录方式,(type,identifier) 唯一)
+	for i, it := range []identitymodel.Identity{
+		{CanonicalUserID: canonical, Type: identitymodel.IdentityTypePhone,
+			Identifier: phone, Verified: true, CreatedAt: now},
+		{CanonicalUserID: canonical, Type: identitymodel.IdentityTypeEmail,
+			Identifier: email, Verified: false, CreatedAt: now.Add(time.Second)},
+	} {
+		if err := gdb.WithContext(ctx).Create(&it).Error; err != nil {
+			t.Fatalf("插入 t_identity[%d]: %v", i, err)
+		}
+	}
+
+	// 成员关系表:raw SQL 显式 created_at(秒级间隔,保证 created_at 排序确定)。
+	// 同 (canonical, instA) 下 M1/M2 两角色(created_at 不同)→ 验证"同作用域多角色取最新"。
+	// M3 在 instB → 验证"ListByCanonicalUserID 按 created_at 升序"(M3 < M1 < M2)。
+	memberships := []struct {
+		inst    int64
+		role    string
+		created time.Time
+	}{
+		{inst: instB, role: "academic", created: now.Add(-30 * time.Second)},
+		{inst: instA, role: "teacher", created: now.Add(-20 * time.Second)},
+		{inst: instA, role: "finance", created: now.Add(-10 * time.Second)},
+	}
+	insertMembershipSQL := `INSERT INTO t_membership
+		(canonical_user_id, institution_id, campus_id, role, status, joined_at, created_at)
+		VALUES (?, ?, 0, ?, 'active', ?, ?)`
+	for i, m := range memberships {
+		// 5 个占位符:canonical_user_id, institution_id, role, joined_at, created_at(joined_at 与 created_at 同值)
+		if err := gdb.WithContext(ctx).
+			Exec(insertMembershipSQL, canonical, m.inst, m.role, m.created, m.created).Error; err != nil {
+			t.Fatalf("插入 t_membership[%d]: %v", i, err)
+		}
+	}
+
+	return fixture{
+		canonical: canonical,
+		other:     other,
+		hashOld:   hashOld,
+		hashNew:   hashNew,
+		phone:     phone,
+		email:     email,
+		instA:     instA,
+		instB:     instB,
+	}
+}

+ 122 - 0
internal/identity/repository/user_repo.go

@@ -0,0 +1,122 @@
+// Package repository 身份域仓储(登录/用户/成员关系 数据访问,对齐 model 包 T2 实体)。
+//
+// 平台级访问(关键设计决策):
+//   - 身份域的数据(t_user/t_identity/t_membership)是**平台级全局数据**,不绑多租户作用域——
+//     登录时"这个手机号是谁"是跨机构的全局查询,不能先有作用域再查(作用域本身由登录结果产生)。
+//     故本域 Repository **不继承 scope.BaseRepo**、不做 scoping 强制过滤,直接用裸 *gorm.DB。
+//   - 多租户防线由**消费方**负责:登录成功后 scope 注入(token claims 带 institution_id/campus_id),
+//     后续业务域(academic/teaching)的 Repository 才继承 BaseRepo 做 scoping。
+//   - 这是"平台级元数据"的标准处置(对齐 P0-11 收敛规则 7:平台级表不进 scoping)。
+//
+// 错误转换纪律(架构规格 §3.2 三层错误):
+//   - Repository 把 gorm.ErrRecordNotFound 转成 apperrors.ErrNotFound(不向上泄漏 ORM 原错);
+//     gorm 其它错误(连接/SQL)转成 apperrors.ErrDBError;Service 层只认 sentinel,Handler 映射响应码。
+//
+// 命名说明:本包同时需要标准库 errors(errors.Is 解包判断)与 internal 业务错误包
+// (其包名亦 errors),故 internal 错误包一律用别名 apperrors,标准库用 stderrors。
+//
+// 依赖方向:repository → model(实体)+ apperrors + gorm;不依赖 service/handler(纯数据访问)。
+package repository
+
+import (
+	"context"
+	stderrors "errors"
+	"fmt"
+
+	"gorm.io/gorm"
+
+	apperrors "yuansiwei/unified-backend/internal/infra/errors"
+	"yuansiwei/unified-backend/internal/identity/model"
+)
+
+// mapGormErr 把 gorm 层错误转成业务 sentinel(Repository 统一出口,禁止泄漏 ORM 原错)。
+//
+// 入参:err *gorm.DB 的 Error 字段(或任意 gorm 调用返回的 error)。
+// 出参:*apperrors.AppError:
+//   - gorm.ErrRecordNotFound → apperrors.ErrNotFound(3001 记录不存在);
+//   - 其它(连接/SQL/事务)→ apperrors.ErrDBError(5002 数据库异常)。
+//
+// 说明:保留原 err 作 Cause(WithCause),便于日志排障(Cause 不进响应体)。
+// 入参为 nil 时返回 nil(调用方传 nil 视为"无错误",防御性处理)。
+func mapGormErr(err error) *apperrors.AppError {
+	switch {
+	case err == nil:
+		return nil
+	case stderrors.Is(err, gorm.ErrRecordNotFound):
+		return apperrors.WithCause(apperrors.ErrNotFound, err)
+	default:
+		return apperrors.WithCause(apperrors.ErrDBError, err)
+	}
+}
+
+// UserRepo 用户主表仓储(t_user,平台级全局唯一,跨机构共享)。
+type UserRepo struct {
+	db *gorm.DB
+}
+
+// NewUserRepo 构造用户仓储。
+//
+// 入参:db *gorm.DB:数据库句柄(infra/db.NewDB 建立的连接池,必填)。
+// 出参:*UserRepo:就绪仓储。
+func NewUserRepo(db *gorm.DB) *UserRepo {
+	return &UserRepo{db: db}
+}
+
+// GetByCanonicalUserID 按 canonical_user_id(ULID)取单个用户。
+//
+// 入参:
+//   - ctx context.Context:请求上下文(透传 GORM 取消/超时)。
+//   - uid string:canonical_user_id(26 字符 ULID;调用方应已校验合法性,此处不重复校验)。
+//
+// 出参:
+//   - *model.User:用户实体(含密码三列——仅登录链路在内存中消费,绝不出 JSON/日志)。
+//   - error:
+//     - apperrors.ErrNotFound:该 ULID 无用户(登录时"账号不存在"统一文案的来源之一,防枚举);
+//     - apperrors.ErrDBError:DB 异常。
+func (r *UserRepo) GetByCanonicalUserID(ctx context.Context, uid string) (*model.User, error) {
+	var u model.User
+	if err := r.db.WithContext(ctx).
+		Where("canonical_user_id = ?", uid).
+		First(&u).Error; err != nil {
+		return nil, mapGormErr(err)
+	}
+	return &u, nil
+}
+
+// UpdatePasswordLazyUpgrade 惰性升级写回(ADR-016 后半段):argon2 重哈希 + hash_version='argon2' + salt 置 NULL。
+//
+// 入参:
+//   - ctx context.Context:请求上下文。
+//   - uid string:目标用户 canonical_user_id。
+//   - newHash string:credential.HashArgon2 产出的 PHC 编码串(salt 已内嵌,故独立 salt 列置 NULL)。
+//
+// 出参:
+//   - error:
+//     - apperrors.ErrNotFound:用户不存在(调用方 bug——升级必在"已登录该用户"之后,理论上不该发生,单测覆盖);
+//     - apperrors.ErrDBError:写失败。
+//
+// 安全纪律:
+//   - 升级是"验旧成功"之后的写回,必须与"撤销该用户 token"成对执行(Service 层保证顺序);
+//   - salt 置 NULL(非空串):argon2 的盐内嵌 PHC 串,独立列保留空串会让 credential.Check 误判"有盐"。
+//
+// 实现说明:用 Table + Updates(map[string]any) 而非 Model(struct)——map 形式允许显式写 NULL
+// (password_salt=nil),struct 形式 GORM 默认跳过零值字段(NULL 写不进去,会留下旧 salt 脏值)。
+// updated_at 显式 gorm.Expr:GORM 的 Updates 不触发 DB 的 ON UPDATE CURRENT_TIMESTAMP,须手动刷。
+func (r *UserRepo) UpdatePasswordLazyUpgrade(ctx context.Context, uid, newHash string) error {
+	res := r.db.WithContext(ctx).
+		Table("t_user").
+		Where("canonical_user_id = ?", uid).
+		Updates(map[string]any{
+			"password_hash": newHash,
+			"password_salt": nil, // 显式 NULL(argon2 盐内嵌 PHC 串)
+			"hash_version":  model.HashVersionArgon2,
+			"updated_at":    gorm.Expr("CURRENT_TIMESTAMP(3)"),
+		})
+	if res.Error != nil {
+		return mapGormErr(res.Error)
+	}
+	if res.RowsAffected == 0 {
+		return apperrors.WithCause(apperrors.ErrNotFound, fmt.Errorf("repository: 惰性升级未命中用户 %s", uid))
+	}
+	return nil
+}

+ 92 - 0
internal/identity/repository/user_repo_test.go

@@ -0,0 +1,92 @@
+// UserRepo 仓储集成测试(t_user 主表查询 + 密码惰性升级写回)。
+//
+// 依赖 test_helpers_test.go 的 setup/insertFixture;未设置 UNIFIED_TEST_DSN 时跳过。
+package repository
+
+import (
+	"context"
+	stderrors "errors"
+	"testing"
+
+	apperrors "yuansiwei/unified-backend/internal/infra/errors"
+	"yuansiwei/unified-backend/internal/identity/id"
+	identitymodel "yuansiwei/unified-backend/internal/identity/model"
+)
+
+// TestUserRepo_GetByCanonicalUserID 命中 / 未命中。
+func TestUserRepo_GetByCanonicalUserID(t *testing.T) {
+	gdb := setup(t)
+	ctx := context.Background()
+	fx := insertFixture(t, gdb, ctx)
+	repo := NewUserRepo(gdb)
+
+	t.Run("命中", func(t *testing.T) {
+		u, err := repo.GetByCanonicalUserID(ctx, fx.canonical)
+		if err != nil {
+			t.Fatalf("GetByCanonicalUserID 命中: %v", err)
+		}
+		if u.CanonicalUserID != fx.canonical {
+			t.Errorf("返回 canonical_user_id = %q,期望 %q", u.CanonicalUserID, fx.canonical)
+		}
+		if u.Status != identitymodel.UserStatusActive {
+			t.Errorf("status = %q,期望 %q", u.Status, identitymodel.UserStatusActive)
+		}
+	})
+
+	t.Run("未命中_返回ErrNotFound", func(t *testing.T) {
+		_, err := repo.GetByCanonicalUserID(ctx, "00000000000000000000000000")
+		if !stderrors.Is(err, apperrors.ErrNotFound) {
+			t.Fatalf("期望 ErrNotFound,实际 %v", err)
+		}
+	})
+}
+
+// TestUserRepo_UpdatePasswordLazyUpgrade 惰性升级写回(argon2 + salt NULL + hash_version)/ 未命中用户。
+func TestUserRepo_UpdatePasswordLazyUpgrade(t *testing.T) {
+	gdb := setup(t)
+	ctx := context.Background()
+	fx := insertFixture(t, gdb, ctx)
+	repo := NewUserRepo(gdb)
+
+	t.Run("命中_升级写回argon2_salt置NULL", func(t *testing.T) {
+		// 升级前确认旧值
+		u, err := repo.GetByCanonicalUserID(ctx, fx.canonical)
+		if err != nil {
+			t.Fatalf("升级前取用户: %v", err)
+		}
+		if u.PasswordHash == nil || *u.PasswordHash != fx.hashOld {
+			t.Fatalf("升级前 password_hash = %v,期望 %q", u.PasswordHash, fx.hashOld)
+		}
+
+		// 升级写回
+		if err := repo.UpdatePasswordLazyUpgrade(ctx, fx.canonical, fx.hashNew); err != nil {
+			t.Fatalf("UpdatePasswordLazyUpgrade: %v", err)
+		}
+
+		// 断言升级后三列
+		u2, err := repo.GetByCanonicalUserID(ctx, fx.canonical)
+		if err != nil {
+			t.Fatalf("升级后取用户: %v", err)
+		}
+		if u2.PasswordHash == nil || *u2.PasswordHash != fx.hashNew {
+			t.Errorf("升级后 password_hash = %v,期望 %q", u2.PasswordHash, fx.hashNew)
+		}
+		if u2.PasswordSalt != nil {
+			t.Errorf("升级后 password_salt 应为 NULL,实际 %v", *u2.PasswordSalt)
+		}
+		if u2.HashVersion == nil || *u2.HashVersion != identitymodel.HashVersionArgon2 {
+			t.Errorf("升级后 hash_version = %v,期望 argon2", u2.HashVersion)
+		}
+	})
+
+	t.Run("未命中用户_返回ErrNotFound", func(t *testing.T) {
+		// 库里不存在该 ULID 的用户(RowsAffected=0 → ErrNotFound)
+		ghost, err := id.NewCanonicalUserID()
+		if err != nil {
+			t.Fatalf("生成 ghost uid: %v", err)
+		}
+		if err := repo.UpdatePasswordLazyUpgrade(ctx, ghost, fx.hashNew); !stderrors.Is(err, apperrors.ErrNotFound) {
+			t.Fatalf("期望 ErrNotFound,实际 %v", err)
+		}
+	})
+}

+ 36 - 15
internal/infra/errors/errors.go

@@ -29,6 +29,9 @@ import "fmt"
 //   - Code int:分段错误码(见包注释的 1xxx~5xxx 分段表)。
 //   - Msg  string:面向用户的错误文案(脱敏,不含 SQL/堆栈/哈希)。
 //   - Cause error:底层原因(可选);用于日志排障,绝不进入响应体。
+//   - base *AppError(非导出):WithCause 时的"源 sentinel"回引用——
+//     使 WithCause(ErrNotFound, gormErr) 仍能被 errors.Is(err, ErrNotFound) 命中
+//     (见 Unwrap 说明)。New(code,msg) 创建的自定义错误 base 为 nil。
 //
 // 使用约束:
 //   - 业务域通常直接复用本包提供的 sentinel(ErrNotFound 等);
@@ -38,6 +41,9 @@ type AppError struct {
 	Code  int
 	Msg   string
 	Cause error
+	// base 指向"被 WithCause 附挂底层原因的源 sentinel"(如 ErrNotFound)。
+	// 仅内部用于 Unwrap 的解包链,导出接口/JSON 均不感知。
+	base *AppError
 }
 
 // Error 实现 error 接口。
@@ -54,18 +60,31 @@ func (e *AppError) Error() string {
 	return e.Msg
 }
 
-// Unwrap 暴露底层原因,支持 errors.Is / errors.As 逐层解包。
+// Unwrap 暴露底层原因链,支持 errors.Is / errors.As 逐层解包(Go 1.20+ 多错误解包)。
 //
-// 入参:无。
+// 入参:无(指针接收在方法集上,调用者无需关心)。
 // 出参:
-//   - error:Cause(可能为 nil)。
-//
-// 样例:
-//
-//	err := WithCause(ErrNotFound, fmt.Errorf("sql: no rows in result set"))
-//	errors.Is(err, ErrNotFound) // true
-func (e *AppError) Unwrap() error {
-	return e.Cause
+//   - []error:解包链,按序含 Cause 与 base(源 sentinel);去重且过滤 nil 后返回。
+//     空切片表示"无底层可解包"(既无 Cause 也无 base,如 New 创建的自定义错误)。
+//
+// 关键设计(使 errors.Is 命中 WithCause 的源 sentinel):
+//   WithCause(ErrNotFound, gormErr) 返回的 *AppError 是 ErrNotFound 的**副本**(Code/Msg 拷贝),
+//   与 ErrNotFound 指针不同。若 Unwrap 只回 Cause(gormErr),errors.Is(err, ErrNotFound) 会
+//   走 err→gormErr→nil,永远找不到 ErrNotFound。故这里把 base(源 sentinel 指针)一并解出:
+//
+//	err := WithCause(ErrNotFound, fmt.Errorf("sql: no rows"))
+//	err.Unwrap()               // [gormErr, ErrNotFound]
+//	errors.Is(err, ErrNotFound) // true(命中 base)
+func (e *AppError) Unwrap() []error {
+	// 去重:若 base 与 Cause 恰好是同一 *AppError(调用方把 sentinel 本身当 cause 传入),只保留一份。
+	var chain []error
+	if e.Cause != nil {
+		chain = append(chain, e.Cause)
+	}
+	if e.base != nil && e.base != e.Cause {
+		chain = append(chain, e.base)
+	}
+	return chain
 }
 
 // New 创建一个新的 AppError(自定义 code 与 msg)。
@@ -94,19 +113,21 @@ func New(code int, msg string) *AppError {
 //   - cause error:底层原因(ORM 原错/第三方错误);为 nil 时直接返回 base 的副本。
 //
 // 出参:
-//   - *AppError:Code/Msg 继承自 base,Cause 置为 cause(不修改入参 base,避免共享污染)。
+//   - *AppError:Code/Msg 继承自 base,Cause 置为 cause,base 回引用指向入参 base
+//     (不修改入参 base,避免共享污染;回引用使 errors.Is 能命中源 sentinel,见 Unwrap)。
 //
 // 异常场景:
-//   - base 为 nil:返回 ErrInternal 的副本并挂上 cause(防御调用方传错)。
+//   - base 为 nil:返回 ErrInternal 的副本并挂上 cause(base 回引用指向 ErrInternal,防御调用方传错)。
 //
 // 样例:
 //
-//	return errors.WithCause(errors.ErrConflict, dbErr)
+//	err := errors.WithCause(errors.ErrConflict, dbErr)
+//	stderrors.Is(err, errors.ErrConflict) // true
 func WithCause(base *AppError, cause error) *AppError {
 	if base == nil {
-		return &AppError{Code: ErrInternal.Code, Msg: ErrInternal.Msg, Cause: cause}
+		return &AppError{Code: ErrInternal.Code, Msg: ErrInternal.Msg, Cause: cause, base: ErrInternal}
 	}
-	return &AppError{Code: base.Code, Msg: base.Msg, Cause: cause}
+	return &AppError{Code: base.Code, Msg: base.Msg, Cause: cause, base: base}
 }
 
 // 分段错误码常量(与 R.Code 对齐;新接口一律引用这些常量,禁止散落 magic number)。

+ 63 - 0
internal/infra/errors/errors_test.go

@@ -0,0 +1,63 @@
+// errors 包单测(纯逻辑,无 DB/外部依赖)。
+//
+// 重点覆盖 WithCause 的解包链契约:
+//   WithCause(sentinel, cause) 返回的错误必须能被 errors.Is 命中**源 sentinel**,
+//   同时底层 cause 仍可解出。这是 T4-2 仓储层实证暴露的 latent bug 的回归保护
+//   (旧实现 Unwrap 只回 Cause,sentinel 副本被拷贝后丢失,errors.Is 永远 false)。
+//
+// 包名说明:本文件位于 internal/infra/errors 包内,与包自身同名,故标准库 errors
+// 一律用别名 stderrors,避免与包内符号(ErrNotFound/WithCause 等)混淆。
+package errors
+
+import (
+	stderrors "errors"
+	"fmt"
+	"testing"
+)
+
+// TestWithCause_IsHitsBaseSentinel WithCause 的错误必须 errors.Is 命中源 sentinel。
+func TestWithCause_IsHitsBaseSentinel(t *testing.T) {
+	cause := fmt.Errorf("sql: no rows in result set")
+	wrapped := WithCause(ErrNotFound, cause)
+
+	// 源 sentinel 必须可被 errors.Is 命中(Service/Handler 层依赖此契约做分支)
+	if !stderrors.Is(wrapped, ErrNotFound) {
+		t.Errorf("errors.Is(WithCause(ErrNotFound, cause), ErrNotFound) = false,期望 true")
+	}
+	// 底层 cause 仍可达(日志排障链路不断)
+	if !stderrors.Is(wrapped, cause) {
+		t.Errorf("errors.Is(WithCause, cause) = false,底层 cause 丢失")
+	}
+}
+
+// TestWithCause_NilBase 兜底:base 为 nil 时回退 ErrInternal 且仍可命中。
+func TestWithCause_NilBase(t *testing.T) {
+	cause := fmt.Errorf("boom")
+	wrapped := WithCause(nil, cause)
+	if wrapped.Code != ErrInternal.Code {
+		t.Errorf("base=nil 应回退 ErrInternal code=%d,实际 %d", ErrInternal.Code, wrapped.Code)
+	}
+	if !stderrors.Is(wrapped, ErrInternal) {
+		t.Errorf("errors.Is(WithCause(nil, cause), ErrInternal) = false,期望 true")
+	}
+}
+
+// TestNew_NoBase New 创建的自定义错误:无 base、无 cause,Unwrap 返回空链。
+func TestNew_NoBase(t *testing.T) {
+	e := New(3002, "该手机号已被绑定")
+	if e.Code != 3002 || e.Msg != "该手机号已被绑定" {
+		t.Errorf("New 字段异常: code=%d msg=%q", e.Code, e.Msg)
+	}
+	if got := e.Unwrap(); len(got) != 0 {
+		t.Errorf("New 创建的错误 Unwrap 应返回空链,实际 %v", got)
+	}
+}
+
+// TestWithCause_CauseAndBaseSame 去重:把 sentinel 本身当 cause 传入,解包链只保留一份。
+func TestWithCause_CauseAndBaseSame(t *testing.T) {
+	wrapped := WithCause(ErrConflict, ErrConflict) // cause == base(同一 *AppError)
+	chain := wrapped.Unwrap()
+	if len(chain) != 1 {
+		t.Errorf("cause==base 应去重为 1 项,实际 %d 项", len(chain))
+	}
+}