| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547 |
- // 财务只读查询服务(端点 1 / 11 / 12 / 21 的编排层)。
- //
- // 依据:unified/03-migration/academic-domain-design.md
- // - §4.1 端点 1(账户快照)、11(流水列表)、12(工单列表)、21(仪表盘,P2)
- // - §3.8-f 约束 2:「`t_user_expense_log` **无 campus 维度 ⇒ 恒等式/汇总只能按 user 做**」
- // —— 这条约束直接决定了本服务两个"必须显式拒绝"的边界,见 ListLogs / Dashboard 注释。
- // - §1.7 例外一:全局钱包与流水无作用域列,**越权断言必须在 Service 层**(不能靠 Scoped)。
- //
- // 为什么单独一个查询服务(而不是塞进 handler 或 FundService):
- // - Handler 层纪律是"不碰 DB"(infra/scope 包注释、org/identity handler 同款);
- // - FundService 是**资金写路径**(每个方法都在事务里改余额),把 4 个只读聚合塞进去会
- // 让"这个服务会不会动钱"变得不可一眼判断(评审成本最高的一类退化)。
- // 本服务**全程只读、不开事务**(唯一例外是它调用的仓储方法本身不开事务)。
- package service
- import (
- "context"
- "strings"
- "time"
- "gorm.io/gorm"
- "yuansiwei/unified-backend/internal/academic/model"
- "yuansiwei/unified-backend/internal/academic/money"
- "yuansiwei/unified-backend/internal/academic/port"
- "yuansiwei/unified-backend/internal/academic/repository"
- infraerrors "yuansiwei/unified-backend/internal/infra/errors"
- appscope "yuansiwei/unified-backend/internal/infra/scope"
- )
- // FinanceQueryService 财务只读查询服务(端点 1 / 11 / 12 / 21)。
- //
- // 字段:
- // - expense 余额账户仓储(全局钱包 / 校区账户 / 流水);
- // - requests 余额工单仓储(工单列表);
- // - member 成员校验器(可 nil:nil 时跳过对象级越权断言,仅限双跑/回填脚本);
- // - sc 请求作用域(可 nil:系统任务视角)。
- type FinanceQueryService struct {
- expense *repository.ExpenseRepo
- requests *repository.BalanceRequestRepo
- member port.MembershipChecker
- sc *appscope.Scope
- }
- // NewFinanceQueryService 构造财务只读查询服务。
- //
- // 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域);member port.MembershipChecker(可 nil)。
- // 出参:*FinanceQueryService。
- func NewFinanceQueryService(db *gorm.DB, sc *appscope.Scope, member port.MembershipChecker) *FinanceQueryService {
- return &FinanceQueryService{
- expense: repository.NewExpenseRepo(db, sc),
- requests: repository.NewBalanceRequestRepo(db, sc),
- member: member,
- sc: sc,
- }
- }
- // ---------------------------------------------------------------- 端点 1:账户快照
- // AccountSnapshot 账户快照(端点 1 `GET /admin/finance/accounts/:userId`)。
- //
- // **形状:扁平**(契约 v1.5 裁决 —— 前端页面已按扁平读,改后端只动 DTO 与断言,改前端要动页面)。
- // 每字段的来源列(契约 §3.4 ③「字段准入条件」要求可追溯):
- //
- // totalBalance ← t_user_expense.balance (全局/跨校区)
- // campusBalance ← t_user_expense_school.balance (**指定校区**;未指定校区或无账户 → "0.00")
- // specialExpenses ← 对应表的 special_expenses (有校区账户取校区值,否则取全局值)
- // cumulativeIncome ← total_income (同上取法)
- //
- // `institutionId` / `campusId` 是**入参回显**,不是账户列
- // (`t_user_expense` 根本没有作用域列,§3.8-f 约束 2)⇒ **不得**从账户行里取。
- type AccountSnapshot struct {
- // UserID 用户(ULID)。
- UserID string `json:"userId"`
- // InstitutionID 机构 ID(**入参回显**,不是账户列)。
- InstitutionID int64 `json:"institutionId"`
- // CampusID 校区 ID(**入参回显**,不是账户列)。
- CampusID int64 `json:"campusId"`
- // TotalBalance 全局(跨校区)余额(元)← `t_user_expense.balance`。
- TotalBalance money.Amount `json:"totalBalance"`
- // CampusBalance 指定校区余额(元)← `t_user_expense_school.balance`(未指定/无账户 → 0)。
- CampusBalance money.Amount `json:"campusBalance"`
- // SpecialExpenses 特殊费用(元):有校区账户取校区列,否则取全局列。
- SpecialExpenses money.Amount `json:"specialExpenses"`
- // CumulativeIncome 累计充值(元)← `total_income`(同上取法)。
- CumulativeIncome money.Amount `json:"cumulativeIncome"`
- }
- // AccountSnapshot 读账户快照(端点 1)。
- //
- // 入参:
- // - ctx 请求上下文;
- // - userID 用户(ULID;必填);
- // - institutionID 机构 ID(可选:>0 时作为越权判定输入与校区账户定位维度);
- // - campusID 校区 ID(可选:>0 时附带该校区账户;=0 则不返回 campus 段)。
- //
- // 出参:
- // - *AccountSnapshot;全局账户不存在时 Global 全 0 且 **不报错**(新用户尚未建户,
- // 与 dump 的"账户不存在 → 余额 0"同口径;前端按 0 展示);
- // - ErrBadParam(2001) 缺 user_id / ErrUnscoped(1004) 越权 / ErrDBError(5002)。
- //
- // 越权:全局钱包无作用域列(§1.7 例外一)⇒ **必须**在这里经 member 断言该用户属于本机构;
- // 未注入 member(双跑脚本)时跳过——这条跳过的后果写进注释,评审可一眼看到边界。
- func (s *FinanceQueryService) AccountSnapshot(
- ctx context.Context, userID string, institutionID, campusID int64,
- ) (*AccountSnapshot, error) {
- if strings.TrimSpace(userID) == "" {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: AccountSnapshot 需要 user_id"))
- }
- if err := s.assertMember(ctx, userID, institutionID, campusID); err != nil {
- return nil, err
- }
- out := &AccountSnapshot{UserID: userID}
- // 入参回显(**不是**账户列:`t_user_expense` 无作用域列)
- instID := institutionID
- if instID <= 0 && s.sc != nil {
- instID = s.sc.InstitutionID
- }
- out.InstitutionID = instID
- out.CampusID = campusID
- global, err := s.expense.GetGlobalAccount(ctx, nil, userID)
- if err != nil {
- if isNotFoundErr(err) {
- // 账户不存在:全 0(dump 的 ISNULL 归零分支),不是错误
- return out, nil
- }
- return nil, err
- }
- // totalBalance ← t_user_expense.balance
- globalBalance, berr := parseAccountAmount(global.Balance, "t_user_expense.balance")
- if berr != nil {
- return nil, berr
- }
- out.TotalBalance = globalBalance
- // specialExpenses / cumulativeIncome 默认取**全局**列,有校区账户时被校区值覆盖
- globalSpecial, serr := parseAccountAmount(global.SpecialExpenses, "t_user_expense.special_expenses")
- if serr != nil {
- return nil, serr
- }
- globalIncome, ierr := parseAccountAmount(global.TotalIncome, "t_user_expense.total_income")
- if ierr != nil {
- return nil, ierr
- }
- out.SpecialExpenses = globalSpecial
- out.CumulativeIncome = globalIncome
- if campusID <= 0 {
- return out, nil
- }
- if instID <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: 指定 campus_id 时还需要 institution_id(或作用域机构)"))
- }
- campus, cErr := s.expense.GetCampusAccount(ctx, nil, userID, instID, campusID)
- if cErr != nil {
- if isNotFoundErr(cErr) {
- // 该校区未建户 ⇒ campusBalance 保持 "0.00"(扁平形状下无法用 null 表达"未建户",
- // 这是扁平化的**已知代价**:前端要区分"未建户"与"余额 0"需另加标志位,本期不加)
- return out, nil
- }
- return nil, cErr
- }
- // campusBalance ← t_user_expense_school.balance;special/income 也改取校区列(校区视图口径)
- campusBalance, cberr := parseAccountAmount(campus.Balance, "t_user_expense_school.balance")
- if cberr != nil {
- return nil, cberr
- }
- out.CampusBalance = campusBalance
- campusSpecial, cserr := parseAccountAmount(campus.SpecialExpenses, "t_user_expense_school.special_expenses")
- if cserr != nil {
- return nil, cserr
- }
- campusIncome, cierr := parseAccountAmount(campus.TotalIncome, "t_user_expense_school.total_income")
- if cierr != nil {
- return nil, cierr
- }
- out.SpecialExpenses = campusSpecial
- out.CumulativeIncome = campusIncome
- return out, nil
- }
- // ---------------------------------------------------------------- 端点 11:流水列表
- // ListLogsInput 流水列表入参(端点 11 `GET /admin/finance/logs`)。
- //
- // 字段:
- // - UserID 用户(**非超管必填**,见 ListLogs 注释);
- // - Type 流水类型(nil 不筛);
- // - From / To `create_time` 闭区间(nil 不筛);
- // - Page / PageSize 分页(<=0 → ClampPage 归一;>200 → 200)。
- type ListLogsInput struct {
- // UserID 用户(ULID;非超管必填)。
- UserID string
- // Type 流水类型(model.ExpenseLogType*;nil 不筛)。
- Type *int32
- // From 起始时间(闭区间;nil 不筛)。
- From *time.Time
- // To 结束时间(闭区间;nil 不筛)。
- To *time.Time
- // Page 页码。
- Page int
- // PageSize 每页条数。
- PageSize int
- }
- // LogView 流水分页行(端点 11 响应 list 元素)。
- //
- // 安全:只暴露对账必需列;`remark` 之外的自由文本(如 `t_order_pay.pay_return_data`、
- // 工单 `imgs`)**不在本视图内**(AC-12 验收第 ⑥ 条)。
- type LogView struct {
- // ID 流水 ID。
- ID int64 `json:"id"`
- // UserID 用户(ULID)。
- UserID string `json:"userId"`
- // Type 流水类型(model.ExpenseLogType*)。
- Type int32 `json:"type"`
- // LogID 业务单号(旧 logid;可空 → "")。
- LogID string `json:"logId"`
- // Money 变更金额(元;符号表方向)。
- Money money.Amount `json:"money"`
- // BalanceAfter 该操作完成后**全局账户**余额快照(历史行 NULL → null)。
- BalanceAfter *money.Amount `json:"balanceAfter"`
- // Remark 备注(脱敏后透出;写入侧已禁止敏感信息)。
- Remark string `json:"remark"`
- // CreateTime 业务时间(§3.8-E2 ①类)。
- CreateTime time.Time `json:"createTime"`
- }
- // ListLogs 分页查流水(端点 11)。
- //
- // 入参:ctx;in ListLogsInput。
- // 出参:([]LogView, total, error);错误码 2001(缺 userId 且非超管)/1004/5002。
- //
- // **作用域红线(必须读)**:`t_user_expense_log` **没有** institution_id / campus_id
- // (§3.8-f 约束 2),因此"跨用户查全机构流水"在数据层**无法**按机构过滤——一旦放开,
- // 普通财务就能读到**别的机构**用户的流水(真实越权)。
- // 处置:**非超管必须带 userId**(否则 2001),并由 member 断言该用户属于本机构;
- // 超管可跨用户(但仍**不**跨库过滤,需要机构维度请按 userId 逐用户查)。
- // 这不是"少做一步",而是把"做不到"显式化(2001 + 明确文案),绝不静默返回全量。
- func (s *FinanceQueryService) ListLogs(ctx context.Context, in ListLogsInput) ([]LogView, int64, error) {
- userID := strings.TrimSpace(in.UserID)
- isSuper := s.sc != nil && s.sc.SuperAdmin
- if userID == "" {
- if !isSuper {
- return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: ListLogs 非超管必须指定 userId(流水表无机构列,无法按机构过滤,禁止全量导出)"))
- }
- } else if err := s.assertMember(ctx, userID, 0, 0); err != nil {
- return nil, 0, err
- }
- if in.From != nil && in.To != nil && in.From.After(*in.To) {
- return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: ListLogs 的 from 不能晚于 to"))
- }
- page := repository.PageQuery{Page: in.Page, PageSize: in.PageSize}
- rows, total, err := s.expense.ListLogsFiltered(ctx, repository.LogFilter{
- UserID: userID, Type: in.Type, From: in.From, To: in.To,
- }, page)
- if err != nil {
- return nil, 0, err
- }
- views := make([]LogView, 0, len(rows))
- for i := range rows {
- view, vErr := buildLogView(&rows[i])
- if vErr != nil {
- return nil, 0, vErr
- }
- views = append(views, *view)
- }
- return views, total, nil
- }
- // buildLogView 把流水行转成视图(金额定点解析 + 空值归一)。
- //
- // 入参:row 流水行(非 nil)。出参:*LogView;金额非法 → ErrDBError。
- func buildLogView(row *model.UserExpenseLog) (*LogView, error) {
- amount, err := money.Parse(row.Money)
- if err != nil {
- return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- view := &LogView{
- ID: row.ID, UserID: row.UserID, Type: row.Type, Money: amount, CreateTime: row.CreateTime,
- }
- if row.LogID != nil {
- view.LogID = *row.LogID
- }
- if row.Remark != nil {
- view.Remark = *row.Remark
- }
- if row.BalanceAfter != nil {
- after, perr := money.Parse(*row.BalanceAfter)
- if perr != nil {
- return nil, infraerrors.WithCause(infraerrors.ErrDBError, perr)
- }
- view.BalanceAfter = &after
- }
- return view, nil
- }
- // ---------------------------------------------------------------- 端点 12:工单列表
- // ListRequestsInput 工单列表入参(端点 12 `GET /admin/finance/requests`)。
- type ListRequestsInput struct {
- // Type 工单类型(100/101/202/203/300;nil 不筛)。
- Type *int32
- // Approved 审批状态(-2/-1/0/1;nil 不筛)。
- Approved *int8
- // UserID 用户(空 = 本机构全部)。
- UserID string
- // Page / PageSize 分页。
- Page int
- PageSize int
- }
- // RequestView 工单列表行(端点 12 响应 list 元素)。
- //
- // 安全:**不含 `imgs`**(凭证图片可能含个人信息/银行流水,model 侧已 `json:"-"`,
- // 这里同样不纳入视图);如需下载凭证,走单独的受控端点(本期不做)。
- type RequestView struct {
- // ID 工单 ID。
- ID int64 `json:"id"`
- // BizNo 业务单号(对外单号;幂等锚点)。
- BizNo string `json:"bizNo"`
- // Type 工单类型。
- Type int32 `json:"type"`
- // UserID 用户。
- UserID string `json:"userId"`
- // InstitutionID 机构 ID。
- InstitutionID int64 `json:"institutionId"`
- // CampusID 校区 ID。
- CampusID int64 `json:"campusId"`
- // ToInstitutionID 转入机构(仅 type=300)。
- ToInstitutionID *int64 `json:"toInstitutionId"`
- // ToCampusID 转入校区(仅 type=300)。
- ToCampusID *int64 `json:"toCampusId"`
- // Money 金额(元)。
- Money money.Amount `json:"money"`
- // TransferAccountsType 收款方式(0/1/2/3)。
- TransferAccountsType int8 `json:"transferAccountsType"`
- // OrderNo 交易单号(可空)。
- OrderNo string `json:"orderNo"`
- // Item 项目(可空)。
- Item string `json:"item"`
- // Remark 备注(可空)。
- Remark string `json:"remark"`
- // ApplicantID 申请人(← `request_user_id`;契约名 `applicantId`,语义听 dump:`request_user_id`)。
- ApplicantID string `json:"applicantId"`
- // RequestTime 申请时间(← **`request_time`**,业务时间;可空)。
- RequestTime *time.Time `json:"requestTime"`
- // ApproverID 审批人(← `response_user_id`;**未审批时为 null**,契约名 `approverId`)。
- ApproverID *string `json:"approverId"`
- // ResponseTime 审批时间(← **`response_time`**,业务时间;未审批为 null)。
- ResponseTime *time.Time `json:"responseTime"`
- // ResponseMsg 审批意见(可空)。
- ResponseMsg string `json:"responseMsg"`
- // Approved 审批状态(-2/-1/0/1)。
- Approved int8 `json:"approved"`
- // ⚠ **不下发 `createdAt`**(契约 v1.5 裁决):
- // `created_at` 是**迁移期新加的入库时间列**(`DEFAULT CURRENT_TIMESTAMP(3)`),
- // 迁移后**所有历史工单的值都是"迁移执行那一刻"**——下发会让前端把全部历史工单排在同一天。
- // 它不是业务时间,**任何端点都不得当业务时间下发**(要业务时间用 `requestTime`)。
- }
- // ListRequests 分页查工单(端点 12)。
- //
- // 入参:ctx;in ListRequestsInput。出参:([]RequestView, total, error);2001/5002。
- //
- // 作用域:仓储经 `Scoped`(普通角色只见本机构;超管全量)——工单表**有** institution_id,
- // 所以这里不需要 ListLogs 那种"非超管必须带 userId"的兜底。
- func (s *FinanceQueryService) ListRequests(ctx context.Context, in ListRequestsInput) ([]RequestView, int64, error) {
- page := repository.PageQuery{Page: in.Page, PageSize: in.PageSize}
- rows, total, err := s.requests.ListFiltered(ctx, repository.RequestFilter{
- Type: in.Type, Approved: in.Approved, UserID: strings.TrimSpace(in.UserID),
- }, page)
- if err != nil {
- return nil, 0, err
- }
- views := make([]RequestView, 0, len(rows))
- for i := range rows {
- view, vErr := buildRequestView(&rows[i])
- if vErr != nil {
- return nil, 0, vErr
- }
- views = append(views, *view)
- }
- return views, total, nil
- }
- // buildRequestView 把工单行转成视图(金额定点解析 + 可空列归一)。
- //
- // 入参:row 工单行(非 nil)。出参:*RequestView;金额非法 → ErrDBError。
- func buildRequestView(row *model.UserBalanceRequest) (*RequestView, error) {
- amount, err := money.Parse(row.Money)
- if err != nil {
- return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- view := &RequestView{
- ID: row.ID, BizNo: row.BizNo, Type: row.Type, UserID: row.UserID,
- InstitutionID: row.InstitutionID, CampusID: row.CampusID,
- ToInstitutionID: row.ToInstitutionID, ToCampusID: row.ToCampusID,
- Money: amount, TransferAccountsType: row.TransferAccountsType,
- ApplicantID: row.RequestUserID, ApproverID: row.ResponseUserID,
- RequestTime: row.RequestTime, ResponseTime: row.ResponseTime, Approved: row.Approved,
- }
- if row.OrderNo != nil {
- view.OrderNo = *row.OrderNo
- }
- if row.Item != nil {
- view.Item = *row.Item
- }
- if row.Remark != nil {
- view.Remark = *row.Remark
- }
- // approverId ← response_user_id(未审批 → null,用指针保留"未审批"语义)
- view.ApproverID = row.ResponseUserID
- if row.ResponseMsg != nil {
- view.ResponseMsg = *row.ResponseMsg
- }
- return view, nil
- }
- // ---------------------------------------------------------------- 端点 21:仪表盘(P2)
- // DashboardInput 仪表盘入参(端点 21 `GET /admin/finance/dashboard`)。
- type DashboardInput struct {
- // From / To 统计窗口回显(本端点按"账户累计列"聚合,窗口仅回显 + 供前端对齐筛选器,见 Dashboard 注释)。
- From time.Time
- To time.Time
- // CampusID 校区 ID(>0 只统计该校区;0 = 全机构)。
- CampusID int64
- }
- // DashboardResult 仪表盘结果(余额分布 + 累计收支)。
- //
- // ⚠ 口径(P2 简化,已在设计 §4.1 #21「P2,可延后」范围内):
- // `t_user_expense_log` 无 campus 列 ⇒ **无法**按校区/机构聚合"区间发生额"(§3.8-f 约束 2)。
- // 因此本结果是**账户累计列**的横截面(total_income / total_expenditure / balance / special_expenses),
- // **不是** [From, To] 区间增量。`from`/`to` 仅回显,供前端呈现筛选器状态;
- // 若产品确需"区间发生额分布",须先给出"流水 → 机构/校区"的归属路径(当前 schema 不支持),
- // 属跨域建模变更,不在本期。
- type DashboardResult struct {
- // From / To 回显窗口。
- From time.Time `json:"from"`
- To time.Time `json:"to"`
- // CampusID 统计维度(0 = 全机构)。
- CampusID int64 `json:"campusId"`
- // Accounts 参与统计的校区账户数。
- Accounts int64 `json:"accounts"`
- // Balance 校区余额合计(元)。
- Balance money.Amount `json:"balance"`
- // TotalIncome 累计充值合计(元)。
- TotalIncome money.Amount `json:"totalIncome"`
- // TotalExpenditure 累计消费合计(元)。
- TotalExpenditure money.Amount `json:"totalExpenditure"`
- // SpecialExpenses 特殊费用合计(元)。
- SpecialExpenses money.Amount `json:"specialExpenses"`
- // NetReceivable 应收净额 = TotalIncome − TotalExpenditure(元;**派生列**,便于看板直接展示)。
- NetReceivable money.Amount `json:"netReceivable"`
- // BalanceDistribution 各校区余额分布(契约 v1.5 唯一可算项):
- // ← `t_user_expense_school.balance` 按 `campus_id` 聚合(该表**有** schoolid/campusid)。
- // 空 → `[]`。
- BalanceDistribution []CampusBalanceItem `json:"balanceDistribution"`
- // FlowsByCampusSupported 恒 `false`:明确告知前端"本端点不支持按区间/校区拆分流水",
- // 免得前端以为字段缺失是自己拼错(口径见上方类型注释)。
- FlowsByCampusSupported bool `json:"flowsByCampusSupported"`
- }
- // CampusBalanceItem 单校区余额(端点 21 的 `balanceDistribution` 元素)。
- type CampusBalanceItem struct {
- // CampusID 校区 ID(← `t_user_expense_school.campus_id`;0 = 机构级账户)。
- CampusID int64 `json:"campusId"`
- // Balance 余额(元)← `t_user_expense_school.balance`。
- Balance money.Amount `json:"balance"`
- }
- // Dashboard 财务仪表盘(端点 21,P2)。
- //
- // 入参:ctx;in DashboardInput(From/To 必填;CampusID 可选)。
- // 出参:*DashboardResult;2001(缺时间窗)/1004/5002。
- func (s *FinanceQueryService) Dashboard(ctx context.Context, in DashboardInput) (*DashboardResult, error) {
- if in.From.IsZero() || in.To.IsZero() {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: Dashboard 需要 from/to"))
- }
- if in.From.After(in.To) {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: Dashboard 的 from 不能晚于 to"))
- }
- if s.sc == nil {
- return nil, infraerrors.WithCause(infraerrors.ErrUnauth,
- errString("service: Dashboard 需要请求作用域(机构维度不可缺省)"))
- }
- agg, err := s.expense.AggregateCampusAccounts(ctx, s.sc.InstitutionID, in.CampusID)
- if err != nil {
- return nil, err
- }
- rows, derr := s.expense.ListCampusBalances(ctx, s.sc.InstitutionID, in.CampusID)
- if derr != nil {
- return nil, derr
- }
- dist := make([]CampusBalanceItem, 0, len(rows))
- for i := range rows {
- dist = append(dist, CampusBalanceItem{CampusID: rows[i].CampusID, Balance: rows[i].Balance})
- }
- return &DashboardResult{
- From: in.From, To: in.To, CampusID: in.CampusID, Accounts: agg.Accounts,
- BalanceDistribution: dist,
- Balance: agg.Balance, TotalIncome: agg.TotalIncome,
- TotalExpenditure: agg.TotalExpenditure, SpecialExpenses: agg.SpecialExpenses,
- NetReceivable: agg.TotalIncome.Sub(agg.TotalExpenditure),
- FlowsByCampusSupported: false,
- }, nil
- }
- // ---------------------------------------------------------------- 共享断言
- // assertMember 对象级越权断言(全局钱包/流水无作用域列,必须在这里断)。
- //
- // 入参:ctx;userID 目标用户;institutionID/campusID 作用域维度(0 = 不限/取作用域)。
- // 出参:nil 通过;ErrUnscoped(1004) 越界;ErrDBError。
- //
- // 三种放行情形(顺序即优先级):
- // 1. member 未注入(双跑脚本/系统任务)→ 跳过(**这是唯一的"不断言"入口**,评审可见);
- // 2. 作用域为超管 → 跳过;
- // 3. 其余 → `member.AssertInScope(userID, 机构, 校区)`。
- // 机构/校区为 0 时由实现方按"机构内任一校区"语义判定(port 契约已写明)。
- func (s *FinanceQueryService) assertMember(
- ctx context.Context, userID string, institutionID, campusID int64,
- ) error {
- if s.member == nil {
- return nil
- }
- if s.sc != nil && s.sc.SuperAdmin {
- return nil
- }
- instID, campID := institutionID, campusID
- if instID <= 0 && s.sc != nil {
- instID = s.sc.InstitutionID
- }
- if campID <= 0 && s.sc != nil {
- campID = s.sc.CampusID
- }
- return s.member.AssertInScope(ctx, userID, instID, campID)
- }
|