// 老带新双边结算服务(+ 取消充值活动 / 欠费核销的转调出口)——AC-08 活动服务 B。
//
// 依据:unified/03-migration/academic-domain-design.md
// - §3.2 #18 `sp_courses_activity_use_activity_recommend`(dump **763307~763366**)→ `SettleRecommend`
// - §3.1 #8 `sp_user_expense_cancel_recharge_activity`(AC-05 已交付 RefundService.CancelRechargeActivity)
// → **本服务只转调、不重复实现**(同一 tx 透传,§10.3)
// - §3.5 #27 `sp_courses_student_counteract_arrears_course`(AC-11 已交付 MaintenanceService.CounteractArrears)
// → 同上,只转调(依赖图 AC-11 -.-> AC-08 虚线边)
// - §5.1 纪律 4「横切优先」/ 纪律 5「幂等回放等价」
//
// dump 原文(763329 游标 → 763366 收尾,逐行对齐):
//
// 游标:signed JOIN activity ON type=4 WHERE t1.userid=? -- **不过滤报名 status**
// 763340 IF recommend_userid<=0 OR activity_money<=0 → LEAVE(无推荐人/无金额 → 整体 no-op)
// 763346 IF EXISTS(used_log WHERE userid=? AND activityid=?) → LEAVE(**幂等**:已结算过)
// 763348 schoolid/campusid 为 0 → 从 `t_courses_student WHERE studentid=? AND id=inputOrder` 回查
// 763355 仍为 0 → LEAVE
// 763358 remark = CONCAT('参与老带新活动(', title, ')
', inputRemark)
// 763361 **被推荐人**:UPSERT 双账户 → balance+=m, total_recommended_amount+=m → INSERT 107 —— 再欠费核销
// 763362 **推荐人**:同上(双边各入一次,**两条 107**)
// 763363 INSERT used_log(activityid, userid, recommend_userid, orderid, activity_money_used=**-m**)
// 763365 UPDATE signed SET activity_money_used=m, status=2
//
// 「双边 + 各一条 107」是这条 SP 最容易写错的地方:写成"只给被推荐人入账"或"两个人共用一条 107"
// 在总额上差一倍,而只断言"有 107 流水"的用例依然全绿 —— 所以单测断言的是
// **两个 user 各自的 balance/total_recommended_amount/107 条数与金额**,且 `balance_after`
// 各取**本 user 自己的全局快照**(不要求两边相等,多校区/多历史下本来也不等)。
package service
import (
"context"
"errors"
"strconv"
"strings"
"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"
)
// recommendRemarkPrefix 老带新备注前缀(dump 763358 `CONCAT('参与老带新活动(',title,')
',remark)`)。
const (
// recommendRemarkPrefix 备注前缀。
recommendRemarkPrefix = "参与老带新活动("
// recommendRemarkJoin 标题与用户备注之间的分隔(dump 的 `
` 原样保留,前端按 HTML 渲染)。
recommendRemarkJoin = ")
"
)
// RecommendService 老带新结算服务(+ 取消充值活动 / 欠费核销的转调出口)。
//
// 字段:
// - act 活动/报名仓储(取 type=4 报名、写结算态);
// - disc 赠送池仓储(used_log 幂等判据与写入);
// - expense 余额账户仓储(双边入账、流水);
// - courses 报课仓储(活动 schoolid/campusid 为 0 时回查);
// - runner 事务运行器(整个结算 + 两侧欠费核销在同一事务);
// - refund 退费服务(取消充值活动的**唯一实现**,本服务只转调);
// - maint 运维服务(欠费核销的**唯一实现**,本服务只转调);
// - member 成员校验器(可 nil)。
type RecommendService struct {
act *repository.ActivityRepo
disc *repository.ActivityDiscountRepo
expense *repository.ExpenseRepo
courses *repository.CourseRepo
runner *repository.TxRunner
refund *RefundService
maint *MaintenanceService
member port.MembershipChecker
campus port.CampusReader
sc *appscope.Scope
db *gorm.DB
}
// NewRecommendService 构造老带新结算服务。
//
// 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域)。
// 出参:*RecommendService(取消充值活动需要校区端口,用 `WithCampusReader` 注入后可用)。
func NewRecommendService(db *gorm.DB, sc *appscope.Scope) *RecommendService {
return &RecommendService{
act: repository.NewActivityRepo(db, sc),
disc: repository.NewActivityDiscountRepo(db, sc),
expense: repository.NewExpenseRepo(db, sc),
courses: repository.NewCourseRepo(db, sc),
runner: repository.NewTxRunner(db),
refund: NewRefundService(db, sc, nil, nil),
maint: NewMaintenanceService(db, sc),
sc: sc,
db: db,
}
}
// WithCampusReader 注入校区只读端口(取消充值活动需要它解析校区→机构,返自身便于链式装配)。
//
// 入参:reader port.CampusReader(nil 时取消充值活动会返回 5002「未装配」)。
// 出参:*RecommendService(同一个实例)。
func (s *RecommendService) WithCampusReader(reader port.CampusReader) *RecommendService {
s.campus = reader
s.refund = NewRefundService(s.db, s.sc, reader, s.member)
return s
}
// WithMembershipChecker 注入成员校验器(返自身,链式装配)。
//
// 入参:member port.MembershipChecker(可 nil)。
// 出参:*RecommendService(同一个实例)。
func (s *RecommendService) WithMembershipChecker(member port.MembershipChecker) *RecommendService {
s.member = member
s.refund = NewRefundService(s.db, s.sc, s.campus, member)
return s
}
// ---------------------------------------------------------------- 入参 / 响应 DTO
// SettleRecommendInput 老带新结算入参(dump 4 参:userid / order / remark / createUserid)。
type SettleRecommendInput struct {
// UserID 被推荐人(**新生**;必填 ULID)。
UserID string
// OrderNo 报课记录 ID 的字符串形式(dump `inputOrder`;活动 schoolid/campusid 为 0 时用它回查)。
OrderNo string
// Remark 用户备注(会拼到 `参与老带新活动(<标题>)
` 之后)。
Remark string
// CreateUserID 操作人(→ 107 流水的 create_user_id)。
CreateUserID string
}
// SettleRecommendResult 老带新结算响应。
//
// 语义要点:
// - `Settled=false` 表示 dump 的 `LEAVE` 分支(无推荐人 / 活动金额<=0 / 回查不到校区),
// 此时**零写入**,其余字段回显候选值(dump 末尾 `SELECT recommendUserid,schoolid,campusid,success`);
// - `Repeated=true` 表示幂等命中(已结算过),**所有金额字段都是当刻库值**(从库回读,
// §5.1 纪律 5:不得复用首执行的中间变量)。
type SettleRecommendResult struct {
// Settled 本次是否真正结算(写入了双边账户/流水/used_log)。
Settled bool `json:"settled"`
// Repeated 幂等命中(已结算过)。
Repeated bool `json:"repeated"`
// ActivityID 活动 ID。
ActivityID int64 `json:"activityId"`
// Amount 活动金额(分)= 每边入账额。
Amount money.Amount `json:"amount"`
// RecommendUserID 推荐人。
RecommendUserID string `json:"recommendUserId"`
// InstitutionID 入账机构(活动上的,或从报课记录回查的)。
InstitutionID int64 `json:"institutionId"`
// CampusID 入账校区。
CampusID int64 `json:"campusId"`
// UserLogID 被推荐人的 107 流水 ID(0 = 未产生)。
UserLogID int64 `json:"userLogId"`
// UserBalance 被推荐人**当前**全局余额(分)。
UserBalance money.Amount `json:"userBalance"`
// UserCampusBalance 被推荐人**当前**校区余额(分)。
UserCampusBalance money.Amount `json:"userCampusBalance"`
// UserArrearsApplied 被推荐人本次核销欠费额(分;幂等回放为 0,不可重建)。
UserArrearsApplied money.Amount `json:"userArrearsApplied"`
// RecommendUserLogID 推荐人的 107 流水 ID。
RecommendUserLogID int64 `json:"recommendUserLogId"`
// RecommendUserBalance 推荐人**当前**全局余额(分)。
RecommendUserBalance money.Amount `json:"recommendUserBalance"`
// RecommendUserCampusBalance 推荐人**当前**校区余额(分)。
RecommendUserCampusBalance money.Amount `json:"recommendUserCampusBalance"`
// RecommendUserArrearsApplied 推荐人本次核销欠费额(分)。
RecommendUserArrearsApplied money.Amount `json:"recommendUserArrearsApplied"`
// UsedLogID used_log 记录 ID(幂等锚点)。
UsedLogID int64 `json:"usedLogId"`
}
// ---------------------------------------------------------------- 老带新结算(SP18)
// SettleRecommend 老带新双边结算(SP18 `sp_courses_activity_use_activity_recommend`,dump 763307)。
//
// 单事务内 6 步(顺序不可调整,见文件头 dump 原文):候选 → 幂等 → 解析校区 →
// 被推荐人入账+核销 → 推荐人入账+核销 → used_log + signed 结算态。
//
// 幂等:`used_log` 存在即视为已结算(dump 763346);命中分支**从库回读**账户余额与流水 ID。
//
// 入参:ctx / in。出参:*SettleRecommendResult;错误码 2001/5002(参数与 DB 异常)、
// 3104(报课记录回查校区失败时按 no-op 处理,不报错——与 dump LEAVE 一致);
// 3113(欠费核销冲突,来自 MaintenanceService)。
func (s *RecommendService) SettleRecommend(
ctx context.Context, in SettleRecommendInput,
) (*SettleRecommendResult, error) {
if strings.TrimSpace(in.UserID) == "" {
return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: SettleRecommend 需要 user_id"))
}
if s == nil || s.act == nil || s.disc == nil || s.expense == nil || s.runner == nil {
return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: RecommendService 未装配"))
}
userID := strings.TrimSpace(in.UserID)
orderNo := strings.TrimSpace(in.OrderNo)
var out SettleRecommendResult
err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
txCtx := repository.WithTxContext(ctx, tx)
// ① 候选:该 user 的 type=4 报名(**不过滤报名 status**,dump 763329)
rows, e := s.act.ListRecommendSignedByUser(txCtx, userID)
if e != nil {
return e
}
if len(rows) == 0 {
return nil // dump:游标无行 → success=0(零写入)
}
cand := rows[0]
out.ActivityID = cand.ActivityID
out.InstitutionID = cand.InstitutionID
out.CampusID = cand.CampusID
// ② 无推荐人 / 无活动金额 → LEAVE(dump 763340)
recommender := ""
if hasUserRef(cand.RecommendUserID) {
recommender = strings.TrimSpace(*cand.RecommendUserID)
}
out.RecommendUserID = recommender
amount, e := parseAccountAmount(cand.ActivityMoney, "t_courses_activity_signed.activity_money")
if e != nil {
return e
}
out.Amount = amount
if recommender == "" || !amount.GreaterThan(0) {
return nil
}
// ③ 幂等:used_log 存在 → 回读当刻库值(dump 763346)
used, e := s.disc.GetUsedLog(txCtx, cand.ActivityID, userID)
if e != nil {
return e
}
if used != nil {
return s.replaySettle(txCtx, &out, cand, userID, recommender, used)
}
// ④ 机构/校区:活动上为 0 → 从报课记录回查(dump 763348)
institutionID, campusID := cand.InstitutionID, cand.CampusID
if institutionID <= 0 || campusID <= 0 {
scope, se := s.studentCourseScope(txCtx, orderNo, userID)
if se != nil {
return se
}
if scope == nil {
return nil // 回查不到报课记录 → dump 的 IF 判据保持 0 → LEAVE
}
if institutionID <= 0 {
institutionID = scope.InstitutionID
}
if campusID <= 0 {
campusID = scope.CampusID
}
}
out.InstitutionID, out.CampusID = institutionID, campusID
if institutionID <= 0 || campusID <= 0 {
return nil // dump 763355:仍缺机构/校区 → LEAVE(零写入)
}
// ⑤ 备注(dump 763358)
title := ""
if cand.ActivityTitle != nil {
title = strings.TrimSpace(*cand.ActivityTitle)
}
remark := recommendRemarkPrefix + title + recommendRemarkJoin + strings.TrimSpace(in.Remark)
// ⑥ 双边入账 + 各自欠费核销(**被推荐人先、推荐人后**,dump 763361/763362)
userLogID, userArrears, e := s.settleOneSide(txCtx, tx, userID, institutionID, campusID, amount, remark, orderNo, in.CreateUserID)
if e != nil {
return e
}
recLogID, recArrears, e := s.settleOneSide(txCtx, tx, recommender, institutionID, campusID, amount, remark, orderNo, in.CreateUserID)
if e != nil {
return e
}
// ⑦ used_log:activity_money_used = **-m**(dump 763363)
recRef := recommender
usedLog := &model.CoursesActivityUsedLog{
ActivityID: cand.ActivityID,
UserID: userID,
RecommendUserID: &recRef,
OrderNo: optString(orderNo),
ActivityMoneyUsed: amount.Neg().String(),
Memo: optString(remark),
}
if e := s.disc.InsertUsedLog(txCtx, tx, usedLog); e != nil {
return e
}
// ⑧ 报名行结算态:activity_money_used=m, status=2(dump 763365)
affected, e := s.act.SetSignedSettled(txCtx, tx, cand.SignedID, amount,
model.ActivitySignedAttended)
if e != nil {
return e
}
if affected == 0 {
return infraerrors.WithCause(infraerrors.ErrNotFound,
errString("service: SettleRecommend 报名行不可写(并发删除/作用域变化)"))
}
out.Settled = true
out.UsedLogID = usedLog.ID
out.UserLogID = userLogID
out.RecommendUserLogID = recLogID
out.UserArrearsApplied = userArrears
out.RecommendUserArrearsApplied = recArrears
// ⑨ 余额快照**从库回读**(响应体不用中间变量,§5.1 纪律 5)
if e := s.fillBalances(txCtx, &out, userID, recommender, institutionID, campusID); e != nil {
return e
}
return nil
})
if err != nil {
return nil, err
}
return &out, nil
}
// settleOneSide 单边入账:UPSERT 双账户 → 双列同增 → 107 流水 → 欠费核销(dump 763361/763362 逐个照搬)。
//
// 入参:txCtx(含 tx)/ tx / userID / 机构 / 校区 / amount / remark / orderNo / operatorID。
// 出参:(107 流水 ID, 核销额, error)。
//
// 红线:
// - `balance` 与 `total_recommended_amount` **同一语句双增**(AddColumnsGlobalThenCampus 多列原子);
// - 107 的 `logid = orderNo`、`request_id = NULL`(dump 无幂等键;回放靠 used_log 判据);
// - `balance_after` = **本 user 自己的**全局余额快照(两个 user 的值本来就不等)。
func (s *RecommendService) settleOneSide(
txCtx context.Context, tx *gorm.DB, userID string, institutionID, campusID int64,
amount money.Amount, remark, orderNo, operatorID string,
) (int64, money.Amount, error) {
// ① 建户(dump 的 COUNT 判存在 → UPSERT)
if e := s.expense.UpsertGlobal(txCtx, tx, &model.UserExpense{UserID: userID}); e != nil {
return 0, 0, e
}
if e := s.expense.UpsertCampus(txCtx, tx, &model.UserExpenseSchool{
UserID: userID, InstitutionID: institutionID, CampusID: campusID,
}); e != nil {
return 0, 0, e
}
// ② 双表双列同增:balance +m、total_recommended_amount +m
if _, e := s.expense.AddColumnsGlobalThenCampus(txCtx, tx, "SettleRecommend", userID,
institutionID, campusID,
repository.ColumnDelta{Column: repository.ExpenseColumnBalance, Delta: amount},
repository.ColumnDelta{Column: repository.ExpenseColumnTotalRecommendedAmount, Delta: amount},
); e != nil {
return 0, 0, e
}
// ③ 107 流水(log_id = 报课单号;balance_after = 本 user 全局快照)
snapshot, e := globalBalanceAfter(txCtx, s.expense, tx, userID)
if e != nil {
return 0, 0, e
}
lg := newExpenseLog(userID, model.ExpenseLogTypeRecommend, amount, "", remark, operatorID, &snapshot)
lg.LogID = optString(orderNo)
if e := s.insertLog(txCtx, tx, lg); e != nil {
return 0, 0, e
}
// ④ 欠费核销(同 tx 转调 AC-11 的实现,**不重复实现**)
arrears, e := s.counteractArrears(txCtx, userID, institutionID, campusID, amount)
if e != nil {
return 0, 0, e
}
return lg.ID, arrears, nil
}
// counteractArrears 同 tx 转调 `MaintenanceService.CounteractArrears`(SP27)。
//
// 入参:ctx(**须已注入 tx**,下游 runner 会自动透传)/ userID / 机构 / 校区 / 金额。
// 出参:(实际核销额, error)。
func (s *RecommendService) counteractArrears(
ctx context.Context, userID string, institutionID, campusID int64, amount money.Amount,
) (money.Amount, error) {
res, err := s.maint.CounteractArrears(ctx, CounteractArrearsInput{
StudentID: userID,
InstitutionID: institutionID,
CampusID: campusID,
Money: amount,
})
if err != nil {
return 0, err
}
return res.Applied, nil
}
// replaySettle 幂等回放(已结算过):**全部金额字段从库回读**,不复用首执行的中间变量。
//
// 入参:txCtx / out(待填充)/ cand 候选行 / userID / recommender / used 已存在的 used_log 行。
// 出参:nil / 错误。
//
// 能重建的:活动/推荐人/金额(used_log 的 `-m`)、两侧 107 流水 ID(按 `log_id + type`)、
// 两侧当前余额(账户表)。**不可重建**:首次欠费核销额(无独立流水)→ 置 0 并在注释说明。
func (s *RecommendService) replaySettle(
txCtx context.Context, out *SettleRecommendResult, cand repository.RecommendSignedRow,
userID, recommender string, used *model.CoursesActivityUsedLog,
) error {
amount, err := parseAccountAmount(used.ActivityMoneyUsed, "t_courses_activity_used_log.activity_money_used")
if err != nil {
return err
}
out.Amount = absAmount(amount) // used_log 结算行恒为负值
out.Repeated = true
out.Settled = true
out.UsedLogID = used.ID
if out.InstitutionID <= 0 || out.CampusID <= 0 {
out.InstitutionID, out.CampusID = cand.InstitutionID, cand.CampusID
}
// 107 流水:按 (user, log_id, type) 回读(dump 用 orderNo 作 logid)
orderNo := ""
if used.OrderNo != nil {
orderNo = strings.TrimSpace(*used.OrderNo)
}
if orderNo != "" {
if lg, e := s.expense.FindLogByLogID(txCtx, nil, userID, orderNo, model.ExpenseLogTypeRecommend); e == nil && lg != nil {
out.UserLogID = lg.ID
}
if lg, e := s.expense.FindLogByLogID(txCtx, nil, recommender, orderNo, model.ExpenseLogTypeRecommend); e == nil && lg != nil {
out.RecommendUserLogID = lg.ID
}
}
return s.fillBalances(txCtx, out, userID, recommender, out.InstitutionID, out.CampusID)
}
// fillBalances 从库回读两侧当前余额(全局 + 校区),缺失则保持 0(不伪造)。
func (s *RecommendService) fillBalances(
ctx context.Context, out *SettleRecommendResult, userID, recommender string, institutionID, campusID int64,
) error {
if bal, e := globalBalanceAfter(ctx, s.expense, nil, userID); e == nil {
out.UserBalance = bal
} else if !errors.Is(e, infraerrors.ErrNotFound) {
return e
}
if recommender != "" {
if bal, e := globalBalanceAfter(ctx, s.expense, nil, recommender); e == nil {
out.RecommendUserBalance = bal
} else if !errors.Is(e, infraerrors.ErrNotFound) {
return e
}
}
if institutionID > 0 && campusID > 0 {
if bal, e := campusBalanceAfter(ctx, s.expense, nil, userID, institutionID, campusID); e == nil {
out.UserCampusBalance = bal
} else if !errors.Is(e, infraerrors.ErrNotFound) {
return e
}
if recommender != "" {
if bal, e := campusBalanceAfter(ctx, s.expense, nil, recommender, institutionID, campusID); e == nil {
out.RecommendUserCampusBalance = bal
} else if !errors.Is(e, infraerrors.ErrNotFound) {
return e
}
}
}
return nil
}
// studentCourseScope 用报课单号回查机构/校区(dump 763348-763351)。
//
// 入参:ctx / orderNo(**十进制 id 字符串**;非数字 → nil, nil 视为"回查不到")/ studentID。
// 出参:(*repository.StudentCourseScope, nil) 命中;(nil, nil) 回查不到;error 仅 DB 异常。
func (s *RecommendService) studentCourseScope(
ctx context.Context, orderNo, studentID string,
) (*repository.StudentCourseScope, error) {
if s.courses == nil {
return nil, nil
}
id, err := strconv.ParseInt(strings.TrimSpace(orderNo), 10, 64)
if err != nil || id <= 0 {
return nil, nil // dump:`id=inputOrder` 非数值时子查询无行 → 变量保持 0 → LEAVE
}
scope, err := s.courses.GetStudentCourseScope(ctx, id, studentID)
if err != nil {
if errors.Is(err, infraerrors.ErrNotFound) {
return nil, nil
}
return nil, err
}
return scope, nil
}
// insertLog 写余额流水(撞 `uk_request_type` → 内部幂等哨兵,语义同 FundService.insertLog)。
//
// 说明:107 的 `request_id` 为 NULL(dump 无幂等键),正常不会撞唯一键;
// 保留该分支只为与其它资金方法保持同一错误映射。
func (s *RecommendService) insertLog(txCtx context.Context, tx *gorm.DB, lg *model.UserExpenseLog) error {
err := s.expense.InsertLog(txCtx, tx, lg)
if err == nil {
return nil
}
if errors.Is(err, repository.ErrDuplicateLog) {
return apperr.ErrRepeated
}
return err
}
// ---------------------------------------------------------------- 取消充值活动(SP8 转调)
// CancelRechargeActivity 取消充值活动——**同 tx 转调** `RefundService.CancelRechargeActivity`。
//
// 为什么不在本服务重新实现:AC-05 已交付同一资金动作 + 同一句活动状态回写(`signed.status=0`)+
// 幂等键 `(request_id, type=209)`;写两份必然漂移(Design §3.1 #8 归属说明)。
//
// 事务:本方法先开(或透传)一个事务并把 tx 注入 ctx,下游 `RefundService.runner` 命中透传分支
// (§10.3 ②「ctx 携带 tx」),因此**两侧写在同一事务里**,任一步失败整体回滚。
//
// 入参:ctx / in(与 RefundService 同一入参结构)。出参:*CancelRechargeActivityResult(含 Repeated)。
func (s *RecommendService) CancelRechargeActivity(
ctx context.Context, in CancelRechargeActivityInput,
) (*CancelRechargeActivityResult, error) {
if s == nil || s.refund == nil || s.runner == nil {
return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: RecommendService 未装配"))
}
var out *CancelRechargeActivityResult
err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
txCtx := repository.WithTxContext(ctx, tx)
res, e := s.refund.CancelRechargeActivity(txCtx, in)
if e != nil {
return e
}
out = res
return nil
})
if err != nil {
return nil, err
}
return out, nil
}
// ---------------------------------------------------------------- 欠费核销(SP27 转调)
// CounteractArrears 欠费核销——**同 tx 转调** `MaintenanceService.CounteractArrears`。
//
// 为什么不另建 `arrears_service.go`:Design §8 AC-08 明确「以同一 tx 调用 AC-11 已交付的
// `MaintenanceService.CounteractArrears`」,依赖图 AC-11 -.-> AC-08;本方法只是端点出口 + 事务边界。
//
// 入参:ctx / in(与 MaintenanceService 同一入参结构)。
// 出参:*CounteractArrearsResult(Applied/Remaining/Items);3113(核销冲突)。
func (s *RecommendService) CounteractArrears(
ctx context.Context, in CounteractArrearsInput,
) (*CounteractArrearsResult, error) {
if s == nil || s.maint == nil || s.runner == nil {
return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: RecommendService 未装配"))
}
var out *CounteractArrearsResult
err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
txCtx := repository.WithTxContext(ctx, tx)
res, e := s.maint.CounteractArrears(txCtx, in)
if e != nil {
return e
}
out = res
return nil
})
if err != nil {
return nil, err
}
return out, nil
}