| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564 |
- // 老带新双边结算服务(+ 取消充值活动 / 欠费核销的转调出口)——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, ')<br/>', 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,')<br/>',remark)`)。
- const (
- // recommendRemarkPrefix 备注前缀。
- recommendRemarkPrefix = "参与老带新活动("
- // recommendRemarkJoin 标题与用户备注之间的分隔(dump 的 `<br/>` 原样保留,前端按 HTML 渲染)。
- recommendRemarkJoin = ")<br/>"
- )
- // 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 用户备注(会拼到 `参与老带新活动(<标题>)<br/>` 之后)。
- 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
- }
|