finance_request_service.go 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  1. // 余额工单服务(端点 13~16:创建 / 通过 / 驳回 / 撤销)。
  2. //
  3. // 依据:unified/03-migration/academic-domain-design.md
  4. // - §4.1 端点 13~16;§2「4. t_user_balance_request」的资金变动触发点定义:
  5. // **「approved 由 0 → 1 的审批动作」是唯一触发点,且 Service 同事务内先调资金方法、后回写工单**
  6. // - §10.3 事务模板:`repository.WithinTx` + 事务内调下游 service **必须** `WithTxContext` 透传
  7. // - §5.1 纪律 5(幂等回放等价):审批通过时以工单 `biz_no` 作 `request_id`——
  8. // 同一工单被重复点"通过"不会二次入账(资金侧 `uk_request_type` 兜底,且 CAS 只让一次成功)。
  9. //
  10. // ---------------------------------------------------------------------------
  11. // 为什么本服务存在(设计 §8 的 AC-12 文件清单里没有它)
  12. // ---------------------------------------------------------------------------
  13. //
  14. // AC-12 的文件清单只列了 handler 文件,但端点 14「通过」不是"读一眼再写一个状态":
  15. // 它必须**按工单类型分派到对应资金方法**(100 充值 / 101 坏账 / 202 退费 / 300 转结),
  16. // 且资金写入与工单状态回写必须**同一个事务**(§10.3)。把这套编排放在 Handler 里
  17. // 会同时违反两条纪律(Handler 不写业务、事务边界只在 Service),故新增本服务承载,
  18. // 已在交付报告中登记为偏差。
  19. //
  20. // ---------------------------------------------------------------------------
  21. // 审批状态机(只允许 0 → {1, -1, -2},终态不可再变)
  22. // ---------------------------------------------------------------------------
  23. //
  24. // 0 未处理 ──approve──▶ 1 已通过(同事务触发资金方法;以 biz_no 作幂等 request_id)
  25. // 0 未处理 ──reject───▶ -1 不通过(无资金动作)
  26. // 0 未处理 ──cancel───▶ -2 取消(仅申请人本人或 super_admin/finance;无资金动作)
  27. // 终态再点 → 3002(数据冲突):**不做"静默成功"**,因为"钱到底动没动"必须一眼可判
  28. package service
  29. import (
  30. "context"
  31. "crypto/rand"
  32. "encoding/hex"
  33. "fmt"
  34. "strings"
  35. "time"
  36. "gorm.io/gorm"
  37. "yuansiwei/unified-backend/internal/academic/apperr"
  38. "yuansiwei/unified-backend/internal/academic/model"
  39. "yuansiwei/unified-backend/internal/academic/money"
  40. "yuansiwei/unified-backend/internal/academic/port"
  41. "yuansiwei/unified-backend/internal/academic/repository"
  42. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  43. appscope "yuansiwei/unified-backend/internal/infra/scope"
  44. )
  45. // 工单审批状态在"可操作"判断中的语义常量(复用 model 的取值,此处只做局部命名便于阅读)。
  46. const (
  47. // requestPending 未处理(唯一可被 approve/reject/cancel 的状态)。
  48. requestPending int8 = model.BalanceRequestApprovedPending
  49. // requestPassed 已通过(终态)。
  50. requestPassed int8 = model.BalanceRequestApprovedPassed
  51. // requestRejected 不通过(终态)。
  52. requestRejected int8 = model.BalanceRequestApprovedRejected
  53. // requestCancelled 取消(终态)。
  54. requestCancelled int8 = model.BalanceRequestApprovedCancelled
  55. )
  56. // BalanceRequestService 余额工单服务(创建 / 审批 / 驳回 / 撤销)。
  57. //
  58. // 字段:
  59. // - requests 工单仓储(加锁读 + CAS 推进);
  60. // - runner 事务运行器(资金方法 + 工单回写同一事务);
  61. // - fund 资金服务(充值/消费/退费/转结/坏账的**唯一实现**,本服务只分派、不重复实现);
  62. // - member 成员校验器(可 nil);
  63. // - sc 请求作用域(可 nil:系统任务视角)。
  64. type BalanceRequestService struct {
  65. requests *repository.BalanceRequestRepo
  66. runner *repository.TxRunner
  67. fund *FundService
  68. member port.MembershipChecker
  69. sc *appscope.Scope
  70. }
  71. // NewBalanceRequestService 构造余额工单服务。
  72. //
  73. // 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域);member port.MembershipChecker(可 nil)。
  74. // 出参:*BalanceRequestService(内部 FundService 用同一 db/scope 装配,保证同一事务可透传)。
  75. func NewBalanceRequestService(db *gorm.DB, sc *appscope.Scope, member port.MembershipChecker) *BalanceRequestService {
  76. return &BalanceRequestService{
  77. requests: repository.NewBalanceRequestRepo(db, sc),
  78. runner: repository.NewTxRunner(db),
  79. fund: NewFundService(db, sc, member),
  80. member: member,
  81. sc: sc,
  82. }
  83. }
  84. // ---------------------------------------------------------------- 端点 13:创建工单
  85. // CreateRequestInput 创建工单入参(端点 13 `POST /admin/finance/requests`)。
  86. //
  87. // 字段:
  88. // - Type 工单类型(100 普通充值 / 101 坏账 / 202 退费 / 203 取消充值活动 / 300 转结);
  89. // - UserID 目标用户;
  90. // - InstitutionID/CampusID 作用域维度;
  91. // - Money 金额(**已由 Handler 用 money.Parse 解析**,拒绝 float);
  92. // - TransferAccountsType 收款方式(0/1/2/3);
  93. // - OrderNo/Item/Imgs/Remark 附注(Imgs 只写不读,API 不回显);
  94. // - ToInstitutionID/ToCampusID 仅 type=300 转结使用。
  95. type CreateRequestInput struct {
  96. // Type 工单类型。
  97. Type int32
  98. // UserID 目标用户(ULID)。
  99. UserID string
  100. // InstitutionID 机构 ID。
  101. InstitutionID int64
  102. // CampusID 校区 ID。
  103. CampusID int64
  104. // ToInstitutionID 转入机构(仅 300)。
  105. ToInstitutionID int64
  106. // ToCampusID 转入校区(仅 300)。
  107. ToCampusID int64
  108. // Money 金额(元)。
  109. Money money.Amount
  110. // TransferAccountsType 收款方式。
  111. TransferAccountsType int8
  112. // OrderNo 交易单号(可空)。
  113. OrderNo string
  114. // Item 项目(可空)。
  115. Item string
  116. // Imgs 凭证图片 JSON 串(可空;**仅落库,不回显**)。
  117. Imgs string
  118. // Remark 备注(可空)。
  119. Remark string
  120. // RequestUserID 申请人(ULID;由 Handler 从作用域取)。
  121. RequestUserID string
  122. }
  123. // CreateRequestResult 创建工单结果(端点 13 响应 `{id,bizNo}`)。
  124. type CreateRequestResult struct {
  125. // ID 工单 ID。
  126. ID int64 `json:"id"`
  127. // BizNo 业务单号(对外单号,同时是审批通过时的幂等 request_id)。
  128. BizNo string `json:"bizNo"`
  129. }
  130. // requestTypesCreatable 可创建的工单类型白名单(与 §2「4.」的类型枚举对齐)。
  131. var requestTypesCreatable = map[int32]struct{}{
  132. model.BalanceRequestTypeRecharge: {}, // 100
  133. model.BalanceRequestTypeBadDebt: {}, // 101
  134. model.BalanceRequestTypeRefund: {}, // 202
  135. model.BalanceRequestTypeCancelRecharge: {}, // 203
  136. model.BalanceRequestTypeTransfer: {}, // 300
  137. }
  138. // Create 创建余额工单(端点 13)。
  139. //
  140. // 入参:ctx;in CreateRequestInput。
  141. // 出参:*CreateRequestResult;错误码 2001(缺参/类型非白名单)/2002(金额非法)/5002。
  142. //
  143. // 红线(设计 §2):`approved` **一律 0**(不得创建即通过)——资金变动必须走审批链;
  144. // 本方法也**不**触发任何资金动作(真正的触发点是 Approve)。
  145. func (s *BalanceRequestService) Create(ctx context.Context, in CreateRequestInput) (*CreateRequestResult, error) {
  146. if strings.TrimSpace(in.UserID) == "" {
  147. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: CreateRequest 需要 user_id"))
  148. }
  149. if _, ok := requestTypesCreatable[in.Type]; !ok {
  150. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  151. errString("service: CreateRequest 工单类型不在白名单(100/101/202/203/300)"))
  152. }
  153. if in.InstitutionID <= 0 {
  154. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  155. errString("service: CreateRequest 需要 institution_id(>0)"))
  156. }
  157. if in.Type == model.BalanceRequestTypeTransfer {
  158. if in.ToInstitutionID <= 0 || in.ToCampusID <= 0 {
  159. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  160. errString("service: 转结工单必须指定 toInstitutionId/toCampusId"))
  161. }
  162. } else if err := validatePositiveAmount(in.Money, "CreateRequest"); err != nil {
  163. return nil, err
  164. }
  165. if in.Type == model.BalanceRequestTypeTransfer {
  166. if err := validatePositiveAmount(in.Money, "CreateRequest"); err != nil {
  167. return nil, err
  168. }
  169. }
  170. bizNo, err := newRequestBizNo()
  171. if err != nil {
  172. return nil, err
  173. }
  174. row := &model.UserBalanceRequest{
  175. BizNo: bizNo, Type: in.Type, UserID: in.UserID,
  176. InstitutionID: in.InstitutionID, CampusID: in.CampusID,
  177. Money: in.Money.String(), TransferAccountsType: in.TransferAccountsType,
  178. RequestUserID: in.RequestUserID, Approved: requestPending,
  179. }
  180. now := time.Now()
  181. row.RequestTime = &now
  182. if in.Type == model.BalanceRequestTypeTransfer {
  183. toInst, toCampus := in.ToInstitutionID, in.ToCampusID
  184. row.ToInstitutionID = &toInst
  185. row.ToCampusID = &toCampus
  186. }
  187. row.OrderNo = optionalString(in.OrderNo)
  188. row.Item = optionalString(in.Item)
  189. row.Imgs = optionalString(in.Imgs)
  190. row.Remark = optionalString(in.Remark)
  191. if err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  192. return s.requests.Insert(repository.WithTxContext(ctx, tx), tx, row)
  193. }); err != nil {
  194. if strings.Contains(err.Error(), "重复的业务单号") {
  195. return nil, infraerrors.WithCause(infraerrors.ErrConflict,
  196. errString("service: 工单号重复(biz_no 撞键),请重试"))
  197. }
  198. return nil, err
  199. }
  200. return &CreateRequestResult{ID: row.ID, BizNo: row.BizNo}, nil
  201. }
  202. // newRequestBizNo 生成工单业务单号(32 位 hex = 16 字节随机,列宽 `varchar(32)`)。
  203. //
  204. // 为什么不用 ULID:旧库 `t_user_balance_request.id` 是 32 位 UUID(无连字符),
  205. // 目标态 `biz_no` 尺寸就是按它定的;这里用同一长度/字符集的随机串,
  206. // 迁移与回滚时"旧 UUID ↔ 新 biz_no"在形态上不可区分(不需要额外转换规则)。
  207. //
  208. // 出参:32 位小写 hex;随机源不可用 → ErrInternal(**不降级成时间戳**:可预测单号会被撞键)。
  209. func newRequestBizNo() (string, error) {
  210. buf := make([]byte, 16)
  211. if _, err := rand.Read(buf); err != nil {
  212. return "", infraerrors.WithCause(infraerrors.ErrInternal,
  213. errString("service: 工单号随机源不可用: "+err.Error()))
  214. }
  215. return hex.EncodeToString(buf), nil
  216. }
  217. // ---------------------------------------------------------------- 端点 14:审批通过
  218. // ApproveRequestInput 审批通过入参(端点 14 `POST /admin/finance/requests/:id/approve`)。
  219. type ApproveRequestInput struct {
  220. // ID 工单 ID。
  221. ID int64
  222. // ResponseMsg 审批意见(可空)。
  223. ResponseMsg string
  224. // OperatorID 审批人(ULID;写 response_user_id)。
  225. OperatorID string
  226. }
  227. // ApproveRequestResult 审批通过结果(端点 14 响应 `{approved:1,result:{...}}`)。
  228. type ApproveRequestResult struct {
  229. // Approved 恒 1(通过)。
  230. Approved int8 `json:"approved"`
  231. // Type 工单类型(便于前端按类型解释 result 形状)。
  232. Type int32 `json:"type"`
  233. // BizNo 工单号(= 资金流水的 request_id,对账锚点)。
  234. BizNo string `json:"bizNo"`
  235. // Result 对应资金方法的返回值(各类型形状不同:`{logId,balance,repeated}` / `{logId,specialExpenses,...}` / `{logId106,logId206,...}`)。
  236. Result any `json:"result"`
  237. // Repeated true = 资金侧幂等命中(工单此前已通过过、或同一 biz_no 已入账)。
  238. Repeated bool `json:"repeated"`
  239. }
  240. // Approve 审批通过(端点 14):**同一事务**内先调资金方法、后回写工单状态。
  241. //
  242. // 入参:ctx;in ApproveRequestInput。
  243. // 出参:*ApproveRequestResult;错误码 2001/3001/3002/31xx(资金方法透传)/1004/5002。
  244. //
  245. // 时序(不可调整,§10.3 + §5.1 纪律 5):
  246. //
  247. // ① 事务开始
  248. // ② `GetForUpdate`:加行锁读工单(断言 approved == 0,否则 3002 —— 终态不再触发资金)
  249. // ③ 按类型分派资金方法(`WithTxContext(ctx, tx)` 透传同一事务)
  250. // 幂等键 = 工单 `biz_no`:重复审批时资金侧撞 `uk_request_type` → 回放首次结果
  251. // ④ `UpdateApproved(+1)` CAS(`WHERE approved = 0`):影响 0 行 ⇒ 并发被抢 ⇒ 3002 回滚
  252. // ⑤ 提交
  253. //
  254. // ⚠ 203(取消充值活动)**在本端点不支持**:`t_user_balance_request` 没有 activity_id 列
  255. // (dump 也没有),金额/活动只能靠 `item`/`order_no` 自由文本猜——猜错就是把钱退给错的活动。
  256. // 该动作已有专用端点 30(`RefundService.CancelRechargeActivity`,入参含 activityId),
  257. // 因此这里显式 3140 并给出文案,**不静默跳过**(跳过会让"审批通过"变成什么都没发生)。
  258. func (s *BalanceRequestService) Approve(ctx context.Context, in ApproveRequestInput) (*ApproveRequestResult, error) {
  259. if in.ID <= 0 {
  260. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: Approve 需要工单 id"))
  261. }
  262. out := &ApproveRequestResult{}
  263. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  264. txCtx := repository.WithTxContext(ctx, tx)
  265. req, err := s.requests.GetForUpdate(txCtx, tx, in.ID)
  266. if err != nil {
  267. return err
  268. }
  269. if req.Approved != requestPending {
  270. return infraerrors.WithCause(infraerrors.ErrConflict,
  271. errString(fmt.Sprintf("service: 工单 %d 已处理(approved=%d),不会重复触发资金动作",
  272. req.ID, req.Approved)))
  273. }
  274. amount, perr := money.Parse(req.Money)
  275. if perr != nil {
  276. return infraerrors.WithCause(infraerrors.ErrDBError, perr)
  277. }
  278. result, repeated, derr := s.dispatchByType(txCtx, req, amount, in.OperatorID)
  279. if derr != nil {
  280. return derr
  281. }
  282. now := time.Now()
  283. operator := in.OperatorID
  284. rows, uerr := s.requests.UpdateApproved(txCtx, tx, req.ID, requestPassed, &operator, &now)
  285. if uerr != nil {
  286. return uerr
  287. }
  288. if rows == 0 {
  289. return infraerrors.WithCause(infraerrors.ErrConflict,
  290. errString("service: 工单状态已被并发改动(CAS 影响 0 行)"))
  291. }
  292. if strings.TrimSpace(in.ResponseMsg) != "" {
  293. if _, merr := s.requests.UpdateResponseMsg(txCtx, tx, req.ID, in.ResponseMsg); merr != nil {
  294. return merr
  295. }
  296. }
  297. out.Approved = requestPassed
  298. out.Type = req.Type
  299. out.BizNo = req.BizNo
  300. out.Result = result
  301. out.Repeated = repeated
  302. return nil
  303. })
  304. if err != nil {
  305. return nil, err
  306. }
  307. return out, nil
  308. }
  309. // dispatchByType 按工单类型分派到对应资金方法(**唯一实现复用,不重复写资金逻辑**)。
  310. //
  311. // 入参:txCtx 携带事务的上下文(必须透传给下游 service);req 工单行;amount 已解析金额;operatorID 操作人。
  312. // 出参:(资金方法返回值, 是否幂等命中, error)。
  313. //
  314. // 分派表(工单类型 → 资金方法 → 流水类型):
  315. //
  316. // 100 普通充值 → FundService.Recharge → type=100
  317. // 101 坏账 → FundService.BadDebt → type=108(balance 与 special_expenses 双增)
  318. // 202 退费 → FundService.Refund → type=207
  319. // 300 转结 → FundService.Transfer → type=106/206(两条流水共用 request_id)
  320. // 203 取消充值活动 → **不支持**(见 Approve 注释)→ 3140
  321. func (s *BalanceRequestService) dispatchByType(
  322. txCtx context.Context, req *model.UserBalanceRequest, amount money.Amount, operatorID string,
  323. ) (any, bool, error) {
  324. remark := ""
  325. if req.Remark != nil {
  326. remark = *req.Remark
  327. }
  328. switch req.Type {
  329. case model.BalanceRequestTypeRecharge:
  330. res, err := s.fund.Recharge(txCtx, RechargeInput{
  331. UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID,
  332. Money: amount, Type: model.ExpenseLogTypeRecharge, RequestID: req.BizNo,
  333. Remark: remark, OperatorID: operatorID,
  334. })
  335. if err != nil {
  336. return nil, false, err
  337. }
  338. return res, res.Repeated, nil
  339. case model.BalanceRequestTypeBadDebt:
  340. res, err := s.fund.BadDebt(txCtx, BadDebtInput{
  341. UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID,
  342. Money: amount, RequestID: req.BizNo, Remark: remark, OperatorID: operatorID,
  343. })
  344. if err != nil {
  345. return nil, false, err
  346. }
  347. return res, res.Repeated, nil
  348. case model.BalanceRequestTypeRefund:
  349. res, err := s.fund.Refund(txCtx, RefundInput{
  350. UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID,
  351. Money: amount, Type: model.ExpenseLogTypeRefund, RequestID: req.BizNo,
  352. Remark: remark, OperatorID: operatorID,
  353. })
  354. if err != nil {
  355. return nil, false, err
  356. }
  357. return res, res.Repeated, nil
  358. case model.BalanceRequestTypeTransfer:
  359. if req.ToInstitutionID == nil || req.ToCampusID == nil {
  360. return nil, false, infraerrors.WithCause(apperr.ErrBusinessParam,
  361. errString("service: 转结工单缺少 toInstitutionId/toCampusId,无法执行"))
  362. }
  363. res, err := s.fund.Transfer(txCtx, TransferInput{
  364. UserID: req.UserID,
  365. FromInstitutionID: req.InstitutionID, FromCampusID: req.CampusID,
  366. ToInstitutionID: *req.ToInstitutionID, ToCampusID: *req.ToCampusID,
  367. Money: amount, RequestID: req.BizNo, Remark: remark, OperatorID: operatorID,
  368. })
  369. if err != nil {
  370. return nil, false, err
  371. }
  372. return res, res.Repeated, nil
  373. case model.BalanceRequestTypeCancelRecharge:
  374. return nil, false, infraerrors.WithCause(apperr.ErrBusinessParam,
  375. errString("service: 工单类型 203(取消充值活动)无 activity_id 列,不能在本端点执行;"+
  376. "请用活动端点 30 直接取消(RefundService.CancelRechargeActivity)"))
  377. default:
  378. return nil, false, infraerrors.WithCause(apperr.ErrBusinessParam,
  379. errString(fmt.Sprintf("service: 工单类型 %d 不支持审批入账", req.Type)))
  380. }
  381. }
  382. // ---------------------------------------------------------------- 端点 15 / 16:驳回 / 撤销
  383. // RejectRequestInput 驳回入参(端点 15 `POST /admin/finance/requests/:id/reject`)。
  384. type RejectRequestInput struct {
  385. // ID 工单 ID。
  386. ID int64
  387. // ResponseMsg 驳回意见(**必填**:驳回必须说明理由,便于对账与申诉)。
  388. ResponseMsg string
  389. // OperatorID 审批人。
  390. OperatorID string
  391. }
  392. // RejectRequestResult 驳回结果(端点 15 响应 `{approved:-1}`)。
  393. type RejectRequestResult struct {
  394. // Approved 恒 -1。
  395. Approved int8 `json:"approved"`
  396. // ID 工单 ID。
  397. ID int64 `json:"id"`
  398. }
  399. // Reject 驳回工单(端点 15):仅 approved=0 可驳回;**无任何资金动作**。
  400. //
  401. // 入参:ctx;in RejectRequestInput。出参:*RejectRequestResult;2001/3002/5002。
  402. func (s *BalanceRequestService) Reject(ctx context.Context, in RejectRequestInput) (*RejectRequestResult, error) {
  403. if in.ID <= 0 {
  404. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: Reject 需要工单 id"))
  405. }
  406. if strings.TrimSpace(in.ResponseMsg) == "" {
  407. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: Reject 需要 responseMsg"))
  408. }
  409. if err := s.pendingTransition(ctx, in.ID, requestRejected, in.OperatorID); err != nil {
  410. return nil, err
  411. }
  412. return &RejectRequestResult{Approved: requestRejected, ID: in.ID}, nil
  413. }
  414. // CancelRequestInput 撤销入参(端点 16 `POST /admin/finance/requests/:id/cancel`)。
  415. type CancelRequestInput struct {
  416. // ID 工单 ID。
  417. ID int64
  418. // OperatorID 操作人(申请人本人或 super_admin/finance)。
  419. OperatorID string
  420. // Role 操作人角色(作用域角色码;用于"本人或财务"判定)。
  421. Role string
  422. // SuperAdmin 是否平台超管。
  423. SuperAdmin bool
  424. }
  425. // CancelRequestResult 撤销结果(端点 16 响应 `{approved:-2}`)。
  426. type CancelRequestResult struct {
  427. // Approved 恒 -2。
  428. Approved int8 `json:"approved"`
  429. // ID 工单 ID。
  430. ID int64 `json:"id"`
  431. }
  432. // Cancel 撤销工单(端点 16):仅 approved=0 且**申请人本人或 super_admin/finance**。
  433. //
  434. // 入参:ctx;in CancelRequestInput。出参:*CancelRequestResult;2001/1003/3002/5002。
  435. //
  436. // 权限判据(两条**或**关系,都必须能自证失败):
  437. // 1. `operatorID == req.request_user_id`(本人撤回自己的申请);
  438. // 2. 角色 ∈ {super_admin, finance}(管理员兜底处理僵尸单)。
  439. //
  440. // 注意:**不要求** campus 一致(申请人可能是机构级财务代提),但机构由仓储 Scoped 保证。
  441. func (s *BalanceRequestService) Cancel(ctx context.Context, in CancelRequestInput) (*CancelRequestResult, error) {
  442. if in.ID <= 0 {
  443. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: Cancel 需要工单 id"))
  444. }
  445. req, err := s.requests.GetByID(ctx, in.ID)
  446. if err != nil {
  447. return nil, err
  448. }
  449. isOwner := strings.TrimSpace(in.OperatorID) != "" && in.OperatorID == req.RequestUserID
  450. isManager := in.SuperAdmin || in.Role == "super_admin" || in.Role == "finance"
  451. if !isOwner && !isManager {
  452. return nil, infraerrors.WithCause(infraerrors.ErrForbidden,
  453. errString("service: 撤单仅限申请人本人或 super_admin/finance"))
  454. }
  455. if err := s.pendingTransition(ctx, in.ID, requestCancelled, in.OperatorID); err != nil {
  456. return nil, err
  457. }
  458. return &CancelRequestResult{Approved: requestCancelled, ID: in.ID}, nil
  459. }
  460. // pendingTransition 终态推进的公共实现(驳回/撤销共用:**无资金动作**,只做 CAS)。
  461. //
  462. // 入参:ctx;id 工单 ID;to 目标终态(-1 驳回 / -2 取消);operatorID 操作人。
  463. // 出参:nil 成功;3002(工单已被处理/不存在)/* 仓储错误。
  464. //
  465. // 实现要点:**一次 CAS 搞定状态判断与推进**(`WHERE approved = 0`),不做"先读后写"——
  466. // 先读会让两个并发请求都读到 0 再各自写,虽然终态相同但 response_time/审批人会互相覆盖。
  467. func (s *BalanceRequestService) pendingTransition(ctx context.Context, id int64, to int8, operatorID string) error {
  468. return s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  469. txCtx := repository.WithTxContext(ctx, tx)
  470. now := time.Now()
  471. var operator *string
  472. if strings.TrimSpace(operatorID) != "" {
  473. operator = &operatorID
  474. }
  475. rows, err := s.requests.UpdateApproved(txCtx, tx, id, to, operator, &now)
  476. if err != nil {
  477. return err
  478. }
  479. if rows == 0 {
  480. return infraerrors.WithCause(infraerrors.ErrConflict,
  481. errString(fmt.Sprintf("service: 工单 %d 不存在或已处理(仅 approved=0 可推进)", id)))
  482. }
  483. return nil
  484. })
  485. }