// 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 }