// 老带新双边结算服务(+ 取消充值活动 / 欠费核销的转调出口)——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 }