handler.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. // Package handler academic 域 HTTP 接入层(架构规格 §3.2 三层之 Handler,AC-12/AC-13)。
  2. //
  3. // 分层职责(与 identity/org handler 同款,不另起一套):
  4. // - 本包:绑定/校验请求(`validator.Validate` → 2001)→ 从 `scope.FromFiber` 取作用域(取不到 → 1001)
  5. // → **按作用域装配 Service**(academic 的 Service 在构造时绑定 scope)→ 把 `*AppError`
  6. // 映射为统一信封(`response.Fail` / `response.OK` / `response.Page`)。
  7. // **不写业务逻辑、不直接碰 DB**。
  8. // - Service(academic/service):资金 / 退费 / 账单 / 工单编排(事务边界只在这一层,§10.3)。
  9. // - Repository(academic/repository):Scoping 强制过滤 + 只读聚合。
  10. //
  11. // ---------------------------------------------------------------------------
  12. // 路径口径(设计 §4 口径表 v0.1.24,**已冻结**)
  13. // ---------------------------------------------------------------------------
  14. //
  15. // 本包所有路径常量**不含 `/api`**。`/api` 只是**全站外部前缀**(vite `rewrite` / nginx 尾斜杠去掉),
  16. // 不进后端路由表:
  17. //
  18. // 对外请求:/api/admin/finance/recharges
  19. // 后端路由: /admin/finance/recharges ← 本包 Path* 常量就是这一层
  20. //
  21. // academic 只占 3 个一级段:`/admin`、`/student`、`/pay`(`/auth` 属 identity、`/org` 属 org,
  22. // **不得**在此重复注册;AC-14 有一条"一级段白名单"运行期断言专门守这条)。
  23. //
  24. // ---------------------------------------------------------------------------
  25. // 六条接入层纪律(AC-12 验收项,逐条对应实现位置)
  26. // ---------------------------------------------------------------------------
  27. //
  28. // ① 端点覆盖:端点 1~17 + 21 → fund_handler.go(1~16)、bill_handler.go(17、21);
  29. // 端点 22~37(活动)→ activity_handler.go(AC-13b)。
  30. // ② `validateReq` 失败 → **2001**;金额字符串非法 / 枚举非法 → **2002**(两者不串):
  31. // `validator.Validate` 负责 2001(缺参/格式),`parseMoney`/`parseTypeFilter` 负责 2002。
  32. // ③ `scope.FromFiber` 取不到 → **1001**:`mustScope`。
  33. // ④ 金额入参**一律字符串**(DTO 里是 `string`)→ 走 `money.Parse`;**不接受 float64**。
  34. // 这条不是风格问题:JSON number 解码进 `money.Amount` 会经过 float64(精度陷阱),
  35. // 所以 DTO 用 string + 显式 Parse,**连"宽松接受数字"都不做**(见 `parseMoney` 注释)。
  36. // ⑤ 分页钳制**唯一实现点**:`parsePage`(内部只调 `response.ParsePage`),
  37. // handler 里**禁止**再出现 `if pageSize > 200` 这类分支(一处改、处处生效)。
  38. // ⑥ 响应体不含敏感字段:视图 DTO 逐字段声明(`RequestView` 无 `imgs`、
  39. // `LogView` 无 `pay_return_data`),**不直接序列化 model**(model 的 `json:"-"` 是最后一道,
  40. // 不是唯一一道)。
  41. package handler
  42. import (
  43. "strings"
  44. "time"
  45. "github.com/gofiber/fiber/v2"
  46. "go.uber.org/zap"
  47. "gorm.io/gorm"
  48. "yuansiwei/unified-backend/internal/academic/money"
  49. "yuansiwei/unified-backend/internal/academic/port"
  50. "yuansiwei/unified-backend/internal/academic/service"
  51. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  52. "yuansiwei/unified-backend/internal/infra/response"
  53. appscope "yuansiwei/unified-backend/internal/infra/scope"
  54. "yuansiwei/unified-backend/internal/infra/tz"
  55. "yuansiwei/unified-backend/internal/infra/validator"
  56. )
  57. // ---------------------------------------------------------------- 路径常量(**不含 `/api`**)
  58. // 财务域路径常量(端点 1~16、21;对外 = `/api` + 这些值)。
  59. const (
  60. // PathFinanceAccounts 端点 1:账户快照(`GET /admin/finance/accounts/:userId`)。
  61. PathFinanceAccounts = "/admin/finance/accounts/:userId"
  62. // PathFinanceRefundable 端点 2:可退金额(`GET /admin/finance/accounts/:userId/refundable`)。
  63. PathFinanceRefundable = "/admin/finance/accounts/:userId/refundable"
  64. // PathFinanceRecharges 端点 3:充值(`POST /admin/finance/recharges`)。
  65. PathFinanceRecharges = "/admin/finance/recharges"
  66. // PathFinanceExpenditures 端点 4:消费(`POST /admin/finance/expenditures`)。
  67. PathFinanceExpenditures = "/admin/finance/expenditures"
  68. // PathFinanceRefunds 端点 5:退费(`POST /admin/finance/refunds`)。
  69. PathFinanceRefunds = "/admin/finance/refunds"
  70. // PathFinanceRefundsCustom 端点 6:自定义退费(双 log 坏账)(`POST /admin/finance/refunds/custom`)。
  71. PathFinanceRefundsCustom = "/admin/finance/refunds/custom"
  72. // PathFinanceTransfers 端点 7:课费转结(`POST /admin/finance/transfers`)。
  73. PathFinanceTransfers = "/admin/finance/transfers"
  74. // PathFinanceBadDebts 端点 8:坏账(`POST /admin/finance/bad-debts`)。
  75. PathFinanceBadDebts = "/admin/finance/bad-debts"
  76. // PathFinanceClearedBalances 端点 9:余额清零(`POST /admin/finance/cleared-balances`)。
  77. PathFinanceClearedBalances = "/admin/finance/cleared-balances"
  78. // PathFinanceSpends 端点 10:累计消费(`POST /admin/finance/spends`)。
  79. PathFinanceSpends = "/admin/finance/spends"
  80. // PathFinanceLogs 端点 11:流水列表(`GET /admin/finance/logs`)。
  81. PathFinanceLogs = "/admin/finance/logs"
  82. // PathFinanceRequests 端点 12/13:工单列表 / 创建(`/admin/finance/requests`)。
  83. PathFinanceRequests = "/admin/finance/requests"
  84. // PathFinanceRequestApprove 端点 14:工单通过(`POST /admin/finance/requests/:id/approve`)。
  85. PathFinanceRequestApprove = "/admin/finance/requests/:id/approve"
  86. // PathFinanceRequestReject 端点 15:工单驳回(`POST /admin/finance/requests/:id/reject`)。
  87. PathFinanceRequestReject = "/admin/finance/requests/:id/reject"
  88. // PathFinanceRequestCancel 端点 16:工单撤销(`POST /admin/finance/requests/:id/cancel`)。
  89. PathFinanceRequestCancel = "/admin/finance/requests/:id/cancel"
  90. // PathFinanceDashboard 端点 21:仪表盘(`GET /admin/finance/dashboard`,P2)。
  91. PathFinanceDashboard = "/admin/finance/dashboard"
  92. )
  93. // PathStudentBill 端点 17:学生账单(`GET /student/bill`)。
  94. const PathStudentBill = "/student/bill"
  95. // ---------------------------------------------------------------- 处理器装配
  96. // Config Handler 装配参数(AC-14 在 `cmd/api/main.go` 按此构造)。
  97. //
  98. // 字段:
  99. // - DB *gorm.DB:数据库句柄(**必填**:academic 的 Service 在构造时需要 scope,无法预装配,
  100. // 故 Handler 持 DB、按请求作用域现场装配 Service);
  101. // - Member port.MembershipChecker:成员校验器(可 nil:nil 时服务层跳过对象级越权断言,
  102. // 仅限双跑/回填脚本;生产装配**必须**注入);
  103. // - Log *zap.Logger:结构化日志器(可 nil)。
  104. type Config struct {
  105. // DB 数据库句柄(必填)。
  106. DB *gorm.DB
  107. // Member 成员校验器(可 nil)。
  108. Member port.MembershipChecker
  109. // Users 用户显示名只读端口(**可 nil**:nil 时端点 53 的 `realName` 下发 null,
  110. // 页面少一列姓名,不影响任何判据)。实现放 identity 侧,装配层注入(D4 裁决)。
  111. Users port.UserNameReader
  112. // Log 日志器(可 nil)。
  113. Log *zap.Logger
  114. }
  115. // Handler academic 域 HTTP 处理器(启动装配一次,全进程共享)。
  116. //
  117. // 语义说明(**为什么按请求装配 Service**):academic 的 Service 构造函数签名是
  118. // `NewXxxService(db, sc, member)`——作用域是**构造期**绑定到 Repository 的(见 infra/scope 包注释
  119. // "构造 Repository 即绑定本次请求的租户")。因此本 Handler 只持有不可变的 `db`/`member`,
  120. // 每个请求经 `servicesFor(sc)` 现场装配一套服务;`db` 是并发安全的连接池,装配开销只是几个 struct。
  121. type Handler struct {
  122. db *gorm.DB
  123. member port.MembershipChecker
  124. users port.UserNameReader
  125. log *zap.Logger
  126. }
  127. // New 构造 academic 域 HTTP 处理器。
  128. //
  129. // 入参:cfg Config。
  130. // 出参:
  131. // - *Handler:就绪处理器;
  132. // - error:DB 未注入(fail-fast——没有 DB 无法装配任何服务,挂载端点只会 5001)。
  133. func New(cfg Config) (*Handler, error) {
  134. if cfg.DB == nil {
  135. return nil, infraerrors.New(infraerrors.CodeInternal, "academic handler: DB 未注入")
  136. }
  137. return &Handler{db: cfg.DB, member: cfg.Member, users: cfg.Users, log: cfg.Log}, nil
  138. }
  139. // scopedServices 一次请求内装配的服务集合(按作用域现场构造,请求结束即丢弃)。
  140. type scopedServices struct {
  141. // fund 资金服务(端点 2~5、7、8、10)。
  142. fund *service.FundService
  143. // refund 退费服务(端点 6、9)。
  144. refund *service.RefundService
  145. // bill 账单服务(端点 17)。
  146. bill *service.BillService
  147. // query 财务只读查询(端点 1、11、12、21)。
  148. query *service.FinanceQueryService
  149. // requests 工单服务(端点 13~16)。
  150. requests *service.BalanceRequestService
  151. // activity 报名/取消报名(端点 26、27;SP19/SP20,有 dump 对照)。
  152. activity *service.ActivityService
  153. // activityAdmin 活动管理面(端点 22~25、28、37;无 dump SP 对照,走 DDL 列)。
  154. activityAdmin *service.ActivityAdminService
  155. // activityDiscount 赠送池(端点 31~36;SP12~SP17)。
  156. activityDiscount *service.ActivityDiscountService
  157. // recommend 老带新结算 + 取消充值活动(端点 29、30;SP18/SP8 转调)。
  158. recommend *service.RecommendService
  159. // course 课程 / 排班规则 / 排班生成 / 报课(端点 44~47、49、53~55、57;AC-11 + AC-13c 新增读装配)。
  160. course *service.CourseService
  161. // record 开始/结束上课、打卡(端点 48、50~52;AC-11 单事务联动)。
  162. record *service.RecordService
  163. // recordView 结课响应溯源装配(端点 51 的 `divide[]` / `payroll.detailId`;**只读、事务之后**)。
  164. recordView *service.RecordViewService
  165. // maintenance 欠费核销(端点 56;SP27)与维护型修复(端点 61~63;SP28~SP31)。
  166. maintenance *service.MaintenanceService
  167. // payroll 课酬试算(端点 59;SP26)。**与结课共用同一算法规格**(见 service.computeRecordReward)。
  168. payroll *service.PayrollService
  169. // payrollPlan 课酬方案配置(端点 58:方案 + 明细 CRUD;**写入面**,AC-10 未覆盖)。
  170. payrollPlan *service.PayrollPlanService
  171. // expenditure 机构收支台账(端点 60:记录列表/记账/类型字典)。
  172. // ⚠ 与"学生钱包/消费"(`t_user_expense*`,端点 2~9)是**两套账**,列级无联动,不要混算。
  173. expenditure *service.ExpenditureService
  174. }
  175. // servicesFor 按请求作用域装配一套服务。
  176. //
  177. // 入参:sc 作用域(**非 nil**——调用方已用 mustScope 判定)。
  178. // 出参:*scopedServices(字段全部非 nil)。
  179. //
  180. // 装配要点(academic 的 Service 在构造期绑定作用域,见 Handler 类型注释):
  181. // - `activityAdmin` / `recommend` 需要校区端口(校區→机构反查,跨域只读)⇒ 注入同一个 reader;
  182. // - `activity` / `recommend` 需要成员校验器(对象级越权断言)⇒ 注入 `h.member`(可 nil);
  183. // - `activityDiscount` 的越权防线在 Handler 角色白名单(设计 §3.2-A7),故不注入 checker。
  184. func (h *Handler) servicesFor(sc *appscope.Scope) *scopedServices {
  185. campus := port.NewOrgCampusReader(h.db)
  186. // 课酬试算:既作为端点 59 的独立服务(后续批次),也作为结课链路(端点 51)的课酬端口实现。
  187. payroll := service.NewPayrollService(h.db, sc)
  188. // 结课联动:分账(AC-09)+ 课酬(AC-10 端口适配)。两者都必须在**同一 DB 句柄**上构造,
  189. // 否则事务内透传会失效(§10.3 红线)。
  190. record := service.NewRecordService(h.db, sc, service.RecordServiceOptions{
  191. Divide: service.NewDivideServiceWithRecordRepo(h.db, sc),
  192. Quoter: service.NewRecordRewardQuoter(payroll),
  193. })
  194. return &scopedServices{
  195. fund: service.NewFundService(h.db, sc, h.member),
  196. refund: service.NewRefundService(h.db, sc, campus, h.member),
  197. bill: service.NewBillService(h.db, sc).WithMembershipChecker(h.member),
  198. query: service.NewFinanceQueryService(h.db, sc, h.member),
  199. requests: service.NewBalanceRequestService(h.db, sc, h.member),
  200. activity: service.NewActivityService(h.db, sc).WithMembershipChecker(h.member),
  201. activityAdmin: service.NewActivityAdminService(h.db, sc).WithCampusReader(campus),
  202. activityDiscount: service.NewActivityDiscountService(h.db, sc),
  203. recommend: service.NewRecommendService(h.db, sc).WithCampusReader(campus).WithMembershipChecker(h.member),
  204. course: service.NewCourseService(h.db, sc),
  205. record: record,
  206. recordView: service.NewRecordViewService(h.db, sc, payroll),
  207. maintenance: service.NewMaintenanceService(h.db, sc),
  208. payroll: payroll,
  209. payrollPlan: service.NewPayrollPlanService(h.db, sc),
  210. expenditure: service.NewExpenditureService(h.db, sc),
  211. }
  212. }
  213. // ---------------------------------------------------------------- 公共助手(四件事:作用域 / 校验 / 金额 / 分页)
  214. // mustScope 取中间件注入的多租户作用域;取不到 → **1001**(AC-12 验收第 ③ 条)。
  215. //
  216. // 入参:c *fiber.Ctx。
  217. // 出参:
  218. // - *appscope.Scope:非 nil 作用域;
  219. // - error:`infraerrors.ErrUnauth`(1001)——受保护路由未注入作用域 = 未登录/中间件链配置错误。
  220. //
  221. // 为什么返回 error 而不是直接写响应:让调用方 `return response.Fail(c, err)` 统一出口,
  222. // 避免本函数既写响应又返回值(双重写出是 Fiber 里最难查的一类 bug)。
  223. func mustScope(c *fiber.Ctx) (*appscope.Scope, error) {
  224. sc := appscope.FromFiber(c)
  225. if sc == nil {
  226. return nil, infraerrors.ErrUnauth
  227. }
  228. return sc, nil
  229. }
  230. // validateReq 绑定并校验 JSON 请求体;失败时响应已写出(2001),调用方 `return nil`(AC-12 第 ② 条)。
  231. //
  232. // 入参:c;dst 目标结构体指针。
  233. // 出参:true 通过;false 失败(**body code=2001 已写出**)。
  234. //
  235. // 这是 `infra/validator.Validate` 的本地别名,存在意义只有一条:把"academic 的 Handler 一律经此函数
  236. // 校验"变成可 grep 的纪律(评审时 `grep -c validateReq` 应等于非 GET 端点数)。
  237. func validateReq(c *fiber.Ctx, dst any) bool {
  238. return validator.Validate(c, dst)
  239. }
  240. // parseMoney 解析**字符串**金额入参(AC-12 第 ④ 条:金额一律字符串,走 money.Parse)。
  241. //
  242. // 入参:
  243. // - c *fiber.Ctx:请求上下文(仅用于写失败响应);
  244. // - raw string:DTO 里的金额字段(`"100.00"`);
  245. // - field string:字段名(错误文案定位,如 "money"/"spendMoney")。
  246. //
  247. // 出参:
  248. // - money.Amount:定点金额;
  249. // - bool:false 表示解析失败,**body code=2002 已写出**,调用方 `return nil`。
  250. //
  251. // 红线(**为什么必须是 2002 而不是 2001**):值传了但格式非法属"填错"(2002),
  252. // 字段没传属"漏填"(2001,由 validator 负责)。两者在前端提示语与埋点上是不同分支,
  253. // 合并会让"漏填金额"被提示成"金额格式错误",排查成本直接翻倍。
  254. //
  255. // 另一个红线:**不接受 JSON number**。DTO 字段声明为 `string`,`{"money": 100}` 会在
  256. // `validator.Validate` 的 JSON 解码阶段就失败(按 infra/validator 既有约定归 **2002**,
  257. // 而不是被静默接受成 100.00)——
  258. // 这是有意的:让"前端传了数字"在联调第一天就暴露,而不是等上了生产才发现
  259. // float64 → 分 的舍入把 0.1+0.2 级的差额带进对账。
  260. func parseMoney(c *fiber.Ctx, raw, field string) (money.Amount, bool) {
  261. amount, err := money.Parse(strings.TrimSpace(raw))
  262. if err != nil {
  263. if ferr := response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
  264. errString("金额字段 "+field+" 格式非法(必须是两位小数的字符串)"))); ferr != nil {
  265. return 0, false
  266. }
  267. return 0, false
  268. }
  269. return amount, true
  270. }
  271. // parsePage 解析分页参数(AC-12 第 ⑤ 条:**唯一钳制实现点**)。
  272. //
  273. // 入参:c *fiber.Ctx(读 `page`/`pageSize` query)。
  274. // 出参:(page, pageSize):page ≥ 1;pageSize ∈ [1, 200](>200 → 200,缺省 10)。
  275. //
  276. // 实现只转调 `response.ParsePage`——全仓唯一的分页钳制实现。
  277. // **禁止**在任何 handler 里自写 `if pageSize > 200 { pageSize = 200 }`:
  278. // 那样一旦产品把上限改成 500,改了一处漏了五处,症状是"某个页面能拉 500 条、另一个只能 200"。
  279. func parsePage(c *fiber.Ctx) (page, pageSize int) {
  280. return response.ParsePage(c)
  281. }
  282. // parseTimeParam 解析时间 query 参数(`from`/`to`)。
  283. //
  284. // 入参:raw 原始字符串;field 字段名(错误文案定位)。
  285. // 出参:(时间, 是否传了, error);空串 → (零值, false, nil);格式非法 → ErrBadFormat(2002)。
  286. //
  287. // 接受两种格式(前端的两种真实用法):
  288. // 1. `2006-01-02`(纯日期,最常用;按 UTC 当日 00:00:00 解释);
  289. // 2. RFC3339(`2006-01-02T15:04:05Z07:00`,带时分秒与时区)。
  290. //
  291. // ⚠ 端点 17(账单)的 `to` 语义是**不含上界**(见 service.GetBillInput 注释):
  292. // 传 `to=2026-04-01` 表示"到 3 月底为止",当日流水不计入——这与"余额/期初期末"口径一致。
  293. func parseTimeParam(raw, field string) (time.Time, bool, error) {
  294. trimmed := strings.TrimSpace(raw)
  295. if trimmed == "" {
  296. return time.Time{}, false, nil
  297. }
  298. if t, err := time.Parse(time.RFC3339, trimmed); err == nil {
  299. return t, true, nil
  300. }
  301. // 纯日期按**业务时区**解析(不能用 time.Parse = UTC,否则窗口整体偏 8 小时,台账 F31)
  302. if t, err := time.ParseInLocation("2006-01-02", trimmed, tz.Location()); err == nil {
  303. return t, true, nil
  304. }
  305. return time.Time{}, false, infraerrors.WithCause(infraerrors.ErrBadFormat,
  306. errString("时间字段 "+field+" 格式非法(支持 2006-01-02 或 RFC3339)"))
  307. }
  308. // parseIDParam 解析路径/查询里的 int64 ID(空串或非法 → 0,交由下游 2001/3001 判)。
  309. //
  310. // 入参:raw 原始字符串。出参:int64(解析失败为 0)。
  311. //
  312. // 为什么不在 Handler 直接报 2002:ID 为 0 时下游(Service/Repo)会给出更精确的语义
  313. // (2001 缺参 or 3001 不存在),Handler 抢先判定只会让错误码与真实原因脱节。
  314. func parseIDParam(raw string) int64 {
  315. trimmed := strings.TrimSpace(raw)
  316. if trimmed == "" {
  317. return 0
  318. }
  319. var out int64
  320. for _, ch := range trimmed {
  321. if ch < '0' || ch > '9' {
  322. return 0
  323. }
  324. out = out*10 + int64(ch-'0')
  325. }
  326. return out
  327. }
  328. // errString 实现 error 的最小字符串类型(本包不引入额外依赖,语义同 repository.errString)。
  329. type errString string
  330. // Error 实现 error 接口。
  331. func (e errString) Error() string { return string(e) }