// 财务只读查询服务(端点 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) }