// 收支字典 + 收支记录的数据访问(端点 60:`/admin/expenditures`)——AC-13d 第三批。 // // 依据:unified/03-migration/academic-frontend-contract-v1.md §2.5 行 60 / §3.4 D 表 60(v1.9 重写); // 目标态 DDL 两张表: // // t_expenditure : id, type, parent_id, name, remark, institution_id // t_expenditure_log : id, biz_no, institution_id, campus_id, expenditure_id, cost, // create_user_id, create_time, year, month, remark, imgs // // 端点 60 的三条口径(契约 v1.9 已定,这里只实现,不再议): // // ① `type` / `name` 记录表**没有这两列** ⇒ 必须查字典 `t_expenditure`: // `type`(1 支出 / 2 收入) 与 `name`(类型名)都从字典拿;同时补 `expenditureId`, // 否则前端无法回填编辑表单(有 name 没 id,改不了)。 // ② 不实现 `direction`:与 `type` 是同一事实的两个名字,两表都没有该列。 // ③ `userId` → `createUserId`(← `create_user_id`,语义 = **记账人**)。 // 记录表**没有"收付对象"列**,需要只能加列 + 迁移(本轮不做)。 // // --------------------------------------------------------------------------- // ⚠ 本文件最要紧的一处:**平台通用字典(`institution_id = 0`)不能被作用域过滤掉** // --------------------------------------------------------------------------- // // `t_expenditure.institution_id` 的 `0` **不是**"未归属/脏数据",而是**平台通用字典** // (model.Expenditure 列注释:"0=平台通用字典")。机构可以自建类型,但绝大多数类型是平台那批。 // // 若对字典表也套 `BaseRepo.Scoped`(生成 `WHERE institution_id = 1`),后果是: // // 记录列表 JOIN 不到类型名 ⇒ 所有"平台通用类型"的收支记录 `name` 全是空、`type` 全是 0; // 而记录本身查得到、金额分页都对、`bizNo` 都在 —— **只有"类型"这一列静默变空**。 // 前端会把这批记录渲染成"未知类型",用户以为数据丢了,实际是被作用域过滤掉的字典行。 // // 更糟的是这种缺陷**在机构视角下"看起来像没配字典"**,排查方向会先跑到"字典维护页是不是漏了"。 // // 因此本文件对字典表的读取**刻意不用 `Scoped`**,而是显式写: // // WHERE (institution_id = 0 OR institution_id = ?) -- ? = 请求机构(超管无机构时只取 0) // // 记录表(`t_expenditure_log`)**有**真实作用域列且无平台行语义 ⇒ 照常走 `Scoped`。 // // --------------------------------------------------------------------------- // 两条实现纪律 // --------------------------------------------------------------------------- // // 1. **不做 SQL JOIN**(虽然契约文案写的是 "JOIN 字典"):记录表走 `Scoped` 生成的是**裸列名** // (`institution_id = ?`),一旦 JOIN 就 `ambiguous column name: institution_id` // (本项目已踩过:`ListStudentSchedule`)。改为"分页取记录 → 按 `expenditure_id` 批量取字典" // 两步走,语义等价、无歧义、且字典命中天然带上了"平台通用"那一批。 // ⇒ 反过来看:**两步走不是妥协,而是让"平台字典必须放行"这条规则只出现在一处**(`ListTypesByIDs`)。 // 2. `type` 过滤走**子查询**(`expenditure_id IN (SELECT id FROM t_expenditure WHERE type = ? ...)`), // 不下发"先查一批 id 再 IN":字典可能上百行,把 IN 列表拼进 SQL 反而更容易写错。 package repository import ( "context" "strings" "time" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/model" infraerrors "yuansiwei/unified-backend/internal/infra/errors" appscope "yuansiwei/unified-backend/internal/infra/scope" ) // ExpenditureRepo 收支记录 + 收支字典仓储。 // // 作用域:记录表 `t_expenditure_log` 带 `institution_id`/`campus_id` ⇒ `CampusScoped=true`; // 字典表按"0 = 平台通用"特判(见文件头),不走 `Scoped`。 type ExpenditureRepo struct { *BaseRepo } // NewExpenditureRepo 构造收支仓储。 // // 入参:db 连接池;sc 请求作用域(nil = 系统任务视角——字典读取需要特判,记录查询会 panic,见 Scoped)。 // 出参:*ExpenditureRepo(CampusScoped=true)。 func NewExpenditureRepo(db *gorm.DB, sc *appscope.Scope) *ExpenditureRepo { r := &ExpenditureRepo{BaseRepo: NewBaseRepo(db, sc)} r.CampusScoped = true return r } // ExpenditureLogFilter 收支记录列表过滤(端点 60 GET)。 // // 字段: // - InstitutionID:机构(0 = 不额外筛,作用域由 `Scoped` 强制;非超管传了别的机构 → Service 层 1004); // - CampusID:校区(nil 不筛); // - ExpenditureID:收支类型(nil 不筛;按字典 id 精确匹配); // - Type:收支方向(nil 不筛;1 支出 / 2 收入,走字典子查询); // - From / To:创建时间区间(含两端;nil 不筛)。 // // ⚠ `Type` 与 `ExpenditureID` 同时给出时是**交集**(正常业务不会传,传了也不该"或")。 type ExpenditureLogFilter struct { InstitutionID int64 CampusID *int64 ExpenditureID *int64 Type *int32 From *time.Time To *time.Time } // ListLogs 分页查询收支记录(按 `create_time DESC, id DESC`:最新记账在前)。 // // 入参:ctx;f 过滤;pg 分页。 // 出参:([]model.ExpenditureLog, total, error);空结果非 nil 由调用方保证;ErrDBError(5002)。 // // 排序为什么带 `id DESC` 兜底:`create_time` 是 `datetime(3)`,同一毫秒批量导入会出现重复值, // 只按时间排序时**分页边界会重复/漏行**(MySQL 对相等键的排序不稳定)。加主键兜底即确定性。 func (r *ExpenditureRepo) ListLogs(ctx context.Context, f ExpenditureLogFilter, pg PageQuery) ([]model.ExpenditureLog, int64, error) { db, err := r.db(ctx) if err != nil { return nil, 0, err } build := func() *gorm.DB { q := r.Scoped(db.Model(&model.ExpenditureLog{})) if f.InstitutionID > 0 { // 超管跨机构时用于收窄;非超管与 Scoped 同值(重复条件是等价的,只为让超管能筛)。 q = q.Where("institution_id = ?", f.InstitutionID) } if f.CampusID != nil { q = q.Where("campus_id = ?", *f.CampusID) } if f.ExpenditureID != nil { q = q.Where("expenditure_id = ?", *f.ExpenditureID) } if f.Type != nil { q = q.Where("expenditure_id IN (?)", r.typeSubquery(db, *f.Type)) } if f.From != nil { q = q.Where("create_time >= ?", *f.From) } if f.To != nil { // **上界是开区间**(`<`):Handler 已把"纯日期"形态补到次日 00:00 // (`to=2026-04-10` 含当日全部流水)。若这里写成 `<=`, // 次日 00:00:00.000 的那条会被多算进前一天——与端点 17(账单 `to` 不含上界)同口径。 q = q.Where("create_time < ?", *f.To) } return q } var total int64 if err := build().Count(&total).Error; err != nil { return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err) } page, size := pg.Normalize() rows := make([]model.ExpenditureLog, 0, size) if err := build(). Order("create_time DESC, id DESC"). Offset(PageOffset(page, size)).Limit(size). Find(&rows).Error; err != nil { return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err) } return rows, total, nil } // typeSubquery 构造"某收支方向对应的字典 id 集合"子查询。 // // 入参:db 查询句柄;typ 收支方向(1 支出 / 2 收入)。 // 出参:*gorm.DB(用作 `expenditure_id IN (?)` 的子查询)。 // // ⚠ 子查询里**必须**放行 `institution_id = 0`(平台通用字典),理由同文件头: // 只查本机构会造成"按方向筛选时,平台通用类型的记录整批消失"——比列表少一列更难查。 // 超管(作用域机构为 0 或 SuperAdmin)不额外收窄机构:平台视角下应能看到所有机构的类型。 func (r *ExpenditureRepo) typeSubquery(db *gorm.DB, typ int32) *gorm.DB { q := db.Model(&model.Expenditure{}).Select("id").Where("type = ?", typ) if r.Sc == nil || r.Sc.SuperAdmin { return q } return q.Where("institution_id = 0 OR institution_id = ?", r.Sc.InstitutionID) } // CreateLog 写入一条收支记录(**接收 tx**,仓储不开事务),并回填 DB 侧落库时间。 // // 入参:ctx;tx(必传);row 记录行(InstitutionID>0、ExpenditureID>0、Cost 为非负定点字符串)。 // 出参:nil 成功(回填自增 ID 与 `CreateTime`);ErrBadParam(2002) / ErrDBError(5002)。 // // ⚠ **不写 `create_time`**:该列 `DEFAULT CURRENT_TIMESTAMP`,GORM 对"零值 + default 标签" // 会跳过该列、由 DB 填(本项目在 `t_courses.status` 上踩过**反向**的坑:`default:1` 让显式 0 被吞)。 // 因此应用侧写 `time.Now()` 是错的(应用与库时钟可能差几分钟,而 `year`/`month` 是按它算的分桶)。 // // ⇒ 插入后**在同一事务内回读** `create_time` 填进 `row.CreateTime`,让上层用"库认定的落库时刻" // 去算 `year`/`month`,而不是用应用时钟。差几秒在跨月/跨年的那一瞬间就是: // **"1 月 31 日 23:59:59 记的账出现在 2 月报表里"**——金额、条数、汇总全对,只有月份错, // 这种缺陷只能靠对账时"某月少一条、另一月多一条"发现。 func (r *ExpenditureRepo) CreateLog(ctx context.Context, tx *gorm.DB, row *model.ExpenditureLog) error { if err := requireTx(tx, "CreateLog"); err != nil { return err } if row == nil || row.ExpenditureID <= 0 { return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CreateLog 需要 expenditure_id > 0")) } if strings.TrimSpace(row.BizNo) == "" { return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CreateLog 需要 biz_no")) } ctx = ctxOrBackground(ctx) if err := tx.WithContext(ctx).Create(row).Error; err != nil { return infraerrors.WithCause(infraerrors.ErrDBError, err) } // 回读落库时间(同一事务,读得到自己刚插的行)。 var persisted model.ExpenditureLog if err := tx.WithContext(ctx). Select("create_time"). Where("id = ?", row.ID). Take(&persisted).Error; err != nil { return infraerrors.WithCause(infraerrors.ErrDBError, err) } row.CreateTime = persisted.CreateTime return nil } // SetLogBuckets 回写报表分桶列 `year` / `month`(同一事务;由 Service 按落库时间算出)。 // // 入参:ctx;tx(必传);id 记录 ID(>0);year 年(如 2026);month 月(1~12)。 // 出参:nil 成功;ErrBadParam(2002) / ErrDBError(5002)。 // // 单独一次 UPDATE 而不是塞进 INSERT:`create_time` 由 DB 默认值决定,INSERT 时**还没读到**它, // 硬把应用时钟的 y/m 写进去就是上面 `CreateLog` 注释里那个"跨月错桶"缺陷的来源。 func (r *ExpenditureRepo) SetLogBuckets(ctx context.Context, tx *gorm.DB, id int64, year int16, month int8) error { if err := requireTx(tx, "SetLogBuckets"); err != nil { return err } if id <= 0 { return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: SetLogBuckets 需要 id > 0")) } if month < 1 || month > 12 { return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: month 必须在 1~12")) } err := tx.WithContext(ctxOrBackground(ctx)). Model(&model.ExpenditureLog{}). Where("id = ?", id). Updates(map[string]any{"year": year, "month": month}).Error if err != nil { return infraerrors.WithCause(infraerrors.ErrDBError, err) } return nil } // ListTypesByIDs 按 id 批量取收支字典(**含平台通用字典**)。 // // 入参:ctx;ids 类型 id 集合(空 → 空切片,不发 SQL);institutionID 请求机构(0 = 只取平台字典)。 // 出参:([]model.Expenditure, error);ErrDBError(5002)。 // // 过滤口径:`institution_id = 0 OR institution_id = ?` —— `0` 是平台通用字典,**必须放行**。 // 返回的 map 由 Service 组装,未命中(字典被删/`expenditure_id = 0`)的行保持 `type=0/name=""`, // **不伪造"未知类型"字符串**(那是新造业务值,契约追溯不到列)。 func (r *ExpenditureRepo) ListTypesByIDs(ctx context.Context, ids []int64, institutionID int64) ([]model.Expenditure, error) { if len(ids) == 0 { return make([]model.Expenditure, 0), nil } db, err := r.db(ctx) if err != nil { return nil, err } rows := make([]model.Expenditure, 0, len(ids)) if err := r.dictionaryScope(db, institutionID).Where("id IN ?", ids).Find(&rows).Error; err != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, err) } return rows, nil } // ListTypes 取某机构的全部收支字典(**含平台通用字典**),按 `type, parent_id, id` 递增。 // // 入参:ctx;institutionID 请求机构(0 = 只取平台字典)。 // 出参:([]model.Expenditure, error);ErrDBError(5002)。 // // 排序口径(稳定输出,便于前端做两级联动):先按 `type`(1 支出 / 2 收入,支出在前), // 再按 `parent_id`(顶级 0 在前),最后 `id`。SQL 里显式写序,不依赖存储顺序。 func (r *ExpenditureRepo) ListTypes(ctx context.Context, institutionID int64) ([]model.Expenditure, error) { db, err := r.db(ctx) if err != nil { return nil, err } rows := make([]model.Expenditure, 0, 32) if err := r.dictionaryScope(db, institutionID). Order("type ASC, parent_id ASC, id ASC"). Find(&rows).Error; err != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, err) } return rows, nil } // GetTypeByID 取单个收支字典项(**含平台通用字典**)。 // // 入参:ctx;id 类型 id(<=0 → ErrBadParam);institutionID 请求机构(0 = 只认平台字典)。 // 出参:*model.Expenditure;ErrBadParam(2002) / ErrNotFound(3001) / ErrDBError(5002)。 // // 用途(唯一):端点 60 POST 记账时校验 `expenditureId` 真实存在且对本机构可见。 // 不存在与"别人的私有类型"**同码 3001**(不泄露存在性)。 func (r *ExpenditureRepo) GetTypeByID(ctx context.Context, id, institutionID int64) (*model.Expenditure, error) { if id <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: 收支类型 id 必须 > 0")) } db, err := r.db(ctx) if err != nil { return nil, err } var out model.Expenditure qErr := r.dictionaryScope(db, institutionID).Where("id = ?", id).Take(&out).Error if qErr != nil { return nil, wrapQueryErr(qErr, "GetTypeByID(t_expenditure)") } return &out, nil } // dictionaryScope 字典表的作用域条件(**唯一实现点**:`0` = 平台通用字典必须放行)。 // // 入参:db;institutionID 请求机构(<=0 = 只取平台字典)。 // 出参:*gorm.DB(已注入 `institution_id` 条件)。 // // 为什么机构为 0 时**不**写成"不过滤":超管没有机构上下文时若不过滤,会把所有机构的同名类型 // 混在一起(`parentId` 还是各机构自己的 id),下拉框选项重复且父子关系错乱。 // 因此超管未指定机构时**只看平台字典**,要看某机构的私有类型就显式传 `institutionId`。 func (r *ExpenditureRepo) dictionaryScope(db *gorm.DB, institutionID int64) *gorm.DB { if institutionID <= 0 { return db.Where("institution_id = 0") } return db.Where("institution_id = 0 OR institution_id = ?", institutionID) } // db 取查询句柄(ctx 内含 tx 时复用同一事务,保证事务内读一致)。 // // 入参:ctx(可 nil)。出参:(*gorm.DB, error);未装配 DB → ErrInternal。 func (r *ExpenditureRepo) db(ctx context.Context) (*gorm.DB, error) { if r == nil || r.DB == nil { return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: ExpenditureRepo.DB 为 nil")) } if tx := TxFromContext(ctx); tx != nil { return tx, nil } if ctx == nil { return r.DB, nil } return r.DB.WithContext(ctx), nil }