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