| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512 |
- // 余额工单服务(端点 13~16:创建 / 通过 / 驳回 / 撤销)。
- //
- // 依据:unified/03-migration/academic-domain-design.md
- // - §4.1 端点 13~16;§2「4. t_user_balance_request」的资金变动触发点定义:
- // **「approved 由 0 → 1 的审批动作」是唯一触发点,且 Service 同事务内先调资金方法、后回写工单**
- // - §10.3 事务模板:`repository.WithinTx` + 事务内调下游 service **必须** `WithTxContext` 透传
- // - §5.1 纪律 5(幂等回放等价):审批通过时以工单 `biz_no` 作 `request_id`——
- // 同一工单被重复点"通过"不会二次入账(资金侧 `uk_request_type` 兜底,且 CAS 只让一次成功)。
- //
- // ---------------------------------------------------------------------------
- // 为什么本服务存在(设计 §8 的 AC-12 文件清单里没有它)
- // ---------------------------------------------------------------------------
- //
- // AC-12 的文件清单只列了 handler 文件,但端点 14「通过」不是"读一眼再写一个状态":
- // 它必须**按工单类型分派到对应资金方法**(100 充值 / 101 坏账 / 202 退费 / 300 转结),
- // 且资金写入与工单状态回写必须**同一个事务**(§10.3)。把这套编排放在 Handler 里
- // 会同时违反两条纪律(Handler 不写业务、事务边界只在 Service),故新增本服务承载,
- // 已在交付报告中登记为偏差。
- //
- // ---------------------------------------------------------------------------
- // 审批状态机(只允许 0 → {1, -1, -2},终态不可再变)
- // ---------------------------------------------------------------------------
- //
- // 0 未处理 ──approve──▶ 1 已通过(同事务触发资金方法;以 biz_no 作幂等 request_id)
- // 0 未处理 ──reject───▶ -1 不通过(无资金动作)
- // 0 未处理 ──cancel───▶ -2 取消(仅申请人本人或 super_admin/finance;无资金动作)
- // 终态再点 → 3002(数据冲突):**不做"静默成功"**,因为"钱到底动没动"必须一眼可判
- package service
- import (
- "context"
- "crypto/rand"
- "encoding/hex"
- "fmt"
- "strings"
- "time"
- "gorm.io/gorm"
- "yuansiwei/unified-backend/internal/academic/apperr"
- "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"
- )
- // 工单审批状态在"可操作"判断中的语义常量(复用 model 的取值,此处只做局部命名便于阅读)。
- const (
- // requestPending 未处理(唯一可被 approve/reject/cancel 的状态)。
- requestPending int8 = model.BalanceRequestApprovedPending
- // requestPassed 已通过(终态)。
- requestPassed int8 = model.BalanceRequestApprovedPassed
- // requestRejected 不通过(终态)。
- requestRejected int8 = model.BalanceRequestApprovedRejected
- // requestCancelled 取消(终态)。
- requestCancelled int8 = model.BalanceRequestApprovedCancelled
- )
- // BalanceRequestService 余额工单服务(创建 / 审批 / 驳回 / 撤销)。
- //
- // 字段:
- // - requests 工单仓储(加锁读 + CAS 推进);
- // - runner 事务运行器(资金方法 + 工单回写同一事务);
- // - fund 资金服务(充值/消费/退费/转结/坏账的**唯一实现**,本服务只分派、不重复实现);
- // - member 成员校验器(可 nil);
- // - sc 请求作用域(可 nil:系统任务视角)。
- type BalanceRequestService struct {
- requests *repository.BalanceRequestRepo
- runner *repository.TxRunner
- fund *FundService
- member port.MembershipChecker
- sc *appscope.Scope
- }
- // NewBalanceRequestService 构造余额工单服务。
- //
- // 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域);member port.MembershipChecker(可 nil)。
- // 出参:*BalanceRequestService(内部 FundService 用同一 db/scope 装配,保证同一事务可透传)。
- func NewBalanceRequestService(db *gorm.DB, sc *appscope.Scope, member port.MembershipChecker) *BalanceRequestService {
- return &BalanceRequestService{
- requests: repository.NewBalanceRequestRepo(db, sc),
- runner: repository.NewTxRunner(db),
- fund: NewFundService(db, sc, member),
- member: member,
- sc: sc,
- }
- }
- // ---------------------------------------------------------------- 端点 13:创建工单
- // CreateRequestInput 创建工单入参(端点 13 `POST /admin/finance/requests`)。
- //
- // 字段:
- // - Type 工单类型(100 普通充值 / 101 坏账 / 202 退费 / 203 取消充值活动 / 300 转结);
- // - UserID 目标用户;
- // - InstitutionID/CampusID 作用域维度;
- // - Money 金额(**已由 Handler 用 money.Parse 解析**,拒绝 float);
- // - TransferAccountsType 收款方式(0/1/2/3);
- // - OrderNo/Item/Imgs/Remark 附注(Imgs 只写不读,API 不回显);
- // - ToInstitutionID/ToCampusID 仅 type=300 转结使用。
- type CreateRequestInput struct {
- // Type 工单类型。
- Type int32
- // UserID 目标用户(ULID)。
- UserID string
- // InstitutionID 机构 ID。
- InstitutionID int64
- // CampusID 校区 ID。
- CampusID int64
- // ToInstitutionID 转入机构(仅 300)。
- ToInstitutionID int64
- // ToCampusID 转入校区(仅 300)。
- ToCampusID int64
- // Money 金额(元)。
- Money money.Amount
- // TransferAccountsType 收款方式。
- TransferAccountsType int8
- // OrderNo 交易单号(可空)。
- OrderNo string
- // Item 项目(可空)。
- Item string
- // Imgs 凭证图片 JSON 串(可空;**仅落库,不回显**)。
- Imgs string
- // Remark 备注(可空)。
- Remark string
- // RequestUserID 申请人(ULID;由 Handler 从作用域取)。
- RequestUserID string
- }
- // CreateRequestResult 创建工单结果(端点 13 响应 `{id,bizNo}`)。
- type CreateRequestResult struct {
- // ID 工单 ID。
- ID int64 `json:"id"`
- // BizNo 业务单号(对外单号,同时是审批通过时的幂等 request_id)。
- BizNo string `json:"bizNo"`
- }
- // requestTypesCreatable 可创建的工单类型白名单(与 §2「4.」的类型枚举对齐)。
- var requestTypesCreatable = map[int32]struct{}{
- model.BalanceRequestTypeRecharge: {}, // 100
- model.BalanceRequestTypeBadDebt: {}, // 101
- model.BalanceRequestTypeRefund: {}, // 202
- model.BalanceRequestTypeCancelRecharge: {}, // 203
- model.BalanceRequestTypeTransfer: {}, // 300
- }
- // Create 创建余额工单(端点 13)。
- //
- // 入参:ctx;in CreateRequestInput。
- // 出参:*CreateRequestResult;错误码 2001(缺参/类型非白名单)/2002(金额非法)/5002。
- //
- // 红线(设计 §2):`approved` **一律 0**(不得创建即通过)——资金变动必须走审批链;
- // 本方法也**不**触发任何资金动作(真正的触发点是 Approve)。
- func (s *BalanceRequestService) Create(ctx context.Context, in CreateRequestInput) (*CreateRequestResult, error) {
- if strings.TrimSpace(in.UserID) == "" {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: CreateRequest 需要 user_id"))
- }
- if _, ok := requestTypesCreatable[in.Type]; !ok {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: CreateRequest 工单类型不在白名单(100/101/202/203/300)"))
- }
- if in.InstitutionID <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: CreateRequest 需要 institution_id(>0)"))
- }
- if in.Type == model.BalanceRequestTypeTransfer {
- if in.ToInstitutionID <= 0 || in.ToCampusID <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("service: 转结工单必须指定 toInstitutionId/toCampusId"))
- }
- } else if err := validatePositiveAmount(in.Money, "CreateRequest"); err != nil {
- return nil, err
- }
- if in.Type == model.BalanceRequestTypeTransfer {
- if err := validatePositiveAmount(in.Money, "CreateRequest"); err != nil {
- return nil, err
- }
- }
- bizNo, err := newRequestBizNo()
- if err != nil {
- return nil, err
- }
- row := &model.UserBalanceRequest{
- BizNo: bizNo, Type: in.Type, UserID: in.UserID,
- InstitutionID: in.InstitutionID, CampusID: in.CampusID,
- Money: in.Money.String(), TransferAccountsType: in.TransferAccountsType,
- RequestUserID: in.RequestUserID, Approved: requestPending,
- }
- now := time.Now()
- row.RequestTime = &now
- if in.Type == model.BalanceRequestTypeTransfer {
- toInst, toCampus := in.ToInstitutionID, in.ToCampusID
- row.ToInstitutionID = &toInst
- row.ToCampusID = &toCampus
- }
- row.OrderNo = optionalString(in.OrderNo)
- row.Item = optionalString(in.Item)
- row.Imgs = optionalString(in.Imgs)
- row.Remark = optionalString(in.Remark)
- if err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
- return s.requests.Insert(repository.WithTxContext(ctx, tx), tx, row)
- }); err != nil {
- if strings.Contains(err.Error(), "重复的业务单号") {
- return nil, infraerrors.WithCause(infraerrors.ErrConflict,
- errString("service: 工单号重复(biz_no 撞键),请重试"))
- }
- return nil, err
- }
- return &CreateRequestResult{ID: row.ID, BizNo: row.BizNo}, nil
- }
- // newRequestBizNo 生成工单业务单号(32 位 hex = 16 字节随机,列宽 `varchar(32)`)。
- //
- // 为什么不用 ULID:旧库 `t_user_balance_request.id` 是 32 位 UUID(无连字符),
- // 目标态 `biz_no` 尺寸就是按它定的;这里用同一长度/字符集的随机串,
- // 迁移与回滚时"旧 UUID ↔ 新 biz_no"在形态上不可区分(不需要额外转换规则)。
- //
- // 出参:32 位小写 hex;随机源不可用 → ErrInternal(**不降级成时间戳**:可预测单号会被撞键)。
- func newRequestBizNo() (string, error) {
- buf := make([]byte, 16)
- if _, err := rand.Read(buf); err != nil {
- return "", infraerrors.WithCause(infraerrors.ErrInternal,
- errString("service: 工单号随机源不可用: "+err.Error()))
- }
- return hex.EncodeToString(buf), nil
- }
- // ---------------------------------------------------------------- 端点 14:审批通过
- // ApproveRequestInput 审批通过入参(端点 14 `POST /admin/finance/requests/:id/approve`)。
- type ApproveRequestInput struct {
- // ID 工单 ID。
- ID int64
- // ResponseMsg 审批意见(可空)。
- ResponseMsg string
- // OperatorID 审批人(ULID;写 response_user_id)。
- OperatorID string
- }
- // ApproveRequestResult 审批通过结果(端点 14 响应 `{approved:1,result:{...}}`)。
- type ApproveRequestResult struct {
- // Approved 恒 1(通过)。
- Approved int8 `json:"approved"`
- // Type 工单类型(便于前端按类型解释 result 形状)。
- Type int32 `json:"type"`
- // BizNo 工单号(= 资金流水的 request_id,对账锚点)。
- BizNo string `json:"bizNo"`
- // Result 对应资金方法的返回值(各类型形状不同:`{logId,balance,repeated}` / `{logId,specialExpenses,...}` / `{logId106,logId206,...}`)。
- Result any `json:"result"`
- // Repeated true = 资金侧幂等命中(工单此前已通过过、或同一 biz_no 已入账)。
- Repeated bool `json:"repeated"`
- }
- // Approve 审批通过(端点 14):**同一事务**内先调资金方法、后回写工单状态。
- //
- // 入参:ctx;in ApproveRequestInput。
- // 出参:*ApproveRequestResult;错误码 2001/3001/3002/31xx(资金方法透传)/1004/5002。
- //
- // 时序(不可调整,§10.3 + §5.1 纪律 5):
- //
- // ① 事务开始
- // ② `GetForUpdate`:加行锁读工单(断言 approved == 0,否则 3002 —— 终态不再触发资金)
- // ③ 按类型分派资金方法(`WithTxContext(ctx, tx)` 透传同一事务)
- // 幂等键 = 工单 `biz_no`:重复审批时资金侧撞 `uk_request_type` → 回放首次结果
- // ④ `UpdateApproved(+1)` CAS(`WHERE approved = 0`):影响 0 行 ⇒ 并发被抢 ⇒ 3002 回滚
- // ⑤ 提交
- //
- // ⚠ 203(取消充值活动)**在本端点不支持**:`t_user_balance_request` 没有 activity_id 列
- // (dump 也没有),金额/活动只能靠 `item`/`order_no` 自由文本猜——猜错就是把钱退给错的活动。
- // 该动作已有专用端点 30(`RefundService.CancelRechargeActivity`,入参含 activityId),
- // 因此这里显式 3140 并给出文案,**不静默跳过**(跳过会让"审批通过"变成什么都没发生)。
- func (s *BalanceRequestService) Approve(ctx context.Context, in ApproveRequestInput) (*ApproveRequestResult, error) {
- if in.ID <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: Approve 需要工单 id"))
- }
- out := &ApproveRequestResult{}
- err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
- txCtx := repository.WithTxContext(ctx, tx)
- req, err := s.requests.GetForUpdate(txCtx, tx, in.ID)
- if err != nil {
- return err
- }
- if req.Approved != requestPending {
- return infraerrors.WithCause(infraerrors.ErrConflict,
- errString(fmt.Sprintf("service: 工单 %d 已处理(approved=%d),不会重复触发资金动作",
- req.ID, req.Approved)))
- }
- amount, perr := money.Parse(req.Money)
- if perr != nil {
- return infraerrors.WithCause(infraerrors.ErrDBError, perr)
- }
- result, repeated, derr := s.dispatchByType(txCtx, req, amount, in.OperatorID)
- if derr != nil {
- return derr
- }
- now := time.Now()
- operator := in.OperatorID
- rows, uerr := s.requests.UpdateApproved(txCtx, tx, req.ID, requestPassed, &operator, &now)
- if uerr != nil {
- return uerr
- }
- if rows == 0 {
- return infraerrors.WithCause(infraerrors.ErrConflict,
- errString("service: 工单状态已被并发改动(CAS 影响 0 行)"))
- }
- if strings.TrimSpace(in.ResponseMsg) != "" {
- if _, merr := s.requests.UpdateResponseMsg(txCtx, tx, req.ID, in.ResponseMsg); merr != nil {
- return merr
- }
- }
- out.Approved = requestPassed
- out.Type = req.Type
- out.BizNo = req.BizNo
- out.Result = result
- out.Repeated = repeated
- return nil
- })
- if err != nil {
- return nil, err
- }
- return out, nil
- }
- // dispatchByType 按工单类型分派到对应资金方法(**唯一实现复用,不重复写资金逻辑**)。
- //
- // 入参:txCtx 携带事务的上下文(必须透传给下游 service);req 工单行;amount 已解析金额;operatorID 操作人。
- // 出参:(资金方法返回值, 是否幂等命中, error)。
- //
- // 分派表(工单类型 → 资金方法 → 流水类型):
- //
- // 100 普通充值 → FundService.Recharge → type=100
- // 101 坏账 → FundService.BadDebt → type=108(balance 与 special_expenses 双增)
- // 202 退费 → FundService.Refund → type=207
- // 300 转结 → FundService.Transfer → type=106/206(两条流水共用 request_id)
- // 203 取消充值活动 → **不支持**(见 Approve 注释)→ 3140
- func (s *BalanceRequestService) dispatchByType(
- txCtx context.Context, req *model.UserBalanceRequest, amount money.Amount, operatorID string,
- ) (any, bool, error) {
- remark := ""
- if req.Remark != nil {
- remark = *req.Remark
- }
- switch req.Type {
- case model.BalanceRequestTypeRecharge:
- res, err := s.fund.Recharge(txCtx, RechargeInput{
- UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID,
- Money: amount, Type: model.ExpenseLogTypeRecharge, RequestID: req.BizNo,
- Remark: remark, OperatorID: operatorID,
- })
- if err != nil {
- return nil, false, err
- }
- return res, res.Repeated, nil
- case model.BalanceRequestTypeBadDebt:
- res, err := s.fund.BadDebt(txCtx, BadDebtInput{
- UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID,
- Money: amount, RequestID: req.BizNo, Remark: remark, OperatorID: operatorID,
- })
- if err != nil {
- return nil, false, err
- }
- return res, res.Repeated, nil
- case model.BalanceRequestTypeRefund:
- res, err := s.fund.Refund(txCtx, RefundInput{
- UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID,
- Money: amount, Type: model.ExpenseLogTypeRefund, RequestID: req.BizNo,
- Remark: remark, OperatorID: operatorID,
- })
- if err != nil {
- return nil, false, err
- }
- return res, res.Repeated, nil
- case model.BalanceRequestTypeTransfer:
- if req.ToInstitutionID == nil || req.ToCampusID == nil {
- return nil, false, infraerrors.WithCause(apperr.ErrBusinessParam,
- errString("service: 转结工单缺少 toInstitutionId/toCampusId,无法执行"))
- }
- res, err := s.fund.Transfer(txCtx, TransferInput{
- UserID: req.UserID,
- FromInstitutionID: req.InstitutionID, FromCampusID: req.CampusID,
- ToInstitutionID: *req.ToInstitutionID, ToCampusID: *req.ToCampusID,
- Money: amount, RequestID: req.BizNo, Remark: remark, OperatorID: operatorID,
- })
- if err != nil {
- return nil, false, err
- }
- return res, res.Repeated, nil
- case model.BalanceRequestTypeCancelRecharge:
- return nil, false, infraerrors.WithCause(apperr.ErrBusinessParam,
- errString("service: 工单类型 203(取消充值活动)无 activity_id 列,不能在本端点执行;"+
- "请用活动端点 30 直接取消(RefundService.CancelRechargeActivity)"))
- default:
- return nil, false, infraerrors.WithCause(apperr.ErrBusinessParam,
- errString(fmt.Sprintf("service: 工单类型 %d 不支持审批入账", req.Type)))
- }
- }
- // ---------------------------------------------------------------- 端点 15 / 16:驳回 / 撤销
- // RejectRequestInput 驳回入参(端点 15 `POST /admin/finance/requests/:id/reject`)。
- type RejectRequestInput struct {
- // ID 工单 ID。
- ID int64
- // ResponseMsg 驳回意见(**必填**:驳回必须说明理由,便于对账与申诉)。
- ResponseMsg string
- // OperatorID 审批人。
- OperatorID string
- }
- // RejectRequestResult 驳回结果(端点 15 响应 `{approved:-1}`)。
- type RejectRequestResult struct {
- // Approved 恒 -1。
- Approved int8 `json:"approved"`
- // ID 工单 ID。
- ID int64 `json:"id"`
- }
- // Reject 驳回工单(端点 15):仅 approved=0 可驳回;**无任何资金动作**。
- //
- // 入参:ctx;in RejectRequestInput。出参:*RejectRequestResult;2001/3002/5002。
- func (s *BalanceRequestService) Reject(ctx context.Context, in RejectRequestInput) (*RejectRequestResult, error) {
- if in.ID <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: Reject 需要工单 id"))
- }
- if strings.TrimSpace(in.ResponseMsg) == "" {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: Reject 需要 responseMsg"))
- }
- if err := s.pendingTransition(ctx, in.ID, requestRejected, in.OperatorID); err != nil {
- return nil, err
- }
- return &RejectRequestResult{Approved: requestRejected, ID: in.ID}, nil
- }
- // CancelRequestInput 撤销入参(端点 16 `POST /admin/finance/requests/:id/cancel`)。
- type CancelRequestInput struct {
- // ID 工单 ID。
- ID int64
- // OperatorID 操作人(申请人本人或 super_admin/finance)。
- OperatorID string
- // Role 操作人角色(作用域角色码;用于"本人或财务"判定)。
- Role string
- // SuperAdmin 是否平台超管。
- SuperAdmin bool
- }
- // CancelRequestResult 撤销结果(端点 16 响应 `{approved:-2}`)。
- type CancelRequestResult struct {
- // Approved 恒 -2。
- Approved int8 `json:"approved"`
- // ID 工单 ID。
- ID int64 `json:"id"`
- }
- // Cancel 撤销工单(端点 16):仅 approved=0 且**申请人本人或 super_admin/finance**。
- //
- // 入参:ctx;in CancelRequestInput。出参:*CancelRequestResult;2001/1003/3002/5002。
- //
- // 权限判据(两条**或**关系,都必须能自证失败):
- // 1. `operatorID == req.request_user_id`(本人撤回自己的申请);
- // 2. 角色 ∈ {super_admin, finance}(管理员兜底处理僵尸单)。
- //
- // 注意:**不要求** campus 一致(申请人可能是机构级财务代提),但机构由仓储 Scoped 保证。
- func (s *BalanceRequestService) Cancel(ctx context.Context, in CancelRequestInput) (*CancelRequestResult, error) {
- if in.ID <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: Cancel 需要工单 id"))
- }
- req, err := s.requests.GetByID(ctx, in.ID)
- if err != nil {
- return nil, err
- }
- isOwner := strings.TrimSpace(in.OperatorID) != "" && in.OperatorID == req.RequestUserID
- isManager := in.SuperAdmin || in.Role == "super_admin" || in.Role == "finance"
- if !isOwner && !isManager {
- return nil, infraerrors.WithCause(infraerrors.ErrForbidden,
- errString("service: 撤单仅限申请人本人或 super_admin/finance"))
- }
- if err := s.pendingTransition(ctx, in.ID, requestCancelled, in.OperatorID); err != nil {
- return nil, err
- }
- return &CancelRequestResult{Approved: requestCancelled, ID: in.ID}, nil
- }
- // pendingTransition 终态推进的公共实现(驳回/撤销共用:**无资金动作**,只做 CAS)。
- //
- // 入参:ctx;id 工单 ID;to 目标终态(-1 驳回 / -2 取消);operatorID 操作人。
- // 出参:nil 成功;3002(工单已被处理/不存在)/* 仓储错误。
- //
- // 实现要点:**一次 CAS 搞定状态判断与推进**(`WHERE approved = 0`),不做"先读后写"——
- // 先读会让两个并发请求都读到 0 再各自写,虽然终态相同但 response_time/审批人会互相覆盖。
- func (s *BalanceRequestService) pendingTransition(ctx context.Context, id int64, to int8, operatorID string) error {
- return s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
- txCtx := repository.WithTxContext(ctx, tx)
- now := time.Now()
- var operator *string
- if strings.TrimSpace(operatorID) != "" {
- operator = &operatorID
- }
- rows, err := s.requests.UpdateApproved(txCtx, tx, id, to, operator, &now)
- if err != nil {
- return err
- }
- if rows == 0 {
- return infraerrors.WithCause(infraerrors.ErrConflict,
- errString(fmt.Sprintf("service: 工单 %d 不存在或已处理(仅 approved=0 可推进)", id)))
- }
- return nil
- })
- }
|