| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586 |
- // 财务只读查询服务(端点 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`)。
- //
- // 语义:**全局钱包** + (可选)**指定校区账户**两段。金额全部 `money.Amount`(两位小数字符串输出)。
- type AccountSnapshot struct {
- // UserID 用户(ULID)。
- UserID string `json:"userId"`
- // Global 全局账户(跨校区钱包;`t_user_expense`)。
- Global AccountBalanceView `json:"global"`
- // Campus 指定校区账户;**未指定校区或账户不存在时为 null**(不是"零余额对象"——
- // 前端须用 null 判断"该校区还没建户",与余额 0 区分开)。
- Campus *CampusAccountBalanceView `json:"campus"`
- }
- // AccountBalanceView 全局账户余额视图(`t_user_expense` 的 6 个金额列)。
- type AccountBalanceView struct {
- // Balance 余额(元,±;负数=欠费)。
- Balance money.Amount `json:"balance"`
- // Spend 上课消费课费单价之和(元;只增)。
- Spend money.Amount `json:"spend"`
- // TotalIncome 累计充值(元)。
- TotalIncome money.Amount `json:"totalIncome"`
- // TotalExpenditure 累计消费(元)。
- TotalExpenditure money.Amount `json:"totalExpenditure"`
- // TotalRecommendedAmount 累计推荐金额(元)。
- TotalRecommendedAmount money.Amount `json:"totalRecommendedAmount"`
- // SpecialExpenses 特殊费用(元;坏账/清零/提前退费累计)。
- SpecialExpenses money.Amount `json:"specialExpenses"`
- }
- // CampusAccountBalanceView 校区账户余额视图(`t_user_expense_school` 的 6 个金额列 + 定位)。
- type CampusAccountBalanceView struct {
- // InstitutionID 机构 ID。
- InstitutionID int64 `json:"institutionId"`
- // CampusID 校区 ID(0=机构级账户)。
- CampusID int64 `json:"campusId"`
- // AccountID 账户行 ID(对账时比余额更精确的锚点)。
- AccountID int64 `json:"accountId"`
- // Balance 校区余额(元,±)。
- Balance money.Amount `json:"balance"`
- // Spend 校区上课消费课费单价之和(元)。
- Spend money.Amount `json:"spend"`
- // TotalIncome 校区累计充值(元)。
- TotalIncome money.Amount `json:"totalIncome"`
- // TotalExpenditure 校区累计消费(元)。
- TotalExpenditure money.Amount `json:"totalExpenditure"`
- // TotalRecommendedAmount 校区累计推荐金额(元)。
- TotalRecommendedAmount money.Amount `json:"totalRecommendedAmount"`
- // SpecialExpenses 校区特殊费用(元)。
- SpecialExpenses money.Amount `json:"specialExpenses"`
- }
- // 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}
- global, err := s.expense.GetGlobalAccount(ctx, nil, userID)
- if err != nil {
- if isNotFoundErr(err) {
- // 账户不存在:全 0(dump 的 ISNULL 归零分支),不是错误
- return out, nil
- }
- return nil, err
- }
- out.Global, err = buildAccountBalanceView(global)
- if err != nil {
- return nil, err
- }
- if campusID <= 0 {
- return out, nil
- }
- instID := institutionID
- if instID <= 0 && s.sc != nil {
- instID = s.sc.InstitutionID
- }
- 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) {
- return out, nil // 该校区未建户 → campus = null(与"余额 0"区分)
- }
- return nil, cErr
- }
- view, vErr := buildCampusAccountBalanceView(campus)
- if vErr != nil {
- return nil, vErr
- }
- out.Campus = view
- return out, nil
- }
- // buildAccountBalanceView 把全局账户行转成金额视图(逐列定点解析)。
- //
- // 入参:row 全局账户行(非 nil)。出参:AccountBalanceView;列金额非法 → ErrDBError。
- func buildAccountBalanceView(row *model.UserExpense) (AccountBalanceView, error) {
- balance, err := parseAccountAmount(row.Balance, "t_user_expense.balance")
- if err != nil {
- return AccountBalanceView{}, err
- }
- spend, err := parseAccountAmount(row.Spend, "t_user_expense.spend")
- if err != nil {
- return AccountBalanceView{}, err
- }
- income, err := parseAccountAmount(row.TotalIncome, "t_user_expense.total_income")
- if err != nil {
- return AccountBalanceView{}, err
- }
- expenditure, err := parseAccountAmount(row.TotalExpenditure, "t_user_expense.total_expenditure")
- if err != nil {
- return AccountBalanceView{}, err
- }
- recommended, err := parseAccountAmount(row.TotalRecommendedAmount, "t_user_expense.total_recommended_amount")
- if err != nil {
- return AccountBalanceView{}, err
- }
- special, err := parseAccountAmount(row.SpecialExpenses, "t_user_expense.special_expenses")
- if err != nil {
- return AccountBalanceView{}, err
- }
- return AccountBalanceView{
- Balance: balance, Spend: spend, TotalIncome: income,
- TotalExpenditure: expenditure, TotalRecommendedAmount: recommended, SpecialExpenses: special,
- }, nil
- }
- // buildCampusAccountBalanceView 把校区账户行转成金额视图(逐列定点解析)。
- //
- // 入参:row 校区账户行(非 nil)。出参:*CampusAccountBalanceView;列金额非法 → ErrDBError。
- func buildCampusAccountBalanceView(row *model.UserExpenseSchool) (*CampusAccountBalanceView, error) {
- balance, err := parseAccountAmount(row.Balance, "t_user_expense_school.balance")
- if err != nil {
- return nil, err
- }
- spend, err := parseAccountAmount(row.Spend, "t_user_expense_school.spend")
- if err != nil {
- return nil, err
- }
- income, err := parseAccountAmount(row.TotalIncome, "t_user_expense_school.total_income")
- if err != nil {
- return nil, err
- }
- expenditure, err := parseAccountAmount(row.TotalExpenditure, "t_user_expense_school.total_expenditure")
- if err != nil {
- return nil, err
- }
- recommended, err := parseAccountAmount(row.TotalRecommendedAmount, "t_user_expense_school.total_recommended_amount")
- if err != nil {
- return nil, err
- }
- special, err := parseAccountAmount(row.SpecialExpenses, "t_user_expense_school.special_expenses")
- if err != nil {
- return nil, err
- }
- return &CampusAccountBalanceView{
- InstitutionID: row.InstitutionID, CampusID: row.CampusID, AccountID: row.ID,
- Balance: balance, Spend: spend, TotalIncome: income,
- TotalExpenditure: expenditure, TotalRecommendedAmount: recommended, SpecialExpenses: special,
- }, 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"`
- // RequestUserID 申请人。
- RequestUserID string `json:"requestUserId"`
- // RequestTime 申请时间(可空)。
- RequestTime *time.Time `json:"requestTime"`
- // ResponseUserID 审批人(可空)。
- ResponseUserID string `json:"responseUserId"`
- // ResponseTime 审批时间(可空)。
- ResponseTime *time.Time `json:"responseTime"`
- // ResponseMsg 审批意见(可空)。
- ResponseMsg string `json:"responseMsg"`
- // Approved 审批状态(-2/-1/0/1)。
- Approved int8 `json:"approved"`
- // CreatedAt 创建时间。
- CreatedAt time.Time `json:"createdAt"`
- }
- // 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,
- RequestUserID: row.RequestUserID, RequestTime: row.RequestTime,
- ResponseTime: row.ResponseTime, Approved: row.Approved, CreatedAt: row.CreatedAt,
- }
- if row.OrderNo != nil {
- view.OrderNo = *row.OrderNo
- }
- if row.Item != nil {
- view.Item = *row.Item
- }
- if row.Remark != nil {
- view.Remark = *row.Remark
- }
- if row.ResponseUserID != nil {
- view.ResponseUserID = *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"`
- // FlowsByCampusSupported 恒 `false`:明确告知前端"本端点不支持按区间/校区拆分流水",
- // 免得前端以为字段缺失是自己拼错(口径见上方类型注释)。
- FlowsByCampusSupported bool `json:"flowsByCampusSupported"`
- }
- // 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
- }
- return &DashboardResult{
- From: in.From, To: in.To, CampusID: in.CampusID, Accounts: agg.Accounts,
- 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)
- }
|