base.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. // Package repository academic 域唯一数据访问层(多租户表继承 BaseRepo 强制 scoping)。
  2. //
  3. // 依据:unified/03-migration/academic-domain-design.md §1.4(事务模型)/ §1.7(scoping 与两条例外)
  4. // / §10.5(BaseRepo 继承方式)。
  5. //
  6. // 分层纪律(依赖方向单向:repository → model + infra/scope + infra/errors):
  7. // - **repository 不开事务**:一律接收 `tx *gorm.DB` 参数(事务边界只在 service 层,设计 §10.3);
  8. // - **repository 不返回 gorm 原错**:统一转 infra/errors 的 sentinel(ErrNotFound/ErrDBError);
  9. // - 多租户表(带 institution_id/campus_id)必须走 `Scoped(db)`(禁止裸 r.DB.Where);
  10. // - 无作用域表(t_user_expense 全局钱包 / t_user_divide_into* 全局推广体系)**不继承 BaseRepo**,
  11. // 作用域断言在 Service 层经 port.MembershipChecker 完成(设计 §1.7 两条例外)。
  12. //
  13. // 本文件只提供"共享内核"能力:BaseRepo(scoping)、TxRunner(事务透传)、分页钳制、唯一键撞车识别。
  14. package repository
  15. import (
  16. "context"
  17. stderrors "errors"
  18. "strings"
  19. "gorm.io/gorm"
  20. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  21. appscope "yuansiwei/unified-backend/internal/infra/scope"
  22. )
  23. // BaseRepo 多租户仓储基类:持有 DB 句柄 + 本次请求作用域,提供强制 scoping 入口。
  24. //
  25. // 字段:
  26. // - DB *gorm.DB:数据库句柄(连接池由 infra/db 统一建立)。
  27. // - Sc *appscope.Scope:请求作用域(Auth 中间件解析 token 后注入;nil → Scoped panic fail-fast)。
  28. // - CampusScoped bool:该仓储查询的表是否含 campus_id 列(机构级资源置 false,避免 Unknown column)。
  29. //
  30. // 使用方式(设计 §10.5):
  31. //
  32. // type ExpenseSchoolRepo struct{ *repository.BaseRepo }
  33. // func NewExpenseSchoolRepo(db *gorm.DB, sc *appscope.Scope) *ExpenseSchoolRepo {
  34. // r := &ExpenseSchoolRepo{BaseRepo: NewBaseRepo(db, sc)}
  35. // r.CampusScoped = true // 表带 campus_id
  36. // return r
  37. // }
  38. // // 查询:r.Scoped(r.WithContext(ctx)).Where("user_id = ?", uid)
  39. // // 事务内:r.Scoped(tx).Where(...)
  40. type BaseRepo struct {
  41. DB *gorm.DB
  42. Sc *appscope.Scope
  43. CampusScoped bool
  44. }
  45. // NewBaseRepo 构造 academic 仓储基类(默认按"表带 campus_id"处理,机构级资源由子类置 false)。
  46. //
  47. // 入参:
  48. // - db *gorm.DB:数据库句柄(必填;nil 时任何查询都会 panic,属配置错误)。
  49. // - sc *appscope.Scope:请求作用域;系统任务/割接脚本可传 nil,但一旦查询即 panic(fail-fast)。
  50. //
  51. // 出参:*BaseRepo——指针类型(便于子类内嵌后修改 CampusScoped)。
  52. func NewBaseRepo(db *gorm.DB, sc *appscope.Scope) *BaseRepo {
  53. return &BaseRepo{DB: db, Sc: sc, CampusScoped: true}
  54. }
  55. // WithContext 绑定请求上下文(仅处理 ctx 传递,**不**注入作用域——作用域永远显式走 Scoped)。
  56. //
  57. // 入参:ctx context.Context——标准请求上下文(取消/超时传播)。
  58. // 出参:*gorm.DB——已 WithContext 的句柄(调用方必须再经 Scoped 注入租户过滤)。
  59. func (r *BaseRepo) WithContext(ctx context.Context) *gorm.DB {
  60. if r == nil || r.DB == nil {
  61. return nil
  62. }
  63. if ctx == nil {
  64. return r.DB
  65. }
  66. return r.DB.WithContext(ctx)
  67. }
  68. // Scoped 把作用域过滤条件注入查询(academic 域多租户防线唯一收口点)。
  69. //
  70. // 入参:
  71. // - db *gorm.DB:基础查询(通常 `r.WithContext(ctx)` 或事务内 `tx`);nil 时返回 nil。
  72. //
  73. // 出参:
  74. // - *gorm.DB:追加 `institution_id` / `campus_id` 过滤后的查询(链式安全,不改原 db)。
  75. //
  76. // 过滤规则(对齐 infra/scope.BaseRepo.Scoping 语义):
  77. // 1. Sc 为 nil → **panic**(无作用域上下文进入数据访问层 = 配置错误;HTTP 层 Recover 兜底 5001);
  78. // 2. Sc.SuperAdmin=true → 不加机构/校区过滤(平台超管跨机构视角);
  79. // 3. 普通用户 → 强制 `institution_id = Sc.InstitutionID`;
  80. // 仅当 `CampusScoped=true 且 Sc.CampusID>0` 时再强制 `campus_id = Sc.CampusID`。
  81. //
  82. // 安全红线:本方法**不允许**被绕过;资金/活动/课程表的任何读写都必须经它(代码评审逐处检查)。
  83. func (r *BaseRepo) Scoped(db *gorm.DB) *gorm.DB {
  84. if db == nil {
  85. return nil
  86. }
  87. if r == nil || r.Sc == nil {
  88. // 纵深防御:无作用域上下文禁止进入数据访问层(与 infra/scope 同口径 fail-fast)
  89. panic(errNoScope.Error())
  90. }
  91. if r.Sc.SuperAdmin {
  92. return db // 超管跨机构,不加作用域
  93. }
  94. db = db.Where("institution_id = ?", r.Sc.InstitutionID)
  95. if r.CampusScoped && r.Sc.CampusID > 0 {
  96. db = db.Where("campus_id = ?", r.Sc.CampusID)
  97. }
  98. return db
  99. }
  100. // GetScopedID 便捷读取当前作用域机构/校区(供 Service 组装写入数据的冗余作用域列)。
  101. //
  102. // 出参:(institutionID, campusID int64);Sc 为 nil 时返回 (0, 0)(调用方须自行拒绝该情况)。
  103. func (r *BaseRepo) GetScopedID() (institutionID, campusID int64) {
  104. if r == nil || r.Sc == nil {
  105. return 0, 0
  106. }
  107. return r.Sc.InstitutionID, r.Sc.CampusID
  108. }
  109. // ErrNoScope 无作用域上下文的哨兵错误(Scoped panic 文案;导出供单测断言)。
  110. var ErrNoScope = errString("repository: missing tenant scope, refusing unsoped query")
  111. // errNoScope 包内别名(与 ErrNoScope 同值,避免调用点改名扩散)。
  112. var errNoScope = ErrNoScope
  113. // errString 实现 error 的最小字符串类型(不为哨兵错误引入额外依赖)。
  114. type errString string
  115. // Error 实现 error 接口。
  116. func (e errString) Error() string { return string(e) }
  117. // TxRunner 事务运行器(academic 域唯一事务入口形状,设计 §10.3)。
  118. //
  119. // 语义(关键):
  120. // - 顶层调用:`DB.Transaction(fn)` 开启事务;
  121. // - **已在事务中**(三种透传入口任命中其一):**直接透传,不重复 BEGIN**
  122. // ① runner 本身绑定的 DB 已是 tx(`WithTx(tx)` 构造);
  123. // ② ctx 中已携带 tx(`WithTxContext(ctx, tx)` 注入,外层事务内继续调用下游 service 时用);
  124. // ③ fn 内部再次调用 WithinTx 且显式传了 tx 绑定的 runner;
  125. // ——避免 savepoint 语义混乱与"内层提交早于外层"的隐性 bug;
  126. // - fn 返回 *infraerrors.AppError 原样上抛(业务错误不包装),其余错误统一转 ErrDBError(5002)。
  127. //
  128. // 禁止(设计 §10.3):Service 内嵌套 `db.Transaction`、Repository 自己开事务、事务内调外部 HTTP/Redis。
  129. //
  130. // 典型用法:
  131. //
  132. // // 顶层
  133. // runner.WithinTx(ctx, func(tx *gorm.DB) error { ... })
  134. // // 事务内继续调用会"再开事务"的下游 service:把 tx 注入 ctx,下游自动透传
  135. // runner.WithinTx(ctx, func(tx *gorm.DB) error {
  136. // return recommendSvc.Settle(WithTxContext(ctx, tx), in) // 内部 WithinTx 命中透传
  137. // })
  138. type TxRunner struct {
  139. DB *gorm.DB
  140. }
  141. // NewTxRunner 构造事务运行器。
  142. //
  143. // 入参:db *gorm.DB——连接池句柄(必填)。出参:*TxRunner(DB 为 nil 时 WithinTx 返回 ErrInternal)。
  144. func NewTxRunner(db *gorm.DB) *TxRunner { return &TxRunner{DB: db} }
  145. // WithTx 返回绑定到指定事务句柄的 runner(事务内调用下游方法时用,保证透传同一 tx)。
  146. //
  147. // 入参:tx *gorm.DB——事务句柄。出参:*TxRunner(DB = tx;后续 WithinTx 直接透传,不再 BEGIN)。
  148. func (t *TxRunner) WithTx(tx *gorm.DB) *TxRunner { return &TxRunner{DB: tx} }
  149. // txCtxKey ctx 中承载事务句柄的私有键类型(避免与其他包的字符串 key 冲突)。
  150. type txCtxKey struct{}
  151. // WithTxContext 把事务句柄注入 context(事务内跨函数传递 tx 的标准通道)。
  152. //
  153. // 入参:ctx context.Context(nil 时退化为 Background);tx 事务句柄(nil 时原样返回 ctx)。
  154. // 出参:携带 tx 的 context.Context。
  155. func WithTxContext(ctx context.Context, tx *gorm.DB) context.Context {
  156. if tx == nil {
  157. if ctx == nil {
  158. return context.Background()
  159. }
  160. return ctx
  161. }
  162. if ctx == nil {
  163. ctx = context.Background()
  164. }
  165. return context.WithValue(ctx, txCtxKey{}, tx)
  166. }
  167. // TxFromContext 从 context 取出事务句柄(未注入时返回 nil)。
  168. //
  169. // 入参:ctx context.Context(可 nil)。出参:*gorm.DB 或 nil。
  170. func TxFromContext(ctx context.Context) *gorm.DB {
  171. if ctx == nil {
  172. return nil
  173. }
  174. if tx, ok := ctx.Value(txCtxKey{}).(*gorm.DB); ok {
  175. return tx
  176. }
  177. return nil
  178. }
  179. // WithinTx 在事务内执行 fn(已在事务中则透传)。
  180. //
  181. // 入参:
  182. // - ctx context.Context:请求上下文;若已含 tx(WithTxContext 注入)则优先透传该 tx。
  183. // - fn func(tx *gorm.DB) error:事务内逻辑;**必须使用传入的 tx**(用 r.DB 会跑到事务外)。
  184. //
  185. // 出参:
  186. // - error:fn 的业务错误原样返回(*infraerrors.AppError);
  187. // 非业务错误 → infraerrors.ErrDBError 包装(保留 Cause 供日志);事务已回滚。
  188. func (t *TxRunner) WithinTx(ctx context.Context, fn func(tx *gorm.DB) error) error {
  189. if t == nil || t.DB == nil {
  190. return infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: TxRunner.DB 为 nil"))
  191. }
  192. if fn == nil {
  193. return infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: WithinTx fn 为 nil"))
  194. }
  195. // ① runner 已绑定事务句柄 → 透传
  196. if InTx(t.DB) {
  197. return fn(bindCtx(t.DB, ctx))
  198. }
  199. // ② ctx 携带事务句柄 → 透传(外层事务内调用下游 service 的主通道)
  200. if ctxTx := TxFromContext(ctx); ctxTx != nil && InTx(ctxTx) {
  201. return fn(bindCtx(ctxTx, ctx))
  202. }
  203. // ③ 顶层:开启事务
  204. db := t.DB
  205. if ctx != nil {
  206. db = db.WithContext(ctx)
  207. }
  208. err := db.Transaction(func(tx *gorm.DB) error {
  209. // 把 tx 注入 ctx,使 fn 内部(含下游 service)的 WithinTx 调用自动透传
  210. return fn(tx.WithContext(WithTxContext(ctx, tx)))
  211. })
  212. return wrapTxErr(err)
  213. }
  214. // bindCtx 在透传路径上绑定请求上下文并回注 tx(不修改连接池/事务归属)。
  215. func bindCtx(tx *gorm.DB, ctx context.Context) *gorm.DB {
  216. if ctx == nil {
  217. return tx
  218. }
  219. return tx.WithContext(WithTxContext(ctx, tx))
  220. }
  221. // InTx 判断给定 GORM 句柄是否已处于事务中(tx 透传判定)。
  222. //
  223. // 判定依据:GORM 在事务内会把 `Statement.ConnPool` 替换为 `*sql.Tx`(实现 Commit/Rollback)。
  224. // 用接口探测而非具体类型断言,兼容不同驱动/包装(MySQL 驱动、glebarez sqlite 均为 *sql.Tx)。
  225. //
  226. // 入参:db *gorm.DB(可 nil)。出参:true=已在事务中。
  227. func InTx(db *gorm.DB) bool {
  228. if db == nil || db.Statement == nil || db.Statement.ConnPool == nil {
  229. return false
  230. }
  231. if _, ok := db.Statement.ConnPool.(txCommitter); ok {
  232. return true
  233. }
  234. return false
  235. }
  236. // txCommitter 事务连接的公共方法集(*sql.Tx 满足;普通 *sql.DB 不满足)。
  237. type txCommitter interface {
  238. Commit() error
  239. Rollback() error
  240. }
  241. // wrapTxErr 把事务执行错误归一为 academic 错误契约。
  242. //
  243. // 规则:*infraerrors.AppError(业务错误)原样返回;其余(SQL/驱动/回滚失败)转 ErrDBError 并挂 Cause。
  244. func wrapTxErr(err error) error {
  245. if err == nil {
  246. return nil
  247. }
  248. var appErr *infraerrors.AppError
  249. if stderrors.As(err, &appErr) {
  250. return err
  251. }
  252. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  253. }
  254. // DefaultPageSize 默认每页条数(与 response.ParsePage 一致,设计 §10.6)。
  255. const DefaultPageSize = 10
  256. // MaxPageSize 每页条数上限(防大查询打爆内存/慢查询,设计 §10.6)。
  257. const MaxPageSize = 200
  258. // ClampPage 分页参数钳制(page < 1 → 1;pageSize < 1 → 10;pageSize > 200 → 200)。
  259. //
  260. // 入参:page/pageSize——请求原始值。出参:钳制后的 (page, pageSize)。
  261. func ClampPage(page, pageSize int) (int, int) {
  262. if page < 1 {
  263. page = 1
  264. }
  265. if pageSize < 1 {
  266. pageSize = DefaultPageSize
  267. }
  268. if pageSize > MaxPageSize {
  269. pageSize = MaxPageSize
  270. }
  271. return page, pageSize
  272. }
  273. // PageOffset 由 page/pageSize 计算 SQL OFFSET(入参已钳制)。
  274. func PageOffset(page, pageSize int) int {
  275. page, pageSize = ClampPage(page, pageSize)
  276. return (page - 1) * pageSize
  277. }
  278. // dupKeyHints 唯一键撞车的文案特征(不引第三方驱动错误类型,兼容 MySQL 与 sqlite)。
  279. var dupKeyHints = []string{
  280. "Duplicate entry", // MySQL 1062
  281. "Duplicate entry for key", // MySQL 变体
  282. "UNIQUE constraint failed", // sqlite
  283. "Error 1062", // MySQL 错误号形态
  284. }
  285. // IsDupKey 判断错误是否为唯一键冲突(幂等键 uk_request_type / uk_user_activity 撞车判定)。
  286. //
  287. // 入参:err error——repository 层返回的错误(可为 *infraerrors.AppError 或原生驱动错误)。
  288. // 出参:true 表示唯一键冲突(调用方应视为"重复请求"而非 5002)。
  289. //
  290. // 实现说明:依次检查 Cause 链上的字符串文案特征;不用 go-sql-driver/mysql 的具体错误类型,
  291. // 以保持"零新增直接依赖"(该驱动目前是 indirect 依赖)+ 兼容 sqlite 单测。
  292. func IsDupKey(err error) bool {
  293. if err == nil {
  294. return false
  295. }
  296. msg := err.Error()
  297. for _, hint := range dupKeyHints {
  298. if strings.Contains(msg, hint) {
  299. return true
  300. }
  301. }
  302. return false
  303. }