user_repo.go 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. // Package repository 身份域仓储(登录/用户/成员关系 数据访问,对齐 model 包 T2 实体)。
  2. //
  3. // 平台级访问(关键设计决策):
  4. // - 身份域的数据(t_user/t_identity/t_membership)是**平台级全局数据**,不绑多租户作用域——
  5. // 登录时"这个手机号是谁"是跨机构的全局查询,不能先有作用域再查(作用域本身由登录结果产生)。
  6. // 故本域 Repository **不继承 scope.BaseRepo**、不做 scoping 强制过滤,直接用裸 *gorm.DB。
  7. // - 多租户防线由**消费方**负责:登录成功后 scope 注入(token claims 带 institution_id/campus_id),
  8. // 后续业务域(academic/teaching)的 Repository 才继承 BaseRepo 做 scoping。
  9. // - 这是"平台级元数据"的标准处置(对齐 P0-11 收敛规则 7:平台级表不进 scoping)。
  10. //
  11. // 错误转换纪律(架构规格 §3.2 三层错误):
  12. // - Repository 把 gorm.ErrRecordNotFound 转成 apperrors.ErrNotFound(不向上泄漏 ORM 原错);
  13. // gorm 其它错误(连接/SQL)转成 apperrors.ErrDBError;Service 层只认 sentinel,Handler 映射响应码。
  14. //
  15. // 命名说明:本包同时需要标准库 errors(errors.Is 解包判断)与 internal 业务错误包
  16. // (其包名亦 errors),故 internal 错误包一律用别名 apperrors,标准库用 stderrors。
  17. //
  18. // 依赖方向:repository → model(实体)+ apperrors + gorm;不依赖 service/handler(纯数据访问)。
  19. package repository
  20. import (
  21. "context"
  22. stderrors "errors"
  23. "fmt"
  24. "gorm.io/gorm"
  25. apperrors "yuansiwei/unified-backend/internal/infra/errors"
  26. "yuansiwei/unified-backend/internal/identity/model"
  27. )
  28. // mapGormErr 把 gorm 层错误转成业务 sentinel(Repository 统一出口,禁止泄漏 ORM 原错)。
  29. //
  30. // 入参:err *gorm.DB 的 Error 字段(或任意 gorm 调用返回的 error)。
  31. // 出参:*apperrors.AppError:
  32. // - gorm.ErrRecordNotFound → apperrors.ErrNotFound(3001 记录不存在);
  33. // - 其它(连接/SQL/事务)→ apperrors.ErrDBError(5002 数据库异常)。
  34. //
  35. // 说明:保留原 err 作 Cause(WithCause),便于日志排障(Cause 不进响应体)。
  36. // 入参为 nil 时返回 nil(调用方传 nil 视为"无错误",防御性处理)。
  37. func mapGormErr(err error) *apperrors.AppError {
  38. switch {
  39. case err == nil:
  40. return nil
  41. case stderrors.Is(err, gorm.ErrRecordNotFound):
  42. return apperrors.WithCause(apperrors.ErrNotFound, err)
  43. default:
  44. return apperrors.WithCause(apperrors.ErrDBError, err)
  45. }
  46. }
  47. // UserRepo 用户主表仓储(t_user,平台级全局唯一,跨机构共享)。
  48. type UserRepo struct {
  49. db *gorm.DB
  50. }
  51. // NewUserRepo 构造用户仓储。
  52. //
  53. // 入参:db *gorm.DB:数据库句柄(infra/db.NewDB 建立的连接池,必填)。
  54. // 出参:*UserRepo:就绪仓储。
  55. func NewUserRepo(db *gorm.DB) *UserRepo {
  56. return &UserRepo{db: db}
  57. }
  58. // GetByCanonicalUserID 按 canonical_user_id(ULID)取单个用户。
  59. //
  60. // 入参:
  61. // - ctx context.Context:请求上下文(透传 GORM 取消/超时)。
  62. // - uid string:canonical_user_id(26 字符 ULID;调用方应已校验合法性,此处不重复校验)。
  63. //
  64. // 出参:
  65. // - *model.User:用户实体(含密码三列——仅登录链路在内存中消费,绝不出 JSON/日志)。
  66. // - error:
  67. // - apperrors.ErrNotFound:该 ULID 无用户(登录时"账号不存在"统一文案的来源之一,防枚举);
  68. // - apperrors.ErrDBError:DB 异常。
  69. func (r *UserRepo) GetByCanonicalUserID(ctx context.Context, uid string) (*model.User, error) {
  70. var u model.User
  71. if err := r.db.WithContext(ctx).
  72. Where("canonical_user_id = ?", uid).
  73. First(&u).Error; err != nil {
  74. return nil, mapGormErr(err)
  75. }
  76. return &u, nil
  77. }
  78. // UpdatePasswordLazyUpgrade 惰性升级写回(ADR-016 后半段):argon2 重哈希 + hash_version='argon2' + salt 置 NULL。
  79. //
  80. // 入参:
  81. // - ctx context.Context:请求上下文。
  82. // - uid string:目标用户 canonical_user_id。
  83. // - newHash string:credential.HashArgon2 产出的 PHC 编码串(salt 已内嵌,故独立 salt 列置 NULL)。
  84. //
  85. // 出参:
  86. // - error:
  87. // - apperrors.ErrNotFound:用户不存在(调用方 bug——升级必在"已登录该用户"之后,理论上不该发生,单测覆盖);
  88. // - apperrors.ErrDBError:写失败。
  89. //
  90. // 安全纪律:
  91. // - 升级是"验旧成功"之后的写回,必须与"撤销该用户 token"成对执行(Service 层保证顺序);
  92. // - salt 置 NULL(非空串):argon2 的盐内嵌 PHC 串,独立列保留空串会让 credential.Check 误判"有盐"。
  93. //
  94. // 实现说明:用 Table + Updates(map[string]any) 而非 Model(struct)——map 形式允许显式写 NULL
  95. // (password_salt=nil),struct 形式 GORM 默认跳过零值字段(NULL 写不进去,会留下旧 salt 脏值)。
  96. // updated_at 显式 gorm.Expr:GORM 的 Updates 不触发 DB 的 ON UPDATE CURRENT_TIMESTAMP,须手动刷。
  97. func (r *UserRepo) UpdatePasswordLazyUpgrade(ctx context.Context, uid, newHash string) error {
  98. res := r.db.WithContext(ctx).
  99. Table("t_user").
  100. Where("canonical_user_id = ?", uid).
  101. Updates(map[string]any{
  102. "password_hash": newHash,
  103. "password_salt": nil, // 显式 NULL(argon2 盐内嵌 PHC 串)
  104. "hash_version": model.HashVersionArgon2,
  105. "updated_at": gorm.Expr("CURRENT_TIMESTAMP(3)"),
  106. })
  107. if res.Error != nil {
  108. return mapGormErr(res.Error)
  109. }
  110. if res.RowsAffected == 0 {
  111. return apperrors.WithCause(apperrors.ErrNotFound, fmt.Errorf("repository: 惰性升级未命中用户 %s", uid))
  112. }
  113. return nil
  114. }