fund_handler.go 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636
  1. // 财务端点处理器(端点 1~16,`/admin/finance/*`)。
  2. //
  3. // 依据:unified/03-migration/academic-domain-design.md §4.1(逐行对应 18 个端点里的 16 个);
  4. // 端点 17/21 在 bill_handler.go。路由挂载在 AC-14 完成(本文件只定义方法 + 路径常量)。
  5. //
  6. // ---------------------------------------------------------------------------
  7. // 端点 → 方法 → 服务(一览,评审按此表核对覆盖)
  8. // ---------------------------------------------------------------------------
  9. //
  10. // 1 GET /admin/finance/accounts/:userId GetAccount → FinanceQueryService.AccountSnapshot
  11. // 2 GET /admin/finance/accounts/:userId/refundable GetRefundable → FundService.RefundableAmount
  12. // 3 POST /admin/finance/recharges CreateRecharge → FundService.Recharge
  13. // 4 POST /admin/finance/expenditures CreateExpenditure → FundService.Expenditure
  14. // 5 POST /admin/finance/refunds CreateRefund → FundService.Refund
  15. // 6 POST /admin/finance/refunds/custom CreateCustomRefund→ RefundService.CustomRefund
  16. // 7 POST /admin/finance/transfers CreateTransfer → FundService.Transfer
  17. // 8 POST /admin/finance/bad-debts CreateBadDebt → FundService.BadDebt
  18. // 9 POST /admin/finance/cleared-balances ClearBalance → RefundService.ClearBalance
  19. // 10 POST /admin/finance/spends AddSpend → FundService.AccumulateSpend
  20. // 11 GET /admin/finance/logs ListLogs → FinanceQueryService.ListLogs
  21. // 12 GET /admin/finance/requests ListRequests → FinanceQueryService.ListRequests
  22. // 13 POST /admin/finance/requests CreateRequest → BalanceRequestService.Create
  23. // 14 POST /admin/finance/requests/:id/approve ApproveRequest → BalanceRequestService.Approve
  24. // 15 POST /admin/finance/requests/:id/reject RejectRequest → BalanceRequestService.Reject
  25. // 16 POST /admin/finance/requests/:id/cancel CancelRequest → BalanceRequestService.Cancel
  26. //
  27. // ---------------------------------------------------------------------------
  28. // 三条口径备注(都是"看起来能省、其实一改就错"的地方)
  29. // ---------------------------------------------------------------------------
  30. //
  31. // 1. **端点 5 的 `changed` 恒 true**(§3.8-R6 定稿:金额 <=0 一律入口 2002):
  32. // 字段仅为契约兼容保留,语义 = "成功即发生账户写入"。**不要**改成"按实际变化置 false"——
  33. // 前端契约 v1.2 已按恒 true 对接,改成 false 会让前端走"未发生写入"的降级分支(静默不刷新)。
  34. // 2. **错误码一律引 apperr 常量**(31xx 段 19 个码),Handler 内**不出现数字字面量**:
  35. // 这样"错误码是否越界/是否重复"由 `apperr.codes_test.go` 统一守,handler 改动不会漏检。
  36. // Handler 自己需要的通用码(1001/2001/2002/5002)引 infra/errors 的常量。
  37. // 3. **枚举白名单在 Handler 与 Service 各校验一次**:Handler 抢先是为了给出**字段级**文案
  38. // ("type 不在消费白名单"),Service 再校验是红线(Handler 可被绕过/复用,业务不变量不能只靠接入层)。
  39. package handler
  40. import (
  41. "strings"
  42. "github.com/gofiber/fiber/v2"
  43. "yuansiwei/unified-backend/internal/academic/model"
  44. "yuansiwei/unified-backend/internal/academic/service"
  45. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  46. "yuansiwei/unified-backend/internal/infra/response"
  47. )
  48. // billSpendTypes 端点 4 允许的消费类型白名单(与 dump `sp_user_expense_school_expenditure` 一致)。
  49. var expenditureAllowedTypes = map[int32]struct{}{
  50. model.ExpenseLogTypeCourseCampus: {}, // 101
  51. model.ExpenseLogTypeCourseSchoolA: {}, // 104
  52. model.ExpenseLogTypeCourseSchoolB: {}, // 105
  53. model.ExpenseLogTypeHistoryDeduct: {}, // 200
  54. model.ExpenseLogTypeCourseCampusB: {}, // 201
  55. model.ExpenseLogTypeCourseSchoolC: {}, // 204
  56. model.ExpenseLogTypeCourseSchoolD: {}, // 205
  57. }
  58. // requestAllowedTypes 端点 13 允许创建的工单类型白名单(§2「4.」类型枚举)。
  59. var requestAllowedTypes = map[int32]struct{}{
  60. model.BalanceRequestTypeRecharge: {}, // 100
  61. model.BalanceRequestTypeBadDebt: {}, // 101
  62. model.BalanceRequestTypeRefund: {}, // 202
  63. model.BalanceRequestTypeCancelRecharge: {}, // 203
  64. model.BalanceRequestTypeTransfer: {}, // 300
  65. }
  66. // ---------------------------------------------------------------- 端点 1:账户快照
  67. // GetAccount 账户快照(端点 1 `GET /admin/finance/accounts/:userId`)。
  68. //
  69. // 入参(query,均可选):`institutionId`/`campusId`——越权判定输入 + 校区账户定位维度。
  70. // 出参(成功):`AccountSnapshot{userId, global{6 列}, campus|null}`。
  71. // 出参(失败):1001(无作用域)/2001(缺 userId)/1004(越权)/5002。
  72. func (h *Handler) GetAccount(c *fiber.Ctx) error {
  73. sc, err := mustScope(c)
  74. if err != nil {
  75. return response.Fail(c, err)
  76. }
  77. userID := strings.TrimSpace(c.Params("userId"))
  78. if userID == "" {
  79. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("userId 不能为空")))
  80. }
  81. svc := h.servicesFor(sc)
  82. snapshot, err := svc.query.AccountSnapshot(c.Context(), userID,
  83. parseIDParam(c.Query("institutionId")), parseIDParam(c.Query("campusId")))
  84. if err != nil {
  85. return response.Fail(c, err)
  86. }
  87. return response.OK(c, snapshot)
  88. }
  89. // ---------------------------------------------------------------- 端点 2:可退金额
  90. // GetRefundable 可退金额(端点 2 `GET /admin/finance/accounts/:userId/refundable`)。
  91. //
  92. // 入参(query,**必填**):`institutionId`/`campusId`(缺 → 2001,由 Service.validateUserScope 判定)。
  93. // 出参(成功):`{refundableAmount}`(**两步修正**口径:截到全局余额 + 减已用赠送额,§3.1 #5)。
  94. // 出参(失败):1001/2001/2370… → 具体为 2001/3140/1004/5002。
  95. //
  96. // ⚠ 与端点 6 的同名字段**不是一回事**:端点 6 的 `refundableAmount` 是"校区 balance 本身"(SP9 口径),
  97. // 本端点返回的是两步修正后的值。前端/用例**不得**互相当基准(§4.6 口径警示)。
  98. func (h *Handler) GetRefundable(c *fiber.Ctx) error {
  99. sc, err := mustScope(c)
  100. if err != nil {
  101. return response.Fail(c, err)
  102. }
  103. userID := strings.TrimSpace(c.Params("userId"))
  104. if userID == "" {
  105. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("userId 不能为空")))
  106. }
  107. svc := h.servicesFor(sc)
  108. amount, err := svc.fund.RefundableAmount(c.Context(), userID,
  109. parseIDParam(c.Query("institutionId")), parseIDParam(c.Query("campusId")))
  110. if err != nil {
  111. return response.Fail(c, err)
  112. }
  113. return response.OK(c, fiber.Map{"refundableAmount": amount})
  114. }
  115. // ---------------------------------------------------------------- 端点 3:充值
  116. // CreateRecharge 充值(端点 3 `POST /admin/finance/recharges`,SP1 `sp_user_expense_recharge`)。
  117. //
  118. // 出参(成功):`{logId,balance,repeated}`(`balance` = 充值后**校区**余额,`repeated=true` 表示幂等命中)。
  119. // 出参(失败):1001/2001(缺参)/2002(金额非法或 <=0)/1004/5002。
  120. func (h *Handler) CreateRecharge(c *fiber.Ctx) error {
  121. sc, err := mustScope(c)
  122. if err != nil {
  123. return response.Fail(c, err)
  124. }
  125. var req RechargeReq
  126. if !validateReq(c, &req) {
  127. return nil
  128. }
  129. amount, ok := parseMoney(c, req.Money, "money")
  130. if !ok {
  131. return nil
  132. }
  133. svc := h.servicesFor(sc)
  134. res, err := svc.fund.Recharge(c.Context(), service.RechargeInput{
  135. UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID,
  136. Money: amount, Type: req.Type, RequestID: strings.TrimSpace(req.RequestID),
  137. Remark: req.Remark, OperatorID: sc.UserID,
  138. })
  139. if err != nil {
  140. return response.Fail(c, err)
  141. }
  142. return response.OK(c, res)
  143. }
  144. // CreateExpenditure 消费(端点 4 `POST /admin/finance/expenditures`,SP3)。
  145. //
  146. // 出参(成功):`{logId,balance,repeated}`。
  147. // 出参(失败):1001/2001/2002(金额非法 / type 不在白名单)/1004/5002。
  148. func (h *Handler) CreateExpenditure(c *fiber.Ctx) error {
  149. sc, err := mustScope(c)
  150. if err != nil {
  151. return response.Fail(c, err)
  152. }
  153. var req ExpenditureReq
  154. if !validateReq(c, &req) {
  155. return nil
  156. }
  157. if _, ok := expenditureAllowedTypes[req.Type]; !ok {
  158. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
  159. errString("type 不在消费白名单(101/104/105/200/201/204/205)")))
  160. }
  161. amount, ok := parseMoney(c, req.Money, "money")
  162. if !ok {
  163. return nil
  164. }
  165. svc := h.servicesFor(sc)
  166. res, err := svc.fund.Expenditure(c.Context(), service.ExpenditureInput{
  167. UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID,
  168. Money: amount, Type: req.Type, RequestID: strings.TrimSpace(req.RequestID),
  169. Remark: req.Remark, OperatorID: sc.UserID,
  170. })
  171. if err != nil {
  172. return response.Fail(c, err)
  173. }
  174. return response.OK(c, res)
  175. }
  176. // CreateRefund 退费(端点 5 `POST /admin/finance/refunds`,SP4)。
  177. //
  178. // 出参(成功):`{logId,balance,changed,repeated}`——**`changed` 恒 true**(§3.8-R6:
  179. // `money<=0` 一律 2002,故"成功"必然伴随账户写入;字段仅契约兼容保留)。
  180. // 出参(失败):1001/2001/2002(金额非法/<=0 或 type 非法)/1004/5002。
  181. func (h *Handler) CreateRefund(c *fiber.Ctx) error {
  182. sc, err := mustScope(c)
  183. if err != nil {
  184. return response.Fail(c, err)
  185. }
  186. var req RefundReq
  187. if !validateReq(c, &req) {
  188. return nil
  189. }
  190. amount, ok := parseMoney(c, req.Money, "money")
  191. if !ok {
  192. return nil
  193. }
  194. svc := h.servicesFor(sc)
  195. res, err := svc.fund.Refund(c.Context(), service.RefundInput{
  196. UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID,
  197. Money: amount, Type: req.Type, RequestID: strings.TrimSpace(req.RequestID),
  198. Remark: req.Remark, OperatorID: sc.UserID,
  199. })
  200. if err != nil {
  201. return response.Fail(c, err)
  202. }
  203. return response.OK(c, res)
  204. }
  205. // CreateCustomRefund 自定义退费(端点 6 `POST /admin/finance/refunds/custom`,SP9 双 log 坏账)。
  206. //
  207. // 出参(成功):`CustomRefundResult`(§4.6 逐字段;`warnings[]` 空时 `[]`、`logId108` 无坏账时 null)。
  208. // 出参(失败):1001/2001(含缺 requestId)/2002(金额非法或 <=0)/3102(校区余额 <=0)/
  209. // 3104(校区不存在)/1004/5002。
  210. func (h *Handler) CreateCustomRefund(c *fiber.Ctx) error {
  211. sc, err := mustScope(c)
  212. if err != nil {
  213. return response.Fail(c, err)
  214. }
  215. var req CustomRefundReq
  216. if !validateReq(c, &req) {
  217. return nil
  218. }
  219. amount, ok := parseMoney(c, req.Money, "money")
  220. if !ok {
  221. return nil
  222. }
  223. svc := h.servicesFor(sc)
  224. res, err := svc.refund.CustomRefund(c.Context(), service.CustomRefundInput{
  225. UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID,
  226. Money: amount, RequestID: strings.TrimSpace(req.RequestID),
  227. Remark: req.Remark, OperatorID: sc.UserID,
  228. })
  229. if err != nil {
  230. return response.Fail(c, err)
  231. }
  232. return response.OK(c, res)
  233. }
  234. // CreateTransfer 课费转结(端点 7 `POST /admin/finance/transfers`,SP6)。
  235. //
  236. // 出参(成功):`{logId106,logId206,fromBalance,toBalance,repeated}`
  237. // (两条流水共用同一 `request_id`、`type` 不同故不撞幂等键;`balance` 本身不动)。
  238. // 出参(失败):1001/2001/2002/3140(转入方机构冲突)/3001(源账户缺失)/1004/5002。
  239. func (h *Handler) CreateTransfer(c *fiber.Ctx) error {
  240. sc, err := mustScope(c)
  241. if err != nil {
  242. return response.Fail(c, err)
  243. }
  244. var req TransferReq
  245. if !validateReq(c, &req) {
  246. return nil
  247. }
  248. amount, ok := parseMoney(c, req.Money, "money")
  249. if !ok {
  250. return nil
  251. }
  252. svc := h.servicesFor(sc)
  253. res, err := svc.fund.Transfer(c.Context(), service.TransferInput{
  254. UserID: req.UserID,
  255. FromInstitutionID: req.FromInstitutionID, FromCampusID: req.FromCampusID,
  256. ToInstitutionID: req.ToInstitutionID, ToCampusID: req.ToCampusID,
  257. Money: amount, RequestID: strings.TrimSpace(req.RequestID),
  258. Remark: req.Remark, OperatorID: sc.UserID,
  259. })
  260. if err != nil {
  261. return response.Fail(c, err)
  262. }
  263. return response.OK(c, res)
  264. }
  265. // CreateBadDebt 坏账处理(端点 8 `POST /admin/finance/bad-debts`,SP7)。
  266. //
  267. // 出参(成功):`{logId,specialExpenses,balance,repeated}`
  268. // (⚠ dump 口径:坏账是 `balance` 与 `special_expenses` **双增**,故 `balance` 已含本次增量)。
  269. // 出参(失败):1001/2001/2002/1004/5002。
  270. func (h *Handler) CreateBadDebt(c *fiber.Ctx) error {
  271. sc, err := mustScope(c)
  272. if err != nil {
  273. return response.Fail(c, err)
  274. }
  275. var req BadDebtReq
  276. if !validateReq(c, &req) {
  277. return nil
  278. }
  279. amount, ok := parseMoney(c, req.Money, "money")
  280. if !ok {
  281. return nil
  282. }
  283. svc := h.servicesFor(sc)
  284. res, err := svc.fund.BadDebt(c.Context(), service.BadDebtInput{
  285. UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID,
  286. Money: amount, RequestID: strings.TrimSpace(req.RequestID),
  287. Remark: req.Remark, OperatorID: sc.UserID,
  288. })
  289. if err != nil {
  290. return response.Fail(c, err)
  291. }
  292. return response.OK(c, res)
  293. }
  294. // ClearBalance 余额清零(端点 9 `POST /admin/finance/cleared-balances`,SP10)。
  295. //
  296. // 出参(成功):`{logId208,cleared,balance,totalBalance,specialExpenses,expiredActivityIds,repeated}`。
  297. // 出参(失败):1001/2001/3120(三条前置校验之一不满足)/1004/5002。
  298. func (h *Handler) ClearBalance(c *fiber.Ctx) error {
  299. sc, err := mustScope(c)
  300. if err != nil {
  301. return response.Fail(c, err)
  302. }
  303. var req ClearBalanceReq
  304. if !validateReq(c, &req) {
  305. return nil
  306. }
  307. svc := h.servicesFor(sc)
  308. res, err := svc.refund.ClearBalance(c.Context(), service.ClearBalanceInput{
  309. UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID,
  310. Remark: req.Remark, OperatorID: sc.UserID,
  311. })
  312. if err != nil {
  313. return response.Fail(c, err)
  314. }
  315. return response.OK(c, res)
  316. }
  317. // AddSpend 累计消费(端点 10 `POST /admin/finance/spends`,SP11)。
  318. //
  319. // 出参(成功):`{spend}`(**`spend` 列,不是 `balance`**——本端点不动余额)。
  320. // 出参(失败):1001/2001/2002/1004/5002。
  321. func (h *Handler) AddSpend(c *fiber.Ctx) error {
  322. sc, err := mustScope(c)
  323. if err != nil {
  324. return response.Fail(c, err)
  325. }
  326. var req SpendReq
  327. if !validateReq(c, &req) {
  328. return nil
  329. }
  330. amount, ok := parseMoney(c, req.SpendMoney, "spendMoney")
  331. if !ok {
  332. return nil
  333. }
  334. svc := h.servicesFor(sc)
  335. res, err := svc.fund.AccumulateSpend(c.Context(), service.SpendInput{
  336. UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID,
  337. SpendMoney: amount,
  338. })
  339. if err != nil {
  340. return response.Fail(c, err)
  341. }
  342. return response.OK(c, res)
  343. }
  344. // ---------------------------------------------------------------- 端点 11 / 12:列表
  345. // ListLogs 流水列表(端点 11 `GET /admin/finance/logs`)。
  346. //
  347. // 入参(query):`userId`(**非超管必填**,理由见 FinanceQueryService.ListLogs)、
  348. // `type`、`from`、`to`、`page`、`pageSize`(钳制上限 200)。
  349. // 出参(成功):`PageData{list,total,page,pageSize,pages}`。
  350. // 出参(失败):1001/2001(缺 userId 且非超管)/2002(type/时间格式非法)/1004/5002。
  351. func (h *Handler) ListLogs(c *fiber.Ctx) error {
  352. sc, err := mustScope(c)
  353. if err != nil {
  354. return response.Fail(c, err)
  355. }
  356. typeFilter, ok := parseOptionalInt32(c, "type")
  357. if !ok {
  358. return nil
  359. }
  360. from, _, ferr := parseTimeParam(c.Query("from"), "from")
  361. if ferr != nil {
  362. return response.Fail(c, ferr)
  363. }
  364. to, _, terr := parseTimeParam(c.Query("to"), "to")
  365. if terr != nil {
  366. return response.Fail(c, terr)
  367. }
  368. page, pageSize := parsePage(c)
  369. svc := h.servicesFor(sc)
  370. in := service.ListLogsInput{
  371. UserID: c.Query("userId"), Type: typeFilter, Page: page, PageSize: pageSize,
  372. }
  373. if !from.IsZero() {
  374. in.From = &from
  375. }
  376. if !to.IsZero() {
  377. in.To = &to
  378. }
  379. rows, total, err := svc.query.ListLogs(c.Context(), in)
  380. if err != nil {
  381. return response.Fail(c, err)
  382. }
  383. return response.Page(c, rows, page, pageSize, total)
  384. }
  385. // ListRequests 工单列表(端点 12 `GET /admin/finance/requests`)。
  386. //
  387. // 入参(query):`type`(100/101/202/203/300)、`approved`(-2/-1/0/1)、`userId`、分页。
  388. // 出参(成功):`PageData`;list 元素为 `RequestView`(**不含 `imgs`**,AC-12 验收 ⑥)。
  389. // 出参(失败):1001/2002(type/approved 非法)/5002。
  390. func (h *Handler) ListRequests(c *fiber.Ctx) error {
  391. sc, err := mustScope(c)
  392. if err != nil {
  393. return response.Fail(c, err)
  394. }
  395. typeFilter, ok := parseOptionalInt32(c, "type")
  396. if !ok {
  397. return nil
  398. }
  399. approved, ok := parseOptionalInt8(c, "approved")
  400. if !ok {
  401. return nil
  402. }
  403. page, pageSize := parsePage(c)
  404. svc := h.servicesFor(sc)
  405. rows, total, err := svc.query.ListRequests(c.Context(), service.ListRequestsInput{
  406. Type: typeFilter, Approved: approved, UserID: c.Query("userId"),
  407. Page: page, PageSize: pageSize,
  408. })
  409. if err != nil {
  410. return response.Fail(c, err)
  411. }
  412. return response.Page(c, rows, page, pageSize, total)
  413. }
  414. // ---------------------------------------------------------------- 端点 13~16:工单写路径
  415. // CreateRequest 创建余额工单(端点 13 `POST /admin/finance/requests`)。
  416. //
  417. // 出参(成功):`{id,bizNo}`(`approved` 恒 0 —— 资金变动只由端点 14 触发)。
  418. // 出参(失败):1001/2001(缺参/类型缺失)/2002(金额非法/类型不在白名单)/5002。
  419. func (h *Handler) CreateRequest(c *fiber.Ctx) error {
  420. sc, err := mustScope(c)
  421. if err != nil {
  422. return response.Fail(c, err)
  423. }
  424. var req CreateRequestReq
  425. if !validateReq(c, &req) {
  426. return nil
  427. }
  428. if _, ok := requestAllowedTypes[req.Type]; !ok {
  429. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
  430. errString("type 不在工单白名单(100/101/202/203/300)")))
  431. }
  432. amount, ok := parseMoney(c, req.Money, "money")
  433. if !ok {
  434. return nil
  435. }
  436. svc := h.servicesFor(sc)
  437. res, err := svc.requests.Create(c.Context(), service.CreateRequestInput{
  438. Type: req.Type, UserID: req.UserID,
  439. InstitutionID: req.InstitutionID, CampusID: req.CampusID,
  440. ToInstitutionID: req.ToInstitutionID, ToCampusID: req.ToCampusID,
  441. Money: amount, TransferAccountsType: req.TransferAccountsType,
  442. OrderNo: req.OrderNo, Item: req.Item, Imgs: req.Imgs, Remark: req.Remark,
  443. RequestUserID: sc.UserID,
  444. })
  445. if err != nil {
  446. return response.Fail(c, err)
  447. }
  448. return response.OK(c, res)
  449. }
  450. // ApproveRequest 审批通过(端点 14 `POST /admin/finance/requests/:id/approve`)。
  451. //
  452. // **资金触发点**:同一事务内先调对应资金方法(幂等键 = 工单 `bizNo`)、后 CAS 回写 `approved=1`。
  453. // 出参(成功):`{approved:1,type,bizNo,result:{...},repeated}`。
  454. // 出参(失败):1001/2001/3002(工单已处理/并发被抢)/3140(类型 203 不支持,或转结缺转入维度)/
  455. // 资金方法的 31xx(3101/3102/3104/3120…)/1004/5002。
  456. func (h *Handler) ApproveRequest(c *fiber.Ctx) error {
  457. sc, err := mustScope(c)
  458. if err != nil {
  459. return response.Fail(c, err)
  460. }
  461. id := parseIDParam(c.Params("id"))
  462. if id <= 0 {
  463. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("工单 id 非法")))
  464. }
  465. // body 可空("一键通过"),因此**不**用 validateReq(否则空 body 会被判 2001)
  466. var req ApproveReq
  467. if len(c.Body()) > 0 {
  468. if !validateReq(c, &req) {
  469. return nil
  470. }
  471. }
  472. svc := h.servicesFor(sc)
  473. res, err := svc.requests.Approve(c.Context(), service.ApproveRequestInput{
  474. ID: id, ResponseMsg: req.ResponseMsg, OperatorID: sc.UserID,
  475. })
  476. if err != nil {
  477. return response.Fail(c, err)
  478. }
  479. return response.OK(c, res)
  480. }
  481. // RejectRequest 审批驳回(端点 15 `POST /admin/finance/requests/:id/reject`)。
  482. //
  483. // 出参(成功):`{approved:-1,id}`;**无资金动作**。
  484. // 出参(失败):1001/2001(缺 responseMsg)/3002(已处理)/5002。
  485. func (h *Handler) RejectRequest(c *fiber.Ctx) error {
  486. sc, err := mustScope(c)
  487. if err != nil {
  488. return response.Fail(c, err)
  489. }
  490. id := parseIDParam(c.Params("id"))
  491. if id <= 0 {
  492. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("工单 id 非法")))
  493. }
  494. var req RejectReq
  495. if !validateReq(c, &req) {
  496. return nil
  497. }
  498. svc := h.servicesFor(sc)
  499. res, err := svc.requests.Reject(c.Context(), service.RejectRequestInput{
  500. ID: id, ResponseMsg: req.ResponseMsg, OperatorID: sc.UserID,
  501. })
  502. if err != nil {
  503. return response.Fail(c, err)
  504. }
  505. return response.OK(c, res)
  506. }
  507. // CancelRequest 撤销工单(端点 16 `POST /admin/finance/requests/:id/cancel`)。
  508. //
  509. // 权限:申请人本人 或 super_admin/finance(Service 判定,越权 → **1003**)。
  510. // 出参(成功):`{approved:-2,id}`;出参(失败):1001/2001/1003/3002/5002。
  511. func (h *Handler) CancelRequest(c *fiber.Ctx) error {
  512. sc, err := mustScope(c)
  513. if err != nil {
  514. return response.Fail(c, err)
  515. }
  516. id := parseIDParam(c.Params("id"))
  517. if id <= 0 {
  518. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("工单 id 非法")))
  519. }
  520. svc := h.servicesFor(sc)
  521. res, err := svc.requests.Cancel(c.Context(), service.CancelRequestInput{
  522. ID: id, OperatorID: sc.UserID, Role: sc.Role, SuperAdmin: sc.SuperAdmin,
  523. })
  524. if err != nil {
  525. return response.Fail(c, err)
  526. }
  527. return response.OK(c, res)
  528. }
  529. // ---------------------------------------------------------------- 查询参数助手
  530. // parseOptionalInt32 解析可选 int32 query(未传 → nil;格式非法 → 2002 并返回 false)。
  531. //
  532. // 入参:c;key query 名。
  533. // 出参:(*int32, bool);bool=false 表示失败响应已写出。
  534. func parseOptionalInt32(c *fiber.Ctx, key string) (*int32, bool) {
  535. raw := strings.TrimSpace(c.Query(key))
  536. if raw == "" {
  537. return nil, true
  538. }
  539. value, ok := strconvInt32(raw)
  540. if !ok {
  541. _ = response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
  542. errString("查询参数 "+key+" 非法(需整数)")))
  543. return nil, false
  544. }
  545. return &value, true
  546. }
  547. // parseOptionalInt8 解析可选 int8 query(工单状态 -2/-1/0/1)。
  548. //
  549. // 入参:c;key query 名。出参:(*int8, bool);bool=false 表示失败响应已写出。
  550. func parseOptionalInt8(c *fiber.Ctx, key string) (*int8, bool) {
  551. raw := strings.TrimSpace(c.Query(key))
  552. if raw == "" {
  553. return nil, true
  554. }
  555. value, ok := strconvInt64(raw)
  556. if !ok || value < -128 || value > 127 {
  557. _ = response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
  558. errString("查询参数 "+key+" 非法(需整数)")))
  559. return nil, false
  560. }
  561. out := int8(value)
  562. return &out, true
  563. }
  564. // strconvInt32 手写 int32 解析(接受可选负号;避免引入 strconv 依赖歧义)。
  565. //
  566. // 入参:raw 字符串。出参:(值, 是否成功)。
  567. func strconvInt32(raw string) (int32, bool) {
  568. value, ok := strconvInt64(raw)
  569. if !ok || value < -2147483648 || value > 2147483647 {
  570. return 0, false
  571. }
  572. return int32(value), true
  573. }
  574. // strconvInt64 手写 int64 解析(接受可选前导负号;非数字即失败)。
  575. //
  576. // 入参:raw 字符串。出参:(值, 是否成功)。
  577. func strconvInt64(raw string) (int64, bool) {
  578. trimmed := strings.TrimSpace(raw)
  579. if trimmed == "" {
  580. return 0, false
  581. }
  582. negative := false
  583. if trimmed[0] == '-' {
  584. negative = true
  585. trimmed = trimmed[1:]
  586. }
  587. if trimmed == "" {
  588. return 0, false
  589. }
  590. var out int64
  591. for _, ch := range trimmed {
  592. if ch < '0' || ch > '9' {
  593. return 0, false
  594. }
  595. next := out*10 + int64(ch-'0')
  596. if next < out { // 溢出回绕检测
  597. return 0, false
  598. }
  599. out = next
  600. }
  601. if negative {
  602. out = -out
  603. }
  604. return out, true
  605. }