| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414 |
- // 活动报名服务(报名 / 取消报名)——AC-07 活动服务 A(主链路部分)。
- //
- // 依据:unified/03-migration/academic-domain-design.md
- // - §3.2 #19 `sp_courses_activity_signed_add`(dump **762908**)→ `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 762908)。
- //
- // 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
- // 763007 IF type∈{2,3} AND response_userid>0 → LEAVE(**3107** 已审核不可取消)
- // 763013 ELSEIF type=4 AND EXISTS(used_log WHERE activityid/userid) → LEAVE(**3108** 已消费不可取消)
- // 763028 UPDATE signed SET status=0, activity_money=0, activity_money_used=0(**三列同置**)
- // 763030 UPDATE activity SET number_sign=number_sign-1(Go 加**下限 0**保护)
- //
- // 守卫命中时**不减人数**(`LEAVE` 在两条 UPDATE 之前)——这是"拒绝必须零副作用"的判据:
- // 若把 -1 写进守卫之前,就会留下"取消被拒但人数少了"的脏数据。
- //
- // 入参:ctx / in。出参:*CancelSignResult;错误码 2001/2002/3001/3107/3108/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) {
- 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)
- }
|