// Package repository academic 域唯一数据访问层(多租户表继承 BaseRepo 强制 scoping)。 // // 依据:unified/03-migration/academic-domain-design.md §1.4(事务模型)/ §1.7(scoping 与两条例外) // / §10.5(BaseRepo 继承方式)。 // // 分层纪律(依赖方向单向:repository → model + infra/scope + infra/errors): // - **repository 不开事务**:一律接收 `tx *gorm.DB` 参数(事务边界只在 service 层,设计 §10.3); // - **repository 不返回 gorm 原错**:统一转 infra/errors 的 sentinel(ErrNotFound/ErrDBError); // - 多租户表(带 institution_id/campus_id)必须走 `Scoped(db)`(禁止裸 r.DB.Where); // - 无作用域表(t_user_expense 全局钱包 / t_user_divide_into* 全局推广体系)**不继承 BaseRepo**, // 作用域断言在 Service 层经 port.MembershipChecker 完成(设计 §1.7 两条例外)。 // // 本文件只提供"共享内核"能力:BaseRepo(scoping)、TxRunner(事务透传)、分页钳制、唯一键撞车识别。 package repository import ( "context" stderrors "errors" "strings" "gorm.io/gorm" infraerrors "yuansiwei/unified-backend/internal/infra/errors" appscope "yuansiwei/unified-backend/internal/infra/scope" ) // BaseRepo 多租户仓储基类:持有 DB 句柄 + 本次请求作用域,提供强制 scoping 入口。 // // 字段: // - DB *gorm.DB:数据库句柄(连接池由 infra/db 统一建立)。 // - Sc *appscope.Scope:请求作用域(Auth 中间件解析 token 后注入;nil → Scoped panic fail-fast)。 // - CampusScoped bool:该仓储查询的表是否含 campus_id 列(机构级资源置 false,避免 Unknown column)。 // // 使用方式(设计 §10.5): // // type ExpenseSchoolRepo struct{ *repository.BaseRepo } // func NewExpenseSchoolRepo(db *gorm.DB, sc *appscope.Scope) *ExpenseSchoolRepo { // r := &ExpenseSchoolRepo{BaseRepo: NewBaseRepo(db, sc)} // r.CampusScoped = true // 表带 campus_id // return r // } // // 查询:r.Scoped(r.WithContext(ctx)).Where("user_id = ?", uid) // // 事务内:r.Scoped(tx).Where(...) type BaseRepo struct { DB *gorm.DB Sc *appscope.Scope CampusScoped bool } // NewBaseRepo 构造 academic 仓储基类(默认按"表带 campus_id"处理,机构级资源由子类置 false)。 // // 入参: // - db *gorm.DB:数据库句柄(必填;nil 时任何查询都会 panic,属配置错误)。 // - sc *appscope.Scope:请求作用域;系统任务/割接脚本可传 nil,但一旦查询即 panic(fail-fast)。 // // 出参:*BaseRepo——指针类型(便于子类内嵌后修改 CampusScoped)。 func NewBaseRepo(db *gorm.DB, sc *appscope.Scope) *BaseRepo { return &BaseRepo{DB: db, Sc: sc, CampusScoped: true} } // WithContext 绑定请求上下文(仅处理 ctx 传递,**不**注入作用域——作用域永远显式走 Scoped)。 // // 入参:ctx context.Context——标准请求上下文(取消/超时传播)。 // 出参:*gorm.DB——已 WithContext 的句柄(调用方必须再经 Scoped 注入租户过滤)。 func (r *BaseRepo) WithContext(ctx context.Context) *gorm.DB { if r == nil || r.DB == nil { return nil } if ctx == nil { return r.DB } return r.DB.WithContext(ctx) } // Scoped 把作用域过滤条件注入查询(academic 域多租户防线唯一收口点)。 // // 入参: // - db *gorm.DB:基础查询(通常 `r.WithContext(ctx)` 或事务内 `tx`);nil 时返回 nil。 // // 出参: // - *gorm.DB:追加 `institution_id` / `campus_id` 过滤后的查询(链式安全,不改原 db)。 // // 过滤规则(对齐 infra/scope.BaseRepo.Scoping 语义): // 1. Sc 为 nil → **panic**(无作用域上下文进入数据访问层 = 配置错误;HTTP 层 Recover 兜底 5001); // 2. Sc.SuperAdmin=true → 不加机构/校区过滤(平台超管跨机构视角); // 3. 普通用户 → 强制 `institution_id = Sc.InstitutionID`; // 仅当 `CampusScoped=true 且 Sc.CampusID>0` 时再强制 `campus_id = Sc.CampusID`。 // // 安全红线:本方法**不允许**被绕过;资金/活动/课程表的任何读写都必须经它(代码评审逐处检查)。 func (r *BaseRepo) Scoped(db *gorm.DB) *gorm.DB { if db == nil { return nil } if r == nil || r.Sc == nil { // 纵深防御:无作用域上下文禁止进入数据访问层(与 infra/scope 同口径 fail-fast) panic(errNoScope.Error()) } if r.Sc.SuperAdmin { return db // 超管跨机构,不加作用域 } db = db.Where("institution_id = ?", r.Sc.InstitutionID) if r.CampusScoped && r.Sc.CampusID > 0 { db = db.Where("campus_id = ?", r.Sc.CampusID) } return db } // GetScopedID 便捷读取当前作用域机构/校区(供 Service 组装写入数据的冗余作用域列)。 // // 出参:(institutionID, campusID int64);Sc 为 nil 时返回 (0, 0)(调用方须自行拒绝该情况)。 func (r *BaseRepo) GetScopedID() (institutionID, campusID int64) { if r == nil || r.Sc == nil { return 0, 0 } return r.Sc.InstitutionID, r.Sc.CampusID } // ErrNoScope 无作用域上下文的哨兵错误(Scoped panic 文案;导出供单测断言)。 var ErrNoScope = errString("repository: missing tenant scope, refusing unsoped query") // errNoScope 包内别名(与 ErrNoScope 同值,避免调用点改名扩散)。 var errNoScope = ErrNoScope // errString 实现 error 的最小字符串类型(不为哨兵错误引入额外依赖)。 type errString string // Error 实现 error 接口。 func (e errString) Error() string { return string(e) } // TxRunner 事务运行器(academic 域唯一事务入口形状,设计 §10.3)。 // // 语义(关键): // - 顶层调用:`DB.Transaction(fn)` 开启事务; // - **已在事务中**(三种透传入口任命中其一):**直接透传,不重复 BEGIN** // ① runner 本身绑定的 DB 已是 tx(`WithTx(tx)` 构造); // ② ctx 中已携带 tx(`WithTxContext(ctx, tx)` 注入,外层事务内继续调用下游 service 时用); // ③ fn 内部再次调用 WithinTx 且显式传了 tx 绑定的 runner; // ——避免 savepoint 语义混乱与"内层提交早于外层"的隐性 bug; // - fn 返回 *infraerrors.AppError 原样上抛(业务错误不包装),其余错误统一转 ErrDBError(5002)。 // // 禁止(设计 §10.3):Service 内嵌套 `db.Transaction`、Repository 自己开事务、事务内调外部 HTTP/Redis。 // // 典型用法: // // // 顶层 // runner.WithinTx(ctx, func(tx *gorm.DB) error { ... }) // // 事务内继续调用会"再开事务"的下游 service:把 tx 注入 ctx,下游自动透传 // runner.WithinTx(ctx, func(tx *gorm.DB) error { // return recommendSvc.Settle(WithTxContext(ctx, tx), in) // 内部 WithinTx 命中透传 // }) type TxRunner struct { DB *gorm.DB } // NewTxRunner 构造事务运行器。 // // 入参:db *gorm.DB——连接池句柄(必填)。出参:*TxRunner(DB 为 nil 时 WithinTx 返回 ErrInternal)。 func NewTxRunner(db *gorm.DB) *TxRunner { return &TxRunner{DB: db} } // WithTx 返回绑定到指定事务句柄的 runner(事务内调用下游方法时用,保证透传同一 tx)。 // // 入参:tx *gorm.DB——事务句柄。出参:*TxRunner(DB = tx;后续 WithinTx 直接透传,不再 BEGIN)。 func (t *TxRunner) WithTx(tx *gorm.DB) *TxRunner { return &TxRunner{DB: tx} } // txCtxKey ctx 中承载事务句柄的私有键类型(避免与其他包的字符串 key 冲突)。 type txCtxKey struct{} // WithTxContext 把事务句柄注入 context(事务内跨函数传递 tx 的标准通道)。 // // 入参:ctx context.Context(nil 时退化为 Background);tx 事务句柄(nil 时原样返回 ctx)。 // 出参:携带 tx 的 context.Context。 func WithTxContext(ctx context.Context, tx *gorm.DB) context.Context { if tx == nil { if ctx == nil { return context.Background() } return ctx } if ctx == nil { ctx = context.Background() } return context.WithValue(ctx, txCtxKey{}, tx) } // TxFromContext 从 context 取出事务句柄(未注入时返回 nil)。 // // 入参:ctx context.Context(可 nil)。出参:*gorm.DB 或 nil。 func TxFromContext(ctx context.Context) *gorm.DB { if ctx == nil { return nil } if tx, ok := ctx.Value(txCtxKey{}).(*gorm.DB); ok { return tx } return nil } // WithinTx 在事务内执行 fn(已在事务中则透传)。 // // 入参: // - ctx context.Context:请求上下文;若已含 tx(WithTxContext 注入)则优先透传该 tx。 // - fn func(tx *gorm.DB) error:事务内逻辑;**必须使用传入的 tx**(用 r.DB 会跑到事务外)。 // // 出参: // - error:fn 的业务错误原样返回(*infraerrors.AppError); // 非业务错误 → infraerrors.ErrDBError 包装(保留 Cause 供日志);事务已回滚。 func (t *TxRunner) WithinTx(ctx context.Context, fn func(tx *gorm.DB) error) error { if t == nil || t.DB == nil { return infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: TxRunner.DB 为 nil")) } if fn == nil { return infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: WithinTx fn 为 nil")) } // ① runner 已绑定事务句柄 → 透传 if InTx(t.DB) { return fn(bindCtx(t.DB, ctx)) } // ② ctx 携带事务句柄 → 透传(外层事务内调用下游 service 的主通道) if ctxTx := TxFromContext(ctx); ctxTx != nil && InTx(ctxTx) { return fn(bindCtx(ctxTx, ctx)) } // ③ 顶层:开启事务 db := t.DB if ctx != nil { db = db.WithContext(ctx) } err := db.Transaction(func(tx *gorm.DB) error { // 把 tx 注入 ctx,使 fn 内部(含下游 service)的 WithinTx 调用自动透传 return fn(tx.WithContext(WithTxContext(ctx, tx))) }) return wrapTxErr(err) } // bindCtx 在透传路径上绑定请求上下文并回注 tx(不修改连接池/事务归属)。 func bindCtx(tx *gorm.DB, ctx context.Context) *gorm.DB { if ctx == nil { return tx } return tx.WithContext(WithTxContext(ctx, tx)) } // InTx 判断给定 GORM 句柄是否已处于事务中(tx 透传判定)。 // // 判定依据:GORM 在事务内会把 `Statement.ConnPool` 替换为 `*sql.Tx`(实现 Commit/Rollback)。 // 用接口探测而非具体类型断言,兼容不同驱动/包装(MySQL 驱动、glebarez sqlite 均为 *sql.Tx)。 // // 入参:db *gorm.DB(可 nil)。出参:true=已在事务中。 func InTx(db *gorm.DB) bool { if db == nil || db.Statement == nil || db.Statement.ConnPool == nil { return false } if _, ok := db.Statement.ConnPool.(txCommitter); ok { return true } return false } // txCommitter 事务连接的公共方法集(*sql.Tx 满足;普通 *sql.DB 不满足)。 type txCommitter interface { Commit() error Rollback() error } // wrapTxErr 把事务执行错误归一为 academic 错误契约。 // // 规则:*infraerrors.AppError(业务错误)原样返回;其余(SQL/驱动/回滚失败)转 ErrDBError 并挂 Cause。 func wrapTxErr(err error) error { if err == nil { return nil } var appErr *infraerrors.AppError if stderrors.As(err, &appErr) { return err } return infraerrors.WithCause(infraerrors.ErrDBError, err) } // DefaultPageSize 默认每页条数(与 response.ParsePage 一致,设计 §10.6)。 const DefaultPageSize = 10 // MaxPageSize 每页条数上限(防大查询打爆内存/慢查询,设计 §10.6)。 const MaxPageSize = 200 // ClampPage 分页参数钳制(page < 1 → 1;pageSize < 1 → 10;pageSize > 200 → 200)。 // // 入参:page/pageSize——请求原始值。出参:钳制后的 (page, pageSize)。 func ClampPage(page, pageSize int) (int, int) { if page < 1 { page = 1 } if pageSize < 1 { pageSize = DefaultPageSize } if pageSize > MaxPageSize { pageSize = MaxPageSize } return page, pageSize } // PageOffset 由 page/pageSize 计算 SQL OFFSET(入参已钳制)。 func PageOffset(page, pageSize int) int { page, pageSize = ClampPage(page, pageSize) return (page - 1) * pageSize } // dupKeyHints 唯一键撞车的文案特征(不引第三方驱动错误类型,兼容 MySQL 与 sqlite)。 var dupKeyHints = []string{ "Duplicate entry", // MySQL 1062 "Duplicate entry for key", // MySQL 变体 "UNIQUE constraint failed", // sqlite "Error 1062", // MySQL 错误号形态 } // IsDupKey 判断错误是否为唯一键冲突(幂等键 uk_request_type / uk_user_activity 撞车判定)。 // // 入参:err error——repository 层返回的错误(可为 *infraerrors.AppError 或原生驱动错误)。 // 出参:true 表示唯一键冲突(调用方应视为"重复请求"而非 5002)。 // // 实现说明:依次检查 Cause 链上的字符串文案特征;不用 go-sql-driver/mysql 的具体错误类型, // 以保持"零新增直接依赖"(该驱动目前是 indirect 依赖)+ 兼容 sqlite 单测。 func IsDupKey(err error) bool { if err == nil { return false } msg := err.Error() for _, hint := range dupKeyHints { if strings.Contains(msg, hint) { return true } } return false }