| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252 |
- // 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) }
|