orgadapter.go 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. // org 域适配器(academic → org 单向只读转发,设计 §1.3 依赖方向铁律 / §1.6 跨库收敛)。
  2. //
  3. // 职责边界:
  4. // - 本文件是 academic 域内**唯一**允许"知道 org 表结构"的代码,且**只读**(SELECT),不写 org 表;
  5. // - 不 import `internal/org/repository`(避免跨域业务判断),只按 `t_campus` / `t_membership`
  6. // 的列名做最小字段映射;org 侧列变更只需同步本文件的 row struct;
  7. // - 不跨库:旧 `yuansiwei.t_school_campus` 已收敛为同库 `t_campus`(设计 §1.6),
  8. // 割接期禁止出现 `yuansiwei.` 前缀 SQL。
  9. //
  10. // 装配方式(AC-14 接线时):
  11. //
  12. // campusReader := port.NewOrgCampusReader(gdb) // 或事务内 tx 版本
  13. // memberChecker := port.NewOrgMembershipChecker(gdb)
  14. // fundSvc := service.NewFundService(repo, campusReader, memberChecker)
  15. package port
  16. import (
  17. "context"
  18. stderrors "errors"
  19. "gorm.io/gorm"
  20. "yuansiwei/unified-backend/internal/academic/apperr"
  21. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  22. )
  23. // campusRow t_campus 的最小只读映射(仅 academic 需要的 4 列)。
  24. //
  25. // 列语义(对齐 core-model-ddl.sql 第二部分 t_campus + AC-01 增量列 self_support):
  26. // - campus_id:校区主键(D2 int64)。
  27. // - institution_id:所属机构 ID(旧口径 schoolid;作用域列取值源)。
  28. // - self_support:是否自营(1 自营 / 0 加盟;2026-11-09 拍板 A 新增,来源 t_school_campus.self_support)。
  29. // - status:active/disabled。
  30. //
  31. // 说明:本 struct **不加入** model.AllTables()(不参与 AutoMigrate,仅查询映射)。
  32. type campusRow struct {
  33. CampusID int64 `gorm:"column:campus_id;primaryKey"`
  34. InstitutionID int64 `gorm:"column:institution_id"`
  35. SelfSupport int8 `gorm:"column:self_support"`
  36. Status string `gorm:"column:status"`
  37. }
  38. // TableName 锁定表名(与 org 域 model.Campus 同表;本 struct 只读)。
  39. func (campusRow) TableName() string { return "t_campus" }
  40. // membershipRow t_membership 的最小只读映射(判断"人是否在作用域内 + 角色")。
  41. //
  42. // 列语义(对齐 core-model-ddl.sql t_membership):
  43. // - canonical_user_id:用户 ULID;institution_id/campus_id:作用域;role:角色码;status:active/inactive。
  44. type membershipRow struct {
  45. MembershipID int64 `gorm:"column:membership_id;primaryKey"`
  46. CanonicalUserID string `gorm:"column:canonical_user_id"`
  47. InstitutionID int64 `gorm:"column:institution_id"`
  48. CampusID int64 `gorm:"column:campus_id"`
  49. Role string `gorm:"column:role"`
  50. Status string `gorm:"column:status"`
  51. }
  52. // TableName 锁定表名(与 org 域 model.Membership 同表;本 struct 只读)。
  53. func (membershipRow) TableName() string { return "t_membership" }
  54. // membershipStatusActive t_membership.status 的"在籍"取值(其余值视为已离开,不参与授权)。
  55. const membershipStatusActive = "active"
  56. // OrgCampusReader org 域校区只读读取器(实现 CampusReader + TxCampusReader)。
  57. //
  58. // 字段:
  59. // - DB *gorm.DB:连接池句柄;事务内读取请用 GetByIDTx 传 tx(同一快照,避免读到旧值)。
  60. type OrgCampusReader struct {
  61. DB *gorm.DB
  62. }
  63. // NewOrgCampusReader 构造校区读取器。
  64. //
  65. // 入参:db *gorm.DB——连接池句柄(必填)。出参:*OrgCampusReader(同时满足 CampusReader/TxCampusReader)。
  66. func NewOrgCampusReader(db *gorm.DB) *OrgCampusReader { return &OrgCampusReader{DB: db} }
  67. // 编译期接口合规断言(装配层可把实现直接赋给接口变量)。
  68. var (
  69. _ CampusReader = (*OrgCampusReader)(nil)
  70. _ TxCampusReader = (*OrgCampusReader)(nil)
  71. )
  72. // GetByID 走连接池读取校区(非事务场景,如列表页展示自营标识)。
  73. //
  74. // 入参:ctx 请求上下文;campusID>0。出参:*CampusInfo;error——3140 参数错 / 3104 不存在 / 5002 DB 异常。
  75. func (r *OrgCampusReader) GetByID(ctx context.Context, campusID int64) (*CampusInfo, error) {
  76. return r.GetByIDTx(ctx, nil, campusID)
  77. }
  78. // GetByIDTx 在给定事务句柄上读取校区(tx 为 nil 时退化到连接池)。
  79. //
  80. // 入参:ctx / tx(可为 nil)/ campusID。出参:*CampusInfo;error 语义同 GetByID。
  81. //
  82. // 实现:`SELECT campus_id, institution_id, self_support, status FROM t_campus WHERE campus_id = ? LIMIT 1`。
  83. func (r *OrgCampusReader) GetByIDTx(ctx context.Context, tx *gorm.DB, campusID int64) (*CampusInfo, error) {
  84. if campusID <= 0 {
  85. return nil, apperr.ErrBusinessParam // 3140:campusId<=0 属业务参数错(设计 §1.6 末尾)
  86. }
  87. db, err := r.pickDB(ctx, tx)
  88. if err != nil {
  89. return nil, err
  90. }
  91. var row campusRow
  92. q := db.Model(&campusRow{}).Where("campus_id = ?", campusID).Limit(1)
  93. if err := q.Take(&row).Error; err != nil {
  94. if stderrors.Is(err, gorm.ErrRecordNotFound) {
  95. return nil, apperr.ErrCampusNotFound // 3104
  96. }
  97. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  98. }
  99. return &CampusInfo{
  100. CampusID: row.CampusID,
  101. SchoolID: row.InstitutionID,
  102. SelfSupport: row.SelfSupport == 1, // 1 自营 / 0 加盟(DDL 默认 1)
  103. Status: row.Status,
  104. }, nil
  105. }
  106. // GetSchoolID 读取校区所属机构 ID(旧 SP 的隐式取数点统一收口)。
  107. //
  108. // 入参:ctx / campusID。出参:int64 机构 ID;error——3140 / 3104 / 5002。
  109. func (r *OrgCampusReader) GetSchoolID(ctx context.Context, campusID int64) (int64, error) {
  110. info, err := r.GetByID(ctx, campusID)
  111. if err != nil {
  112. return 0, err
  113. }
  114. if info.SchoolID <= 0 {
  115. // 校区存在但未挂机构(旧数据异常):与"校区不存在"同码,避免资金操作落到错误机构
  116. return 0, apperr.ErrCampusNotFound
  117. }
  118. return info.SchoolID, nil
  119. }
  120. // IsSelfSupport 判断校区是否自营(决定活动失效范围:`campus_id IN (x,0)` vs `= x`)。
  121. //
  122. // 入参:ctx / campusID。出参:bool 自营=true;error——3140 / 3104 / 5002。
  123. func (r *OrgCampusReader) IsSelfSupport(ctx context.Context, campusID int64) (bool, error) {
  124. info, err := r.GetByID(ctx, campusID)
  125. if err != nil {
  126. return false, err
  127. }
  128. return info.SelfSupport, nil
  129. }
  130. // pickDB 选择查询句柄(tx 优先;两者皆空视为装配错误)。
  131. func (r *OrgCampusReader) pickDB(ctx context.Context, tx *gorm.DB) (*gorm.DB, error) {
  132. if tx != nil {
  133. if ctx != nil {
  134. return tx.WithContext(ctx), nil
  135. }
  136. return tx, nil
  137. }
  138. if r == nil || r.DB == nil {
  139. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errNoDB)
  140. }
  141. if ctx != nil {
  142. return r.DB.WithContext(ctx), nil
  143. }
  144. return r.DB, nil
  145. }
  146. // OrgMembershipChecker org 域成员关系校验器(实现 MembershipChecker,只读 t_membership)。
  147. //
  148. // 用途:全局无作用域表(t_user_expense / t_user_divide_into*)的 Service 层越权预检(设计 §1.7)。
  149. type OrgMembershipChecker struct {
  150. DB *gorm.DB
  151. }
  152. // NewOrgMembershipChecker 构造成员校验器。
  153. //
  154. // 入参:db *gorm.DB——连接池句柄(必填)。出参:*OrgMembershipChecker。
  155. func NewOrgMembershipChecker(db *gorm.DB) *OrgMembershipChecker {
  156. return &OrgMembershipChecker{DB: db}
  157. }
  158. // 编译期接口合规断言。
  159. var _ MembershipChecker = (*OrgMembershipChecker)(nil)
  160. // HasMembership 判断用户在指定机构/校区是否具备任一给定角色。
  161. //
  162. // 入参:
  163. // - ctx 请求上下文;
  164. // - userID 用户 ULID(空串直接 false,不打库);
  165. // - institutionID 机构 ID(>0 时强制过滤;0=不限);
  166. // - campusID 校区 ID(>0 时强制过滤;0=机构级任一校区);
  167. // - roles 期望角色(空=任一角色)。
  168. //
  169. // 出参:bool 是否命中;error——5002 DB 异常。
  170. //
  171. // 说明:仅统计 status='active' 的成员关系(离职/退学记录不授权)。
  172. func (c *OrgMembershipChecker) HasMembership(
  173. ctx context.Context, userID string, institutionID, campusID int64, roles ...string,
  174. ) (bool, error) {
  175. if userID == "" {
  176. return false, nil
  177. }
  178. if c == nil || c.DB == nil {
  179. return false, infraerrors.WithCause(infraerrors.ErrInternal, errNoDB)
  180. }
  181. db := c.DB
  182. if ctx != nil {
  183. db = db.WithContext(ctx)
  184. }
  185. q := db.Model(&membershipRow{}).
  186. Where("canonical_user_id = ?", userID).
  187. Where("status = ?", membershipStatusActive)
  188. if institutionID > 0 {
  189. q = q.Where("institution_id = ?", institutionID)
  190. }
  191. if campusID > 0 {
  192. q = q.Where("campus_id = ?", campusID)
  193. }
  194. if len(roles) > 0 {
  195. q = q.Where("role IN ?", roles)
  196. }
  197. var count int64
  198. if err := q.Count(&count).Error; err != nil { // GORM 纪律:Count 收 *int64
  199. return false, infraerrors.WithCause(infraerrors.ErrDBError, err)
  200. }
  201. return count > 0, nil
  202. }
  203. // AssertInScope 断言用户在作用域内(不限定角色;越权 → 1004)。
  204. //
  205. // 入参:ctx / userID / institutionID / campusID。
  206. // 出参:nil 在范围内;infraerrors.ErrUnscoped(1004) 越权;5002 DB 异常。
  207. //
  208. // 语义细节:institutionID<=0 时视为"未提供作用域",直接 1004(资金操作必须带机构上下文);
  209. // 该判定与设计 §1.6「campusid<=0 → 3140」互补:3140 用于参数合法性,1004 用于越权。
  210. func (c *OrgMembershipChecker) AssertInScope(ctx context.Context, userID string, institutionID, campusID int64) error {
  211. if institutionID <= 0 {
  212. return infraerrors.ErrUnscoped
  213. }
  214. ok, err := c.HasMembership(ctx, userID, institutionID, campusID)
  215. if err != nil {
  216. return err
  217. }
  218. if !ok {
  219. return infraerrors.ErrUnscoped // 1004
  220. }
  221. return nil
  222. }
  223. // errNoDB 装配期错误的哨兵文案(DB 句柄缺失 = 装配层 bug)。
  224. var errNoDB = errString("port: 数据库句柄未注入(装配层错误)")
  225. // errString 实现 error 的最小字符串类型(避免为哨兵引入额外依赖)。
  226. type errString string
  227. // Error 实现 error 接口。
  228. func (e errString) Error() string { return string(e) }