finance_query_service.go 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
  1. // 财务只读查询服务(端点 1 / 11 / 12 / 21 的编排层)。
  2. //
  3. // 依据:unified/03-migration/academic-domain-design.md
  4. // - §4.1 端点 1(账户快照)、11(流水列表)、12(工单列表)、21(仪表盘,P2)
  5. // - §3.8-f 约束 2:「`t_user_expense_log` **无 campus 维度 ⇒ 恒等式/汇总只能按 user 做**」
  6. // —— 这条约束直接决定了本服务两个"必须显式拒绝"的边界,见 ListLogs / Dashboard 注释。
  7. // - §1.7 例外一:全局钱包与流水无作用域列,**越权断言必须在 Service 层**(不能靠 Scoped)。
  8. //
  9. // 为什么单独一个查询服务(而不是塞进 handler 或 FundService):
  10. // - Handler 层纪律是"不碰 DB"(infra/scope 包注释、org/identity handler 同款);
  11. // - FundService 是**资金写路径**(每个方法都在事务里改余额),把 4 个只读聚合塞进去会
  12. // 让"这个服务会不会动钱"变得不可一眼判断(评审成本最高的一类退化)。
  13. // 本服务**全程只读、不开事务**(唯一例外是它调用的仓储方法本身不开事务)。
  14. package service
  15. import (
  16. "context"
  17. "strings"
  18. "time"
  19. "gorm.io/gorm"
  20. "yuansiwei/unified-backend/internal/academic/model"
  21. "yuansiwei/unified-backend/internal/academic/money"
  22. "yuansiwei/unified-backend/internal/academic/port"
  23. "yuansiwei/unified-backend/internal/academic/repository"
  24. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  25. appscope "yuansiwei/unified-backend/internal/infra/scope"
  26. )
  27. // FinanceQueryService 财务只读查询服务(端点 1 / 11 / 12 / 21)。
  28. //
  29. // 字段:
  30. // - expense 余额账户仓储(全局钱包 / 校区账户 / 流水);
  31. // - requests 余额工单仓储(工单列表);
  32. // - member 成员校验器(可 nil:nil 时跳过对象级越权断言,仅限双跑/回填脚本);
  33. // - sc 请求作用域(可 nil:系统任务视角)。
  34. type FinanceQueryService struct {
  35. expense *repository.ExpenseRepo
  36. requests *repository.BalanceRequestRepo
  37. member port.MembershipChecker
  38. sc *appscope.Scope
  39. }
  40. // NewFinanceQueryService 构造财务只读查询服务。
  41. //
  42. // 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域);member port.MembershipChecker(可 nil)。
  43. // 出参:*FinanceQueryService。
  44. func NewFinanceQueryService(db *gorm.DB, sc *appscope.Scope, member port.MembershipChecker) *FinanceQueryService {
  45. return &FinanceQueryService{
  46. expense: repository.NewExpenseRepo(db, sc),
  47. requests: repository.NewBalanceRequestRepo(db, sc),
  48. member: member,
  49. sc: sc,
  50. }
  51. }
  52. // ---------------------------------------------------------------- 端点 1:账户快照
  53. // AccountSnapshot 账户快照(端点 1 `GET /admin/finance/accounts/:userId`)。
  54. //
  55. // 语义:**全局钱包** + (可选)**指定校区账户**两段。金额全部 `money.Amount`(两位小数字符串输出)。
  56. type AccountSnapshot struct {
  57. // UserID 用户(ULID)。
  58. UserID string `json:"userId"`
  59. // Global 全局账户(跨校区钱包;`t_user_expense`)。
  60. Global AccountBalanceView `json:"global"`
  61. // Campus 指定校区账户;**未指定校区或账户不存在时为 null**(不是"零余额对象"——
  62. // 前端须用 null 判断"该校区还没建户",与余额 0 区分开)。
  63. Campus *CampusAccountBalanceView `json:"campus"`
  64. }
  65. // AccountBalanceView 全局账户余额视图(`t_user_expense` 的 6 个金额列)。
  66. type AccountBalanceView struct {
  67. // Balance 余额(元,±;负数=欠费)。
  68. Balance money.Amount `json:"balance"`
  69. // Spend 上课消费课费单价之和(元;只增)。
  70. Spend money.Amount `json:"spend"`
  71. // TotalIncome 累计充值(元)。
  72. TotalIncome money.Amount `json:"totalIncome"`
  73. // TotalExpenditure 累计消费(元)。
  74. TotalExpenditure money.Amount `json:"totalExpenditure"`
  75. // TotalRecommendedAmount 累计推荐金额(元)。
  76. TotalRecommendedAmount money.Amount `json:"totalRecommendedAmount"`
  77. // SpecialExpenses 特殊费用(元;坏账/清零/提前退费累计)。
  78. SpecialExpenses money.Amount `json:"specialExpenses"`
  79. }
  80. // CampusAccountBalanceView 校区账户余额视图(`t_user_expense_school` 的 6 个金额列 + 定位)。
  81. type CampusAccountBalanceView struct {
  82. // InstitutionID 机构 ID。
  83. InstitutionID int64 `json:"institutionId"`
  84. // CampusID 校区 ID(0=机构级账户)。
  85. CampusID int64 `json:"campusId"`
  86. // AccountID 账户行 ID(对账时比余额更精确的锚点)。
  87. AccountID int64 `json:"accountId"`
  88. // Balance 校区余额(元,±)。
  89. Balance money.Amount `json:"balance"`
  90. // Spend 校区上课消费课费单价之和(元)。
  91. Spend money.Amount `json:"spend"`
  92. // TotalIncome 校区累计充值(元)。
  93. TotalIncome money.Amount `json:"totalIncome"`
  94. // TotalExpenditure 校区累计消费(元)。
  95. TotalExpenditure money.Amount `json:"totalExpenditure"`
  96. // TotalRecommendedAmount 校区累计推荐金额(元)。
  97. TotalRecommendedAmount money.Amount `json:"totalRecommendedAmount"`
  98. // SpecialExpenses 校区特殊费用(元)。
  99. SpecialExpenses money.Amount `json:"specialExpenses"`
  100. }
  101. // AccountSnapshot 读账户快照(端点 1)。
  102. //
  103. // 入参:
  104. // - ctx 请求上下文;
  105. // - userID 用户(ULID;必填);
  106. // - institutionID 机构 ID(可选:>0 时作为越权判定输入与校区账户定位维度);
  107. // - campusID 校区 ID(可选:>0 时附带该校区账户;=0 则不返回 campus 段)。
  108. //
  109. // 出参:
  110. // - *AccountSnapshot;全局账户不存在时 Global 全 0 且 **不报错**(新用户尚未建户,
  111. // 与 dump 的"账户不存在 → 余额 0"同口径;前端按 0 展示);
  112. // - ErrBadParam(2001) 缺 user_id / ErrUnscoped(1004) 越权 / ErrDBError(5002)。
  113. //
  114. // 越权:全局钱包无作用域列(§1.7 例外一)⇒ **必须**在这里经 member 断言该用户属于本机构;
  115. // 未注入 member(双跑脚本)时跳过——这条跳过的后果写进注释,评审可一眼看到边界。
  116. func (s *FinanceQueryService) AccountSnapshot(
  117. ctx context.Context, userID string, institutionID, campusID int64,
  118. ) (*AccountSnapshot, error) {
  119. if strings.TrimSpace(userID) == "" {
  120. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: AccountSnapshot 需要 user_id"))
  121. }
  122. if err := s.assertMember(ctx, userID, institutionID, campusID); err != nil {
  123. return nil, err
  124. }
  125. out := &AccountSnapshot{UserID: userID}
  126. global, err := s.expense.GetGlobalAccount(ctx, nil, userID)
  127. if err != nil {
  128. if isNotFoundErr(err) {
  129. // 账户不存在:全 0(dump 的 ISNULL 归零分支),不是错误
  130. return out, nil
  131. }
  132. return nil, err
  133. }
  134. out.Global, err = buildAccountBalanceView(global)
  135. if err != nil {
  136. return nil, err
  137. }
  138. if campusID <= 0 {
  139. return out, nil
  140. }
  141. instID := institutionID
  142. if instID <= 0 && s.sc != nil {
  143. instID = s.sc.InstitutionID
  144. }
  145. if instID <= 0 {
  146. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  147. errString("service: 指定 campus_id 时还需要 institution_id(或作用域机构)"))
  148. }
  149. campus, cErr := s.expense.GetCampusAccount(ctx, nil, userID, instID, campusID)
  150. if cErr != nil {
  151. if isNotFoundErr(cErr) {
  152. return out, nil // 该校区未建户 → campus = null(与"余额 0"区分)
  153. }
  154. return nil, cErr
  155. }
  156. view, vErr := buildCampusAccountBalanceView(campus)
  157. if vErr != nil {
  158. return nil, vErr
  159. }
  160. out.Campus = view
  161. return out, nil
  162. }
  163. // buildAccountBalanceView 把全局账户行转成金额视图(逐列定点解析)。
  164. //
  165. // 入参:row 全局账户行(非 nil)。出参:AccountBalanceView;列金额非法 → ErrDBError。
  166. func buildAccountBalanceView(row *model.UserExpense) (AccountBalanceView, error) {
  167. balance, err := parseAccountAmount(row.Balance, "t_user_expense.balance")
  168. if err != nil {
  169. return AccountBalanceView{}, err
  170. }
  171. spend, err := parseAccountAmount(row.Spend, "t_user_expense.spend")
  172. if err != nil {
  173. return AccountBalanceView{}, err
  174. }
  175. income, err := parseAccountAmount(row.TotalIncome, "t_user_expense.total_income")
  176. if err != nil {
  177. return AccountBalanceView{}, err
  178. }
  179. expenditure, err := parseAccountAmount(row.TotalExpenditure, "t_user_expense.total_expenditure")
  180. if err != nil {
  181. return AccountBalanceView{}, err
  182. }
  183. recommended, err := parseAccountAmount(row.TotalRecommendedAmount, "t_user_expense.total_recommended_amount")
  184. if err != nil {
  185. return AccountBalanceView{}, err
  186. }
  187. special, err := parseAccountAmount(row.SpecialExpenses, "t_user_expense.special_expenses")
  188. if err != nil {
  189. return AccountBalanceView{}, err
  190. }
  191. return AccountBalanceView{
  192. Balance: balance, Spend: spend, TotalIncome: income,
  193. TotalExpenditure: expenditure, TotalRecommendedAmount: recommended, SpecialExpenses: special,
  194. }, nil
  195. }
  196. // buildCampusAccountBalanceView 把校区账户行转成金额视图(逐列定点解析)。
  197. //
  198. // 入参:row 校区账户行(非 nil)。出参:*CampusAccountBalanceView;列金额非法 → ErrDBError。
  199. func buildCampusAccountBalanceView(row *model.UserExpenseSchool) (*CampusAccountBalanceView, error) {
  200. balance, err := parseAccountAmount(row.Balance, "t_user_expense_school.balance")
  201. if err != nil {
  202. return nil, err
  203. }
  204. spend, err := parseAccountAmount(row.Spend, "t_user_expense_school.spend")
  205. if err != nil {
  206. return nil, err
  207. }
  208. income, err := parseAccountAmount(row.TotalIncome, "t_user_expense_school.total_income")
  209. if err != nil {
  210. return nil, err
  211. }
  212. expenditure, err := parseAccountAmount(row.TotalExpenditure, "t_user_expense_school.total_expenditure")
  213. if err != nil {
  214. return nil, err
  215. }
  216. recommended, err := parseAccountAmount(row.TotalRecommendedAmount, "t_user_expense_school.total_recommended_amount")
  217. if err != nil {
  218. return nil, err
  219. }
  220. special, err := parseAccountAmount(row.SpecialExpenses, "t_user_expense_school.special_expenses")
  221. if err != nil {
  222. return nil, err
  223. }
  224. return &CampusAccountBalanceView{
  225. InstitutionID: row.InstitutionID, CampusID: row.CampusID, AccountID: row.ID,
  226. Balance: balance, Spend: spend, TotalIncome: income,
  227. TotalExpenditure: expenditure, TotalRecommendedAmount: recommended, SpecialExpenses: special,
  228. }, nil
  229. }
  230. // ---------------------------------------------------------------- 端点 11:流水列表
  231. // ListLogsInput 流水列表入参(端点 11 `GET /admin/finance/logs`)。
  232. //
  233. // 字段:
  234. // - UserID 用户(**非超管必填**,见 ListLogs 注释);
  235. // - Type 流水类型(nil 不筛);
  236. // - From / To `create_time` 闭区间(nil 不筛);
  237. // - Page / PageSize 分页(<=0 → ClampPage 归一;>200 → 200)。
  238. type ListLogsInput struct {
  239. // UserID 用户(ULID;非超管必填)。
  240. UserID string
  241. // Type 流水类型(model.ExpenseLogType*;nil 不筛)。
  242. Type *int32
  243. // From 起始时间(闭区间;nil 不筛)。
  244. From *time.Time
  245. // To 结束时间(闭区间;nil 不筛)。
  246. To *time.Time
  247. // Page 页码。
  248. Page int
  249. // PageSize 每页条数。
  250. PageSize int
  251. }
  252. // LogView 流水分页行(端点 11 响应 list 元素)。
  253. //
  254. // 安全:只暴露对账必需列;`remark` 之外的自由文本(如 `t_order_pay.pay_return_data`、
  255. // 工单 `imgs`)**不在本视图内**(AC-12 验收第 ⑥ 条)。
  256. type LogView struct {
  257. // ID 流水 ID。
  258. ID int64 `json:"id"`
  259. // UserID 用户(ULID)。
  260. UserID string `json:"userId"`
  261. // Type 流水类型(model.ExpenseLogType*)。
  262. Type int32 `json:"type"`
  263. // LogID 业务单号(旧 logid;可空 → "")。
  264. LogID string `json:"logId"`
  265. // Money 变更金额(元;符号表方向)。
  266. Money money.Amount `json:"money"`
  267. // BalanceAfter 该操作完成后**全局账户**余额快照(历史行 NULL → null)。
  268. BalanceAfter *money.Amount `json:"balanceAfter"`
  269. // Remark 备注(脱敏后透出;写入侧已禁止敏感信息)。
  270. Remark string `json:"remark"`
  271. // CreateTime 业务时间(§3.8-E2 ①类)。
  272. CreateTime time.Time `json:"createTime"`
  273. }
  274. // ListLogs 分页查流水(端点 11)。
  275. //
  276. // 入参:ctx;in ListLogsInput。
  277. // 出参:([]LogView, total, error);错误码 2001(缺 userId 且非超管)/1004/5002。
  278. //
  279. // **作用域红线(必须读)**:`t_user_expense_log` **没有** institution_id / campus_id
  280. // (§3.8-f 约束 2),因此"跨用户查全机构流水"在数据层**无法**按机构过滤——一旦放开,
  281. // 普通财务就能读到**别的机构**用户的流水(真实越权)。
  282. // 处置:**非超管必须带 userId**(否则 2001),并由 member 断言该用户属于本机构;
  283. // 超管可跨用户(但仍**不**跨库过滤,需要机构维度请按 userId 逐用户查)。
  284. // 这不是"少做一步",而是把"做不到"显式化(2001 + 明确文案),绝不静默返回全量。
  285. func (s *FinanceQueryService) ListLogs(ctx context.Context, in ListLogsInput) ([]LogView, int64, error) {
  286. userID := strings.TrimSpace(in.UserID)
  287. isSuper := s.sc != nil && s.sc.SuperAdmin
  288. if userID == "" {
  289. if !isSuper {
  290. return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  291. errString("service: ListLogs 非超管必须指定 userId(流水表无机构列,无法按机构过滤,禁止全量导出)"))
  292. }
  293. } else if err := s.assertMember(ctx, userID, 0, 0); err != nil {
  294. return nil, 0, err
  295. }
  296. if in.From != nil && in.To != nil && in.From.After(*in.To) {
  297. return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  298. errString("service: ListLogs 的 from 不能晚于 to"))
  299. }
  300. page := repository.PageQuery{Page: in.Page, PageSize: in.PageSize}
  301. rows, total, err := s.expense.ListLogsFiltered(ctx, repository.LogFilter{
  302. UserID: userID, Type: in.Type, From: in.From, To: in.To,
  303. }, page)
  304. if err != nil {
  305. return nil, 0, err
  306. }
  307. views := make([]LogView, 0, len(rows))
  308. for i := range rows {
  309. view, vErr := buildLogView(&rows[i])
  310. if vErr != nil {
  311. return nil, 0, vErr
  312. }
  313. views = append(views, *view)
  314. }
  315. return views, total, nil
  316. }
  317. // buildLogView 把流水行转成视图(金额定点解析 + 空值归一)。
  318. //
  319. // 入参:row 流水行(非 nil)。出参:*LogView;金额非法 → ErrDBError。
  320. func buildLogView(row *model.UserExpenseLog) (*LogView, error) {
  321. amount, err := money.Parse(row.Money)
  322. if err != nil {
  323. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  324. }
  325. view := &LogView{
  326. ID: row.ID, UserID: row.UserID, Type: row.Type, Money: amount, CreateTime: row.CreateTime,
  327. }
  328. if row.LogID != nil {
  329. view.LogID = *row.LogID
  330. }
  331. if row.Remark != nil {
  332. view.Remark = *row.Remark
  333. }
  334. if row.BalanceAfter != nil {
  335. after, perr := money.Parse(*row.BalanceAfter)
  336. if perr != nil {
  337. return nil, infraerrors.WithCause(infraerrors.ErrDBError, perr)
  338. }
  339. view.BalanceAfter = &after
  340. }
  341. return view, nil
  342. }
  343. // ---------------------------------------------------------------- 端点 12:工单列表
  344. // ListRequestsInput 工单列表入参(端点 12 `GET /admin/finance/requests`)。
  345. type ListRequestsInput struct {
  346. // Type 工单类型(100/101/202/203/300;nil 不筛)。
  347. Type *int32
  348. // Approved 审批状态(-2/-1/0/1;nil 不筛)。
  349. Approved *int8
  350. // UserID 用户(空 = 本机构全部)。
  351. UserID string
  352. // Page / PageSize 分页。
  353. Page int
  354. PageSize int
  355. }
  356. // RequestView 工单列表行(端点 12 响应 list 元素)。
  357. //
  358. // 安全:**不含 `imgs`**(凭证图片可能含个人信息/银行流水,model 侧已 `json:"-"`,
  359. // 这里同样不纳入视图);如需下载凭证,走单独的受控端点(本期不做)。
  360. type RequestView struct {
  361. // ID 工单 ID。
  362. ID int64 `json:"id"`
  363. // BizNo 业务单号(对外单号;幂等锚点)。
  364. BizNo string `json:"bizNo"`
  365. // Type 工单类型。
  366. Type int32 `json:"type"`
  367. // UserID 用户。
  368. UserID string `json:"userId"`
  369. // InstitutionID 机构 ID。
  370. InstitutionID int64 `json:"institutionId"`
  371. // CampusID 校区 ID。
  372. CampusID int64 `json:"campusId"`
  373. // ToInstitutionID 转入机构(仅 type=300)。
  374. ToInstitutionID *int64 `json:"toInstitutionId"`
  375. // ToCampusID 转入校区(仅 type=300)。
  376. ToCampusID *int64 `json:"toCampusId"`
  377. // Money 金额(元)。
  378. Money money.Amount `json:"money"`
  379. // TransferAccountsType 收款方式(0/1/2/3)。
  380. TransferAccountsType int8 `json:"transferAccountsType"`
  381. // OrderNo 交易单号(可空)。
  382. OrderNo string `json:"orderNo"`
  383. // Item 项目(可空)。
  384. Item string `json:"item"`
  385. // Remark 备注(可空)。
  386. Remark string `json:"remark"`
  387. // RequestUserID 申请人。
  388. RequestUserID string `json:"requestUserId"`
  389. // RequestTime 申请时间(可空)。
  390. RequestTime *time.Time `json:"requestTime"`
  391. // ResponseUserID 审批人(可空)。
  392. ResponseUserID string `json:"responseUserId"`
  393. // ResponseTime 审批时间(可空)。
  394. ResponseTime *time.Time `json:"responseTime"`
  395. // ResponseMsg 审批意见(可空)。
  396. ResponseMsg string `json:"responseMsg"`
  397. // Approved 审批状态(-2/-1/0/1)。
  398. Approved int8 `json:"approved"`
  399. // CreatedAt 创建时间。
  400. CreatedAt time.Time `json:"createdAt"`
  401. }
  402. // ListRequests 分页查工单(端点 12)。
  403. //
  404. // 入参:ctx;in ListRequestsInput。出参:([]RequestView, total, error);2001/5002。
  405. //
  406. // 作用域:仓储经 `Scoped`(普通角色只见本机构;超管全量)——工单表**有** institution_id,
  407. // 所以这里不需要 ListLogs 那种"非超管必须带 userId"的兜底。
  408. func (s *FinanceQueryService) ListRequests(ctx context.Context, in ListRequestsInput) ([]RequestView, int64, error) {
  409. page := repository.PageQuery{Page: in.Page, PageSize: in.PageSize}
  410. rows, total, err := s.requests.ListFiltered(ctx, repository.RequestFilter{
  411. Type: in.Type, Approved: in.Approved, UserID: strings.TrimSpace(in.UserID),
  412. }, page)
  413. if err != nil {
  414. return nil, 0, err
  415. }
  416. views := make([]RequestView, 0, len(rows))
  417. for i := range rows {
  418. view, vErr := buildRequestView(&rows[i])
  419. if vErr != nil {
  420. return nil, 0, vErr
  421. }
  422. views = append(views, *view)
  423. }
  424. return views, total, nil
  425. }
  426. // buildRequestView 把工单行转成视图(金额定点解析 + 可空列归一)。
  427. //
  428. // 入参:row 工单行(非 nil)。出参:*RequestView;金额非法 → ErrDBError。
  429. func buildRequestView(row *model.UserBalanceRequest) (*RequestView, error) {
  430. amount, err := money.Parse(row.Money)
  431. if err != nil {
  432. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  433. }
  434. view := &RequestView{
  435. ID: row.ID, BizNo: row.BizNo, Type: row.Type, UserID: row.UserID,
  436. InstitutionID: row.InstitutionID, CampusID: row.CampusID,
  437. ToInstitutionID: row.ToInstitutionID, ToCampusID: row.ToCampusID,
  438. Money: amount, TransferAccountsType: row.TransferAccountsType,
  439. RequestUserID: row.RequestUserID, RequestTime: row.RequestTime,
  440. ResponseTime: row.ResponseTime, Approved: row.Approved, CreatedAt: row.CreatedAt,
  441. }
  442. if row.OrderNo != nil {
  443. view.OrderNo = *row.OrderNo
  444. }
  445. if row.Item != nil {
  446. view.Item = *row.Item
  447. }
  448. if row.Remark != nil {
  449. view.Remark = *row.Remark
  450. }
  451. if row.ResponseUserID != nil {
  452. view.ResponseUserID = *row.ResponseUserID
  453. }
  454. if row.ResponseMsg != nil {
  455. view.ResponseMsg = *row.ResponseMsg
  456. }
  457. return view, nil
  458. }
  459. // ---------------------------------------------------------------- 端点 21:仪表盘(P2)
  460. // DashboardInput 仪表盘入参(端点 21 `GET /admin/finance/dashboard`)。
  461. type DashboardInput struct {
  462. // From / To 统计窗口回显(本端点按"账户累计列"聚合,窗口仅回显 + 供前端对齐筛选器,见 Dashboard 注释)。
  463. From time.Time
  464. To time.Time
  465. // CampusID 校区 ID(>0 只统计该校区;0 = 全机构)。
  466. CampusID int64
  467. }
  468. // DashboardResult 仪表盘结果(余额分布 + 累计收支)。
  469. //
  470. // ⚠ 口径(P2 简化,已在设计 §4.1 #21「P2,可延后」范围内):
  471. // `t_user_expense_log` 无 campus 列 ⇒ **无法**按校区/机构聚合"区间发生额"(§3.8-f 约束 2)。
  472. // 因此本结果是**账户累计列**的横截面(total_income / total_expenditure / balance / special_expenses),
  473. // **不是** [From, To] 区间增量。`from`/`to` 仅回显,供前端呈现筛选器状态;
  474. // 若产品确需"区间发生额分布",须先给出"流水 → 机构/校区"的归属路径(当前 schema 不支持),
  475. // 属跨域建模变更,不在本期。
  476. type DashboardResult struct {
  477. // From / To 回显窗口。
  478. From time.Time `json:"from"`
  479. To time.Time `json:"to"`
  480. // CampusID 统计维度(0 = 全机构)。
  481. CampusID int64 `json:"campusId"`
  482. // Accounts 参与统计的校区账户数。
  483. Accounts int64 `json:"accounts"`
  484. // Balance 校区余额合计(元)。
  485. Balance money.Amount `json:"balance"`
  486. // TotalIncome 累计充值合计(元)。
  487. TotalIncome money.Amount `json:"totalIncome"`
  488. // TotalExpenditure 累计消费合计(元)。
  489. TotalExpenditure money.Amount `json:"totalExpenditure"`
  490. // SpecialExpenses 特殊费用合计(元)。
  491. SpecialExpenses money.Amount `json:"specialExpenses"`
  492. // NetReceivable 应收净额 = TotalIncome − TotalExpenditure(元;**派生列**,便于看板直接展示)。
  493. NetReceivable money.Amount `json:"netReceivable"`
  494. // FlowsByCampusSupported 恒 `false`:明确告知前端"本端点不支持按区间/校区拆分流水",
  495. // 免得前端以为字段缺失是自己拼错(口径见上方类型注释)。
  496. FlowsByCampusSupported bool `json:"flowsByCampusSupported"`
  497. }
  498. // Dashboard 财务仪表盘(端点 21,P2)。
  499. //
  500. // 入参:ctx;in DashboardInput(From/To 必填;CampusID 可选)。
  501. // 出参:*DashboardResult;2001(缺时间窗)/1004/5002。
  502. func (s *FinanceQueryService) Dashboard(ctx context.Context, in DashboardInput) (*DashboardResult, error) {
  503. if in.From.IsZero() || in.To.IsZero() {
  504. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  505. errString("service: Dashboard 需要 from/to"))
  506. }
  507. if in.From.After(in.To) {
  508. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  509. errString("service: Dashboard 的 from 不能晚于 to"))
  510. }
  511. if s.sc == nil {
  512. return nil, infraerrors.WithCause(infraerrors.ErrUnauth,
  513. errString("service: Dashboard 需要请求作用域(机构维度不可缺省)"))
  514. }
  515. agg, err := s.expense.AggregateCampusAccounts(ctx, s.sc.InstitutionID, in.CampusID)
  516. if err != nil {
  517. return nil, err
  518. }
  519. return &DashboardResult{
  520. From: in.From, To: in.To, CampusID: in.CampusID, Accounts: agg.Accounts,
  521. Balance: agg.Balance, TotalIncome: agg.TotalIncome,
  522. TotalExpenditure: agg.TotalExpenditure, SpecialExpenses: agg.SpecialExpenses,
  523. NetReceivable: agg.TotalIncome.Sub(agg.TotalExpenditure),
  524. FlowsByCampusSupported: false,
  525. }, nil
  526. }
  527. // ---------------------------------------------------------------- 共享断言
  528. // assertMember 对象级越权断言(全局钱包/流水无作用域列,必须在这里断)。
  529. //
  530. // 入参:ctx;userID 目标用户;institutionID/campusID 作用域维度(0 = 不限/取作用域)。
  531. // 出参:nil 通过;ErrUnscoped(1004) 越界;ErrDBError。
  532. //
  533. // 三种放行情形(顺序即优先级):
  534. // 1. member 未注入(双跑脚本/系统任务)→ 跳过(**这是唯一的"不断言"入口**,评审可见);
  535. // 2. 作用域为超管 → 跳过;
  536. // 3. 其余 → `member.AssertInScope(userID, 机构, 校区)`。
  537. // 机构/校区为 0 时由实现方按"机构内任一校区"语义判定(port 契约已写明)。
  538. func (s *FinanceQueryService) assertMember(
  539. ctx context.Context, userID string, institutionID, campusID int64,
  540. ) error {
  541. if s.member == nil {
  542. return nil
  543. }
  544. if s.sc != nil && s.sc.SuperAdmin {
  545. return nil
  546. }
  547. instID, campID := institutionID, campusID
  548. if instID <= 0 && s.sc != nil {
  549. instID = s.sc.InstitutionID
  550. }
  551. if campID <= 0 && s.sc != nil {
  552. campID = s.sc.CampusID
  553. }
  554. return s.member.AssertInScope(ctx, userID, instID, campID)
  555. }