// 活动报名服务(报名 / 取消报名)——AC-07 活动服务 A(主链路部分)。 // // 依据:unified/03-migration/academic-domain-design.md // - §3.2 #19 `sp_courses_activity_signed_add`(dump **762910**,CREATE 行;762908 是 DROP)→ `Sign` // - §3.2 #20 `sp_courses_activity_signed_cancel`(dump **762982**)→ `CancelSign` // - §8 AC-07 前置输入(10 原语)、§5.1 五条纪律(第 4 条横切优先 / 第 5 条幂等回放等价) // - §10.1 错误码 3105/3106/3107/3108/3141、§10.3 事务模板、§10.7 金额 // // 两条**反直觉**判据(dump 口径,**禁止按直觉互换,也禁止合并**): // // 3105 已报名 ← dump 762928:`COUNT(*) FROM signed WHERE user_id=? AND activity_id=? AND status != 0` // 判的是**报名行**状态 ⇒ 取消(status=0)后可**重新报名**同一活动; // 3106 老带新限一次 ← dump 762947:`signed JOIN activity ON type=4 WHERE user_id=? AND activity.status != 0` // 判的是**活动**状态、**不看报名行 status** ⇒ 参加过就**永久消耗次数**,取消也不退。 // // 于是必然出现"取消报名后 → 可重报(3105 放行)但不能再参加老带新(3106 拒)"的组合, // 这正是两条判据**不得合并**的直接证据(合并任一方都会让另一半失真)。 package service import ( "context" "errors" "strconv" "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" ) // 报名默认值(dump `IF inputStatus<1 THEN SET inputStatus=1`)。 const ( // defaultSignStatus 报名初始状态(dump 的 status<1 → 1)。 defaultSignStatus int32 = model.ActivitySignedSigned ) // ActivityService 活动报名服务(depends: 活动仓储 + 事务运行器 + 成员校验器)。 // // 字段: // - repo 活动/报名仓储(含 3105/3106 判据与人数增减原语); // - runner 事务运行器(顶层 BEGIN;事务内透传同一 tx); // - member 成员关系校验器(可 nil:系统任务/割接脚本模式); // - now 时钟(单测注入;dump 用 NOW())。 type ActivityService struct { repo *repository.ActivityRepo runner *repository.TxRunner member port.MembershipChecker now func() time.Time } // NewActivityService 构造活动报名服务。 // // 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域,多租户过滤的唯一来源)。 // 出参:*ActivityService。 func NewActivityService(db *gorm.DB, sc *appscope.Scope) *ActivityService { return &ActivityService{ repo: repository.NewActivityRepo(db, sc), runner: repository.NewTxRunner(db), now: time.Now, } } // WithMembershipChecker 注入成员校验器(返自身,链式装配)。 // // 入参:member port.MembershipChecker(可 nil)。 // 出参:*ActivityService(同一个实例)。 func (s *ActivityService) WithMembershipChecker(member port.MembershipChecker) *ActivityService { s.member = member return s } // ---------------------------------------------------------------- 入参 / 响应 DTO // SignInput 活动报名入参(dump `signed_add` **5 参**,签名逐字对齐)。 type SignInput struct { // UserID 报名用户(必填,ULID)。 UserID string // ActivityID 活动 ID(必填 >0)。 ActivityID int64 // Status 报名状态(<1 → 1,dump 762921 同口径)。 Status int32 // RecommendUserID 推荐人(ULID;空/"0" → NULL;老带新活动才有意义)。 RecommendUserID string // CreateUserID 操作人(→ create_user_id;空 → NULL)。 CreateUserID string } // SignResult 活动报名响应。 // // 字段语义: // - Status 实际写入的报名状态(dump 的 `inputStatus`,已按 `<1 → 1` 归一); // - ActivityMoney 快照进报名行的活动金额(= `max(activity.money, 0)`); // - NumberSign 本次报名后的活动报名人数(`number_sign+1` 之后的值)。 type SignResult struct { // SignedID 报名行 ID(DELETE+INSERT 后的新行)。 SignedID int64 `json:"signedId"` // ActivityID 活动 ID。 ActivityID int64 `json:"activityId"` // UserID 报名用户。 UserID string `json:"userId"` // Status 报名状态(归一后的值)。 Status int32 `json:"status"` // ActivityMoney 报名行快照的活动金额(分)。 ActivityMoney money.Amount `json:"activityMoney"` // NumberSign 报名后的活动报名人数。 NumberSign int32 `json:"numberSign"` // ActivityType 活动类型(1/2/3/4)。 ActivityType int8 `json:"activityType"` } // CancelSignInput 取消报名入参(dump `signed_cancel` **2 参**)。 type CancelSignInput struct { // UserID 报名用户(必填)。 UserID string // ActivityID 活动 ID(必填 >0)。 ActivityID int64 } // CancelSignResult 取消报名响应。 // // 字段语义:`Status` 恒为 0(取消);金额三列已同语句归零(见 `repository.CancelSigned`)。 type CancelSignResult struct { // ActivityID 活动 ID。 ActivityID int64 `json:"activityId"` // UserID 报名用户。 UserID string `json:"userId"` // Status 取消后的报名状态(恒 0)。 Status int32 `json:"status"` // NumberSign 取消后的活动报名人数(`number_sign-1`,**下限 0**)。 NumberSign int32 `json:"numberSign"` // ActivityType 活动类型(1/2/3/4)。 ActivityType int8 `json:"activityType"` } // ---------------------------------------------------------------- 报名(SP19) // Sign 用户报名活动(SP19 `sp_courses_activity_signed_add`,dump 762910)。 // // dump 语义(逐行对齐,行号见括号): // // 762921 IF inputStatus<1 THEN inputStatus=1 // 762928 IF COUNT(signed WHERE user=? AND activity=? AND status!=0) > 0 → 返回 success=0(**3105**) // 762939 游标取 activity 的 (type, money)(**无行 → success=0**,见下"偏差") // 762947 IF type=4 AND COUNT(signed JOIN activity ON type=4 WHERE user=?) > 0 → success=0(**3106**) // 762955 IF activityMoney<0 THEN activityMoney=0(→ `max(money, 0)`) // 762965 DELETE signed WHERE user/activity(清旧行,含 status=0)+ INSERT + number_sign+1 // // Go 的三处差异(均已在本方法注释与交付报告中登记): // 1. **3141 人数上限是 Go 增强**:dump 完全没有超卖保护(`number_sign` 只加不查), // 事务内用 `GetByIDForUpdate` + 比 `number_max` 判定(`number_max=0` = 不限);只影响 // "人数已满"这一新分支,dump 已有分支输出不变。 // 2. **活动不存在 → 3001**:dump 游标取不到行时 `success=0` 静默返回(等价于"没报名"), // Go 显式 3001 —— 目标态 signed 对 activity 有外键,静默成功会让前端误判。 // 3. **3106 判据保留 `institution_id` 过滤**(多租户红线,Design §3.2 #19 唯一保留的 Go 偏差): // 只放宽 `signed.status` 与 `campus_id` 维度,机构过滤不删。 // // 幂等语义:DELETE+INSERT 保证 `(user, activity)` 只留一行(UK 兜底);**重复报名不会产生第二行**, // 但按 dump 口径第二次会被 3105 拒(`status!=0` 命中原行)——故"重复报名"的可观测行为是 3105, // 不是 UPSERT 覆盖。 // // 入参:ctx / in。出参:*SignResult;错误码 2001/2002/3001/3105/3106/3141/5002。 func (s *ActivityService) Sign(ctx context.Context, in SignInput) (*SignResult, error) { if strings.TrimSpace(in.UserID) == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: Sign 需要 user_id")) } if in.ActivityID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("service: Sign 需要 activity_id>0")) } if s == nil || s.repo == nil || s.runner == nil { return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityService 未装配")) } status := in.Status if status < 1 { status = defaultSignStatus // dump 762921 } userID := strings.TrimSpace(in.UserID) var out SignResult err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error { txCtx := repository.WithTxContext(ctx, tx) if e := s.assertInScope(txCtx, userID); e != nil { return e } // ① 3105 已报名(判**报名行** status ⇒ 取消后可重报) exists, e := s.repo.ExistsSigned(txCtx, userID, in.ActivityID) if e != nil { return e } if exists { return infraerrors.WithCause(apperr.ErrActivityAlreadySigned, errString("service: Sign 已报名该活动(3105,dump 762928)")) } // ② 取活动 + 行锁(3141 判定需要与 number_sign+1 串行) act, e := s.repo.GetByIDForUpdate(txCtx, tx, in.ActivityID) if e != nil { if errors.Is(e, infraerrors.ErrNotFound) { return infraerrors.WithCause(infraerrors.ErrNotFound, errString("service: Sign 活动不存在或不在作用域内(dump:游标无行 → success=0)")) } return e } // ③ 3106 老带新限一次(判**活动** status、**不判**报名行 status) if act.Type == model.ActivityTypeRecommend { count, ce := s.repo.CountSignedByUser(txCtx, userID, true) if ce != nil { return ce } if count > 0 { return infraerrors.WithCause(apperr.ErrRecommendLimited, errString("service: Sign 老带新每人限参加一次(3106,dump 762947;取消报名也不退次数)")) } } // ④ 3141 人数上限(**Go 增强**:dump 无此校验) if act.NumberMax > 0 && act.NumberSign >= act.NumberMax { return infraerrors.WithCause(apperr.ErrActivityFull, errString("service: Sign 报名人数已满(3141;dump 无此校验,属 Go 有意增强)")) } // ⑤ activity_money = max(activity.money, 0)(dump 762955) actMoney, e := parseAccountAmount(act.Money, "t_courses_activity.money") if e != nil { return e } if actMoney < 0 { actMoney = 0 } // ⑥ DELETE + INSERT(幂等重置全字段) row := &model.CoursesActivitySigned{ ActivityID: in.ActivityID, UserID: userID, InstitutionID: act.InstitutionID, CampusID: act.CampusID, // 快照活动 campus_id(0 有"全自营校区"语义,不用请求作用域值覆盖) Status: status, ActivityMoney: actMoney.String(), ActivityMoneyUsed: "0.00", RecommendUserID: optString(in.RecommendUserID), CreateUserID: optString(in.CreateUserID), } if e := s.repo.UpsertSigned(txCtx, tx, row); e != nil { return e } // ⑦ 报名人数 +1(原子,与插入同事务) if _, e := s.repo.IncreaseNumberSign(txCtx, tx, in.ActivityID, 1); e != nil { return e } out = SignResult{ SignedID: row.ID, ActivityID: in.ActivityID, UserID: userID, Status: status, ActivityMoney: actMoney, NumberSign: act.NumberSign + 1, ActivityType: act.Type, } return nil }) if err != nil { return nil, err } return &out, nil } // ---------------------------------------------------------------- 取消报名(SP20) // CancelSign 用户取消报名(SP20 `sp_courses_activity_signed_cancel`,dump 762982)。 // // dump 语义(逐行对齐): // // 762994 游标:signed JOIN activity WHERE user/activity AND signed.status!=0(dump 762997-762998) // 763013 IF type∈{2,3} AND response_userid>0 → LEAVE(**3107** 已审核不可取消,dump 763013/763015) // 763018 ELSEIF type=4 AND EXISTS(used_log WHERE activityid/userid) → LEAVE(**3108** 已消费不可取消, // dump 763018-763023) // 763028 UPDATE signed SET status=0, activity_money=0, activity_money_used=0(**三列同置**) // 763030 UPDATE activity SET number_sign=number_sign-1(Go 加**下限 0**保护,§3.8-R3) // // 守卫命中时**不减人数**(`LEAVE` 在两条 UPDATE 之前)——这是"拒绝必须零副作用"的判据: // 若把 -1 写进守卫之前,就会留下"取消被拒但人数少了"的脏数据。 // // ⚠ 状态机**取不到 type** → **3121**(§3.8-R8 / §10.1 v0.1.23 扩展;**不得"读不到就放行"**): // 游标是 `signed JOIN activity`,若报名行在、活动行没了(或作用域外),`type` 读不到 ⇒ // 3107/3108 两条审核态保护**无法判定**。dump 在这种情况会按"非 2/3/4"继续往下走并 `UPDATE`, // 等于**跳过审核态保护**(已审核通过/已产生消费的报名被静默取消),故 Go 显式 3121。 // 与 3001 的分界:**完全没有报名行** → 3001(§3.8-R7);**有报名行但 type 读不到** → 3121。 // // 入参:ctx / in。出参:*CancelSignResult;错误码 2001/2002/3001/3107/3108/3121/5002。 func (s *ActivityService) CancelSign(ctx context.Context, in CancelSignInput) (*CancelSignResult, error) { if strings.TrimSpace(in.UserID) == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: CancelSign 需要 user_id")) } if in.ActivityID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("service: CancelSign 需要 activity_id>0")) } if s == nil || s.repo == nil || s.runner == nil { return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityService 未装配")) } userID := strings.TrimSpace(in.UserID) var out CancelSignResult err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error { txCtx := repository.WithTxContext(ctx, tx) if e := s.assertInScope(txCtx, userID); e != nil { return e } // ① 游标等价物:该用户在该活动上的**有效**报名行(status!=0) signed, e := s.repo.GetSigned(txCtx, userID, in.ActivityID) if e != nil { if errors.Is(e, infraerrors.ErrNotFound) { // **3121 与 3001 的分界**(§3.8-R8,见方法头):先判"报名行到底有没有"。 // 有报名行却 JOIN 不到活动 ⇒ type 读不到 ⇒ 状态机无法判定 ⇒ 3121, // **不得**按"非 2/3/4"放行(那会跳过 3107/3108 的审核态保护)。 hasSigned, he := s.repo.ExistsAnySigned(txCtx, userID, in.ActivityID) if he != nil { return he } if hasSigned { return infraerrors.WithCause(apperr.ErrRecordStateInvalid, errString("service: CancelSign 取不到活动类型,报名状态机无法判定(3121,§3.8-R8)")) } return infraerrors.WithCause(infraerrors.ErrNotFound, errString("service: CancelSign 报名记录不存在或已取消(dump:游标无行 → success=0)")) } return e } // ② 3107:充值赠送 / 代金券活动审核通过后不可取消 if signed.ActivityType == model.ActivityTypeRecharge || signed.ActivityType == model.ActivityTypeVoucher { if hasUserRef(signed.ResponseUserID) { return infraerrors.WithCause(apperr.ErrActivitySignedAudited, errString("service: CancelSign 活动已审核通过不可取消(3107,dump 763007)")) } } else if signed.ActivityType == model.ActivityTypeRecommend { // ③ 3108:老带新已有消费/结算记录 → 不可取消(**判存在性**,不看金额) used, ue := s.discountRepo().ExistsUsedLog(txCtx, in.ActivityID, userID) if ue != nil { return ue } if used { return infraerrors.WithCause(apperr.ErrRecommendUsed, errString("service: CancelSign 老带新已产生消费不可取消(3108,dump 763013)")) } } // ④ 三列同置 0(dump 763028:一条 UPDATE) affected, e := s.repo.CancelSigned(txCtx, tx, userID, in.ActivityID) if e != nil { return e } if affected == 0 { return infraerrors.WithCause(infraerrors.ErrNotFound, errString("service: CancelSign 报名行不可写(并发删除/作用域变化)")) } // ⑤ 报名人数 -1(dump 763030;Go 加下限 0,防并发把人数减成负数) if _, e := s.repo.IncreaseNumberSign(txCtx, tx, in.ActivityID, -1); e != nil { return e } // ⑥ 回读人数(响应体必须是**当刻库值**,不复用中间变量) after, e := s.repo.GetByID(txCtx, in.ActivityID) if e != nil { return e } out = CancelSignResult{ ActivityID: in.ActivityID, UserID: userID, Status: model.ActivitySignedCancelled, NumberSign: after.NumberSign, ActivityType: signed.ActivityType, } return nil }) if err != nil { return nil, err } return &out, nil } // ---------------------------------------------------------------- 内部助手 // discountRepo 按需构造赠送池仓储(复用同一 DB/作用域;取消报名的 3108 判据需要它)。 // // 为什么不在结构体里固定持有:AC-07 的取消路径只有 type=4 一支需要它, // 按需构造可让 `ActivityService` 的装配依赖保持最小(同一 DB 句柄,无额外连接)。 func (s *ActivityService) discountRepo() *repository.ActivityDiscountRepo { return repository.NewActivityDiscountRepo(s.repo.DB, s.repo.Sc) } // assertInScope 对象级越权断言(语义同 FundService;未注入 checker 的系统任务模式直接放行)。 func (s *ActivityService) assertInScope(ctx context.Context, userID string) error { if s == nil || s.member == nil { return nil } return s.member.AssertInScope(ctx, userID, 0, 0) } // hasUserRef 判断目标态的用户引用列是否"有值"(旧 int 型的 `>0` 在 ULID 列上的等价判据)。 // // 口径:NULL / 空串 / "0" 都视为无值(与 §3.8-A17 的 `NOT IN ('', '0')` 同源)—— // 迁移期可能把旧的 `0` 物化成字符串,只用 `IS NOT NULL` 会把它当"已审核"。 func hasUserRef(ref *string) bool { if ref == nil { return false } v := strings.TrimSpace(*ref) return v != "" && v != "0" } // orderNoOf 把报课记录 ID 转成流水 `order_no`/`log_id` 的字符串口径(dump 的 VARCHAR(50) 入参)。 // // 入参:id 数值主键(<=0 → 空串,调用方按"无单号"处理)。 // 出参:十进制字符串(id<=0 → "")。 func orderNoOf(id int64) string { if id <= 0 { return "" } return strconv.FormatInt(id, 10) }