// org 域适配器(academic → org 单向只读转发,设计 §1.3 依赖方向铁律 / §1.6 跨库收敛)。 // // 职责边界: // - 本文件是 academic 域内**唯一**允许"知道 org 表结构"的代码,且**只读**(SELECT),不写 org 表; // - 不 import `internal/org/repository`(避免跨域业务判断),只按 `t_campus` / `t_membership` // 的列名做最小字段映射;org 侧列变更只需同步本文件的 row struct; // - 不跨库:旧 `yuansiwei.t_school_campus` 已收敛为同库 `t_campus`(设计 §1.6), // 割接期禁止出现 `yuansiwei.` 前缀 SQL。 // // 装配方式(AC-14 接线时): // // campusReader := port.NewOrgCampusReader(gdb) // 或事务内 tx 版本 // memberChecker := port.NewOrgMembershipChecker(gdb) // fundSvc := service.NewFundService(repo, campusReader, memberChecker) package port import ( "context" stderrors "errors" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/apperr" infraerrors "yuansiwei/unified-backend/internal/infra/errors" ) // campusRow t_campus 的最小只读映射(仅 academic 需要的 4 列)。 // // 列语义(对齐 core-model-ddl.sql 第二部分 t_campus + AC-01 增量列 self_support): // - campus_id:校区主键(D2 int64)。 // - institution_id:所属机构 ID(旧口径 schoolid;作用域列取值源)。 // - self_support:是否自营(1 自营 / 0 加盟;2026-11-09 拍板 A 新增,来源 t_school_campus.self_support)。 // - status:active/disabled。 // // 说明:本 struct **不加入** model.AllTables()(不参与 AutoMigrate,仅查询映射)。 type campusRow struct { CampusID int64 `gorm:"column:campus_id;primaryKey"` InstitutionID int64 `gorm:"column:institution_id"` SelfSupport bool `gorm:"column:self_support"` Status string `gorm:"column:status"` } // TableName 锁定表名(与 org 域 model.Campus 同表;本 struct 只读)。 func (campusRow) TableName() string { return "t_campus" } // membershipRow t_membership 的最小只读映射(判断"人是否在作用域内 + 角色")。 // // 列语义(对齐 core-model-ddl.sql t_membership): // - canonical_user_id:用户 ULID;institution_id/campus_id:作用域;role:角色码;status:active/inactive。 type membershipRow struct { MembershipID int64 `gorm:"column:membership_id;primaryKey"` CanonicalUserID string `gorm:"column:canonical_user_id"` InstitutionID int64 `gorm:"column:institution_id"` CampusID int64 `gorm:"column:campus_id"` Role string `gorm:"column:role"` Status string `gorm:"column:status"` } // TableName 锁定表名(与 org 域 model.Membership 同表;本 struct 只读)。 func (membershipRow) TableName() string { return "t_membership" } // membershipStatusActive t_membership.status 的"在籍"取值(其余值视为已离开,不参与授权)。 const membershipStatusActive = "active" // OrgCampusReader org 域校区只读读取器(实现 CampusReader + TxCampusReader)。 // // 字段: // - DB *gorm.DB:连接池句柄;事务内读取请用 GetByIDTx 传 tx(同一快照,避免读到旧值)。 type OrgCampusReader struct { DB *gorm.DB } // NewOrgCampusReader 构造校区读取器。 // // 入参:db *gorm.DB——连接池句柄(必填)。出参:*OrgCampusReader(同时满足 CampusReader/TxCampusReader)。 func NewOrgCampusReader(db *gorm.DB) *OrgCampusReader { return &OrgCampusReader{DB: db} } // 编译期接口合规断言(装配层可把实现直接赋给接口变量)。 var ( _ CampusReader = (*OrgCampusReader)(nil) _ TxCampusReader = (*OrgCampusReader)(nil) ) // GetByID 走连接池读取校区(非事务场景,如列表页展示自营标识)。 // // 入参:ctx 请求上下文;campusID>0。出参:*CampusInfo;error——3140 参数错 / 3104 不存在 / 5002 DB 异常。 func (r *OrgCampusReader) GetByID(ctx context.Context, campusID int64) (*CampusInfo, error) { return r.GetByIDTx(ctx, nil, campusID) } // GetByIDTx 在给定事务句柄上读取校区(tx 为 nil 时退化到连接池)。 // // 入参:ctx / tx(可为 nil)/ campusID。出参:*CampusInfo;error 语义同 GetByID。 // // 实现:`SELECT campus_id, institution_id, self_support, status FROM t_campus WHERE campus_id = ? LIMIT 1`。 func (r *OrgCampusReader) GetByIDTx(ctx context.Context, tx *gorm.DB, campusID int64) (*CampusInfo, error) { if campusID <= 0 { return nil, apperr.ErrBusinessParam // 3140:campusId<=0 属业务参数错(设计 §1.6 末尾) } db, err := r.pickDB(ctx, tx) if err != nil { return nil, err } var row campusRow q := db.Model(&campusRow{}).Where("campus_id = ?", campusID).Limit(1) if err := q.Take(&row).Error; err != nil { if stderrors.Is(err, gorm.ErrRecordNotFound) { return nil, apperr.ErrCampusNotFound // 3104 } return nil, infraerrors.WithCause(infraerrors.ErrDBError, err) } return &CampusInfo{ CampusID: row.CampusID, SchoolID: row.InstitutionID, SelfSupport: row.SelfSupport, // 直接赋值(tinyint(1)↔bool;不再手写 ==1 转换,v0.1.3 A3) Status: row.Status, }, nil } // GetSchoolID 读取校区所属机构 ID(旧 SP 的隐式取数点统一收口)。 // // 入参:ctx / campusID。出参:int64 机构 ID;error——3140 / 3104 / 5002。 func (r *OrgCampusReader) GetSchoolID(ctx context.Context, campusID int64) (int64, error) { info, err := r.GetByID(ctx, campusID) if err != nil { return 0, err } if info.SchoolID <= 0 { // 校区存在但未挂机构(旧数据异常):与"校区不存在"同码,避免资金操作落到错误机构 return 0, apperr.ErrCampusNotFound } return info.SchoolID, nil } // IsSelfSupport 判断校区是否自营(决定活动失效范围:`campus_id IN (x,0)` vs `= x`)。 // // 入参:ctx / campusID。出参:bool 自营=true;error——3140 / 3104 / 5002。 func (r *OrgCampusReader) IsSelfSupport(ctx context.Context, campusID int64) (bool, error) { info, err := r.GetByID(ctx, campusID) if err != nil { return false, err } return info.SelfSupport, nil } // pickDB 选择查询句柄(tx 优先;两者皆空视为装配错误)。 func (r *OrgCampusReader) pickDB(ctx context.Context, tx *gorm.DB) (*gorm.DB, error) { if tx != nil { if ctx != nil { return tx.WithContext(ctx), nil } return tx, nil } if r == nil || r.DB == nil { return nil, infraerrors.WithCause(infraerrors.ErrInternal, errNoDB) } if ctx != nil { return r.DB.WithContext(ctx), nil } return r.DB, nil } // OrgMembershipChecker org 域成员关系校验器(实现 MembershipChecker,只读 t_membership)。 // // 用途:全局无作用域表(t_user_expense / t_user_divide_into*)的 Service 层越权预检(设计 §1.7)。 type OrgMembershipChecker struct { DB *gorm.DB } // NewOrgMembershipChecker 构造成员校验器。 // // 入参:db *gorm.DB——连接池句柄(必填)。出参:*OrgMembershipChecker。 func NewOrgMembershipChecker(db *gorm.DB) *OrgMembershipChecker { return &OrgMembershipChecker{DB: db} } // 编译期接口合规断言。 var _ MembershipChecker = (*OrgMembershipChecker)(nil) // HasMembership 判断用户在指定机构/校区是否具备任一给定角色。 // // 入参: // - ctx 请求上下文; // - userID 用户 ULID(空串直接 false,不打库); // - institutionID 机构 ID(>0 时强制过滤;0=不限); // - campusID 校区 ID(>0 时强制过滤;0=机构级任一校区); // - roles 期望角色(空=任一角色)。 // // 出参:bool 是否命中;error——5002 DB 异常。 // // 说明:仅统计 status='active' 的成员关系(离职/退学记录不授权)。 func (c *OrgMembershipChecker) HasMembership( ctx context.Context, userID string, institutionID, campusID int64, roles ...string, ) (bool, error) { if userID == "" { return false, nil } if c == nil || c.DB == nil { return false, infraerrors.WithCause(infraerrors.ErrInternal, errNoDB) } db := c.DB if ctx != nil { db = db.WithContext(ctx) } q := db.Model(&membershipRow{}). Where("canonical_user_id = ?", userID). Where("status = ?", membershipStatusActive) if institutionID > 0 { q = q.Where("institution_id = ?", institutionID) } if campusID > 0 { q = q.Where("campus_id = ?", campusID) } if len(roles) > 0 { q = q.Where("role IN ?", roles) } var count int64 if err := q.Count(&count).Error; err != nil { // GORM 纪律:Count 收 *int64 return false, infraerrors.WithCause(infraerrors.ErrDBError, err) } return count > 0, nil } // AssertInScope 断言用户在作用域内(不限定角色;越权 → 1004)。 // // 入参:ctx / userID / institutionID / campusID。 // 出参:nil 在范围内;infraerrors.ErrUnscoped(1004) 越权;5002 DB 异常。 // // 语义细节:institutionID<=0 时视为"未提供作用域",直接 1004(资金操作必须带机构上下文); // 该判定与设计 §1.6「campusid<=0 → 3140」互补:3140 用于参数合法性,1004 用于越权。 func (c *OrgMembershipChecker) AssertInScope(ctx context.Context, userID string, institutionID, campusID int64) error { if institutionID <= 0 { return infraerrors.ErrUnscoped } ok, err := c.HasMembership(ctx, userID, institutionID, campusID) if err != nil { return err } if !ok { return infraerrors.ErrUnscoped // 1004 } return nil } // errNoDB 装配期错误的哨兵文案(DB 句柄缺失 = 装配层 bug)。 var errNoDB = errString("port: 数据库句柄未注入(装配层错误)") // errString 实现 error 的最小字符串类型(避免为哨兵引入额外依赖)。 type errString string // Error 实现 error 接口。 func (e errString) Error() string { return string(e) }