activity_service.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. // 活动报名服务(报名 / 取消报名)——AC-07 活动服务 A(主链路部分)。
  2. //
  3. // 依据:unified/03-migration/academic-domain-design.md
  4. // - §3.2 #19 `sp_courses_activity_signed_add`(dump **762908**)→ `Sign`
  5. // - §3.2 #20 `sp_courses_activity_signed_cancel`(dump **762982**)→ `CancelSign`
  6. // - §8 AC-07 前置输入(10 原语)、§5.1 五条纪律(第 4 条横切优先 / 第 5 条幂等回放等价)
  7. // - §10.1 错误码 3105/3106/3107/3108/3141、§10.3 事务模板、§10.7 金额
  8. //
  9. // 两条**反直觉**判据(dump 口径,**禁止按直觉互换,也禁止合并**):
  10. //
  11. // 3105 已报名 ← dump 762928:`COUNT(*) FROM signed WHERE user_id=? AND activity_id=? AND status != 0`
  12. // 判的是**报名行**状态 ⇒ 取消(status=0)后可**重新报名**同一活动;
  13. // 3106 老带新限一次 ← dump 762947:`signed JOIN activity ON type=4 WHERE user_id=? AND activity.status != 0`
  14. // 判的是**活动**状态、**不看报名行 status** ⇒ 参加过就**永久消耗次数**,取消也不退。
  15. //
  16. // 于是必然出现"取消报名后 → 可重报(3105 放行)但不能再参加老带新(3106 拒)"的组合,
  17. // 这正是两条判据**不得合并**的直接证据(合并任一方都会让另一半失真)。
  18. package service
  19. import (
  20. "context"
  21. "errors"
  22. "strconv"
  23. "strings"
  24. "time"
  25. "gorm.io/gorm"
  26. "yuansiwei/unified-backend/internal/academic/apperr"
  27. "yuansiwei/unified-backend/internal/academic/model"
  28. "yuansiwei/unified-backend/internal/academic/money"
  29. "yuansiwei/unified-backend/internal/academic/port"
  30. "yuansiwei/unified-backend/internal/academic/repository"
  31. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  32. appscope "yuansiwei/unified-backend/internal/infra/scope"
  33. )
  34. // 报名默认值(dump `IF inputStatus<1 THEN SET inputStatus=1`)。
  35. const (
  36. // defaultSignStatus 报名初始状态(dump 的 status<1 → 1)。
  37. defaultSignStatus int32 = model.ActivitySignedSigned
  38. )
  39. // ActivityService 活动报名服务(depends: 活动仓储 + 事务运行器 + 成员校验器)。
  40. //
  41. // 字段:
  42. // - repo 活动/报名仓储(含 3105/3106 判据与人数增减原语);
  43. // - runner 事务运行器(顶层 BEGIN;事务内透传同一 tx);
  44. // - member 成员关系校验器(可 nil:系统任务/割接脚本模式);
  45. // - now 时钟(单测注入;dump 用 NOW())。
  46. type ActivityService struct {
  47. repo *repository.ActivityRepo
  48. runner *repository.TxRunner
  49. member port.MembershipChecker
  50. now func() time.Time
  51. }
  52. // NewActivityService 构造活动报名服务。
  53. //
  54. // 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域,多租户过滤的唯一来源)。
  55. // 出参:*ActivityService。
  56. func NewActivityService(db *gorm.DB, sc *appscope.Scope) *ActivityService {
  57. return &ActivityService{
  58. repo: repository.NewActivityRepo(db, sc),
  59. runner: repository.NewTxRunner(db),
  60. now: time.Now,
  61. }
  62. }
  63. // WithMembershipChecker 注入成员校验器(返自身,链式装配)。
  64. //
  65. // 入参:member port.MembershipChecker(可 nil)。
  66. // 出参:*ActivityService(同一个实例)。
  67. func (s *ActivityService) WithMembershipChecker(member port.MembershipChecker) *ActivityService {
  68. s.member = member
  69. return s
  70. }
  71. // ---------------------------------------------------------------- 入参 / 响应 DTO
  72. // SignInput 活动报名入参(dump `signed_add` **5 参**,签名逐字对齐)。
  73. type SignInput struct {
  74. // UserID 报名用户(必填,ULID)。
  75. UserID string
  76. // ActivityID 活动 ID(必填 >0)。
  77. ActivityID int64
  78. // Status 报名状态(<1 → 1,dump 762921 同口径)。
  79. Status int32
  80. // RecommendUserID 推荐人(ULID;空/"0" → NULL;老带新活动才有意义)。
  81. RecommendUserID string
  82. // CreateUserID 操作人(→ create_user_id;空 → NULL)。
  83. CreateUserID string
  84. }
  85. // SignResult 活动报名响应。
  86. //
  87. // 字段语义:
  88. // - Status 实际写入的报名状态(dump 的 `inputStatus`,已按 `<1 → 1` 归一);
  89. // - ActivityMoney 快照进报名行的活动金额(= `max(activity.money, 0)`);
  90. // - NumberSign 本次报名后的活动报名人数(`number_sign+1` 之后的值)。
  91. type SignResult struct {
  92. // SignedID 报名行 ID(DELETE+INSERT 后的新行)。
  93. SignedID int64 `json:"signedId"`
  94. // ActivityID 活动 ID。
  95. ActivityID int64 `json:"activityId"`
  96. // UserID 报名用户。
  97. UserID string `json:"userId"`
  98. // Status 报名状态(归一后的值)。
  99. Status int32 `json:"status"`
  100. // ActivityMoney 报名行快照的活动金额(分)。
  101. ActivityMoney money.Amount `json:"activityMoney"`
  102. // NumberSign 报名后的活动报名人数。
  103. NumberSign int32 `json:"numberSign"`
  104. // ActivityType 活动类型(1/2/3/4)。
  105. ActivityType int8 `json:"activityType"`
  106. }
  107. // CancelSignInput 取消报名入参(dump `signed_cancel` **2 参**)。
  108. type CancelSignInput struct {
  109. // UserID 报名用户(必填)。
  110. UserID string
  111. // ActivityID 活动 ID(必填 >0)。
  112. ActivityID int64
  113. }
  114. // CancelSignResult 取消报名响应。
  115. //
  116. // 字段语义:`Status` 恒为 0(取消);金额三列已同语句归零(见 `repository.CancelSigned`)。
  117. type CancelSignResult struct {
  118. // ActivityID 活动 ID。
  119. ActivityID int64 `json:"activityId"`
  120. // UserID 报名用户。
  121. UserID string `json:"userId"`
  122. // Status 取消后的报名状态(恒 0)。
  123. Status int32 `json:"status"`
  124. // NumberSign 取消后的活动报名人数(`number_sign-1`,**下限 0**)。
  125. NumberSign int32 `json:"numberSign"`
  126. // ActivityType 活动类型(1/2/3/4)。
  127. ActivityType int8 `json:"activityType"`
  128. }
  129. // ---------------------------------------------------------------- 报名(SP19)
  130. // Sign 用户报名活动(SP19 `sp_courses_activity_signed_add`,dump 762908)。
  131. //
  132. // dump 语义(逐行对齐,行号见括号):
  133. //
  134. // 762921 IF inputStatus<1 THEN inputStatus=1
  135. // 762928 IF COUNT(signed WHERE user=? AND activity=? AND status!=0) > 0 → 返回 success=0(**3105**)
  136. // 762939 游标取 activity 的 (type, money)(**无行 → success=0**,见下"偏差")
  137. // 762947 IF type=4 AND COUNT(signed JOIN activity ON type=4 WHERE user=?) > 0 → success=0(**3106**)
  138. // 762955 IF activityMoney<0 THEN activityMoney=0(→ `max(money, 0)`)
  139. // 762965 DELETE signed WHERE user/activity(清旧行,含 status=0)+ INSERT + number_sign+1
  140. //
  141. // Go 的三处差异(均已在本方法注释与交付报告中登记):
  142. // 1. **3141 人数上限是 Go 增强**:dump 完全没有超卖保护(`number_sign` 只加不查),
  143. // 事务内用 `GetByIDForUpdate` + 比 `number_max` 判定(`number_max=0` = 不限);只影响
  144. // "人数已满"这一新分支,dump 已有分支输出不变。
  145. // 2. **活动不存在 → 3001**:dump 游标取不到行时 `success=0` 静默返回(等价于"没报名"),
  146. // Go 显式 3001 —— 目标态 signed 对 activity 有外键,静默成功会让前端误判。
  147. // 3. **3106 判据保留 `institution_id` 过滤**(多租户红线,Design §3.2 #19 唯一保留的 Go 偏差):
  148. // 只放宽 `signed.status` 与 `campus_id` 维度,机构过滤不删。
  149. //
  150. // 幂等语义:DELETE+INSERT 保证 `(user, activity)` 只留一行(UK 兜底);**重复报名不会产生第二行**,
  151. // 但按 dump 口径第二次会被 3105 拒(`status!=0` 命中原行)——故"重复报名"的可观测行为是 3105,
  152. // 不是 UPSERT 覆盖。
  153. //
  154. // 入参:ctx / in。出参:*SignResult;错误码 2001/2002/3001/3105/3106/3141/5002。
  155. func (s *ActivityService) Sign(ctx context.Context, in SignInput) (*SignResult, error) {
  156. if strings.TrimSpace(in.UserID) == "" {
  157. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: Sign 需要 user_id"))
  158. }
  159. if in.ActivityID <= 0 {
  160. return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("service: Sign 需要 activity_id>0"))
  161. }
  162. if s == nil || s.repo == nil || s.runner == nil {
  163. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityService 未装配"))
  164. }
  165. status := in.Status
  166. if status < 1 {
  167. status = defaultSignStatus // dump 762921
  168. }
  169. userID := strings.TrimSpace(in.UserID)
  170. var out SignResult
  171. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  172. txCtx := repository.WithTxContext(ctx, tx)
  173. if e := s.assertInScope(txCtx, userID); e != nil {
  174. return e
  175. }
  176. // ① 3105 已报名(判**报名行** status ⇒ 取消后可重报)
  177. exists, e := s.repo.ExistsSigned(txCtx, userID, in.ActivityID)
  178. if e != nil {
  179. return e
  180. }
  181. if exists {
  182. return infraerrors.WithCause(apperr.ErrActivityAlreadySigned,
  183. errString("service: Sign 已报名该活动(3105,dump 762928)"))
  184. }
  185. // ② 取活动 + 行锁(3141 判定需要与 number_sign+1 串行)
  186. act, e := s.repo.GetByIDForUpdate(txCtx, tx, in.ActivityID)
  187. if e != nil {
  188. if errors.Is(e, infraerrors.ErrNotFound) {
  189. return infraerrors.WithCause(infraerrors.ErrNotFound,
  190. errString("service: Sign 活动不存在或不在作用域内(dump:游标无行 → success=0)"))
  191. }
  192. return e
  193. }
  194. // ③ 3106 老带新限一次(判**活动** status、**不判**报名行 status)
  195. if act.Type == model.ActivityTypeRecommend {
  196. count, ce := s.repo.CountSignedByUser(txCtx, userID, true)
  197. if ce != nil {
  198. return ce
  199. }
  200. if count > 0 {
  201. return infraerrors.WithCause(apperr.ErrRecommendLimited,
  202. errString("service: Sign 老带新每人限参加一次(3106,dump 762947;取消报名也不退次数)"))
  203. }
  204. }
  205. // ④ 3141 人数上限(**Go 增强**:dump 无此校验)
  206. if act.NumberMax > 0 && act.NumberSign >= act.NumberMax {
  207. return infraerrors.WithCause(apperr.ErrActivityFull,
  208. errString("service: Sign 报名人数已满(3141;dump 无此校验,属 Go 有意增强)"))
  209. }
  210. // ⑤ activity_money = max(activity.money, 0)(dump 762955)
  211. actMoney, e := parseAccountAmount(act.Money, "t_courses_activity.money")
  212. if e != nil {
  213. return e
  214. }
  215. if actMoney < 0 {
  216. actMoney = 0
  217. }
  218. // ⑥ DELETE + INSERT(幂等重置全字段)
  219. row := &model.CoursesActivitySigned{
  220. ActivityID: in.ActivityID,
  221. UserID: userID,
  222. InstitutionID: act.InstitutionID,
  223. CampusID: act.CampusID, // 快照活动 campus_id(0 有"全自营校区"语义,不用请求作用域值覆盖)
  224. Status: status,
  225. ActivityMoney: actMoney.String(),
  226. ActivityMoneyUsed: "0.00",
  227. RecommendUserID: optString(in.RecommendUserID),
  228. CreateUserID: optString(in.CreateUserID),
  229. }
  230. if e := s.repo.UpsertSigned(txCtx, tx, row); e != nil {
  231. return e
  232. }
  233. // ⑦ 报名人数 +1(原子,与插入同事务)
  234. if _, e := s.repo.IncreaseNumberSign(txCtx, tx, in.ActivityID, 1); e != nil {
  235. return e
  236. }
  237. out = SignResult{
  238. SignedID: row.ID,
  239. ActivityID: in.ActivityID,
  240. UserID: userID,
  241. Status: status,
  242. ActivityMoney: actMoney,
  243. NumberSign: act.NumberSign + 1,
  244. ActivityType: act.Type,
  245. }
  246. return nil
  247. })
  248. if err != nil {
  249. return nil, err
  250. }
  251. return &out, nil
  252. }
  253. // ---------------------------------------------------------------- 取消报名(SP20)
  254. // CancelSign 用户取消报名(SP20 `sp_courses_activity_signed_cancel`,dump 762982)。
  255. //
  256. // dump 语义(逐行对齐):
  257. //
  258. // 762994 游标:signed JOIN activity WHERE user/activity AND signed.status!=0
  259. // 763007 IF type∈{2,3} AND response_userid>0 → LEAVE(**3107** 已审核不可取消)
  260. // 763013 ELSEIF type=4 AND EXISTS(used_log WHERE activityid/userid) → LEAVE(**3108** 已消费不可取消)
  261. // 763028 UPDATE signed SET status=0, activity_money=0, activity_money_used=0(**三列同置**)
  262. // 763030 UPDATE activity SET number_sign=number_sign-1(Go 加**下限 0**保护)
  263. //
  264. // 守卫命中时**不减人数**(`LEAVE` 在两条 UPDATE 之前)——这是"拒绝必须零副作用"的判据:
  265. // 若把 -1 写进守卫之前,就会留下"取消被拒但人数少了"的脏数据。
  266. //
  267. // 入参:ctx / in。出参:*CancelSignResult;错误码 2001/2002/3001/3107/3108/5002。
  268. func (s *ActivityService) CancelSign(ctx context.Context, in CancelSignInput) (*CancelSignResult, error) {
  269. if strings.TrimSpace(in.UserID) == "" {
  270. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: CancelSign 需要 user_id"))
  271. }
  272. if in.ActivityID <= 0 {
  273. return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("service: CancelSign 需要 activity_id>0"))
  274. }
  275. if s == nil || s.repo == nil || s.runner == nil {
  276. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityService 未装配"))
  277. }
  278. userID := strings.TrimSpace(in.UserID)
  279. var out CancelSignResult
  280. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  281. txCtx := repository.WithTxContext(ctx, tx)
  282. if e := s.assertInScope(txCtx, userID); e != nil {
  283. return e
  284. }
  285. // ① 游标等价物:该用户在该活动上的**有效**报名行(status!=0)
  286. signed, e := s.repo.GetSigned(txCtx, userID, in.ActivityID)
  287. if e != nil {
  288. if errors.Is(e, infraerrors.ErrNotFound) {
  289. return infraerrors.WithCause(infraerrors.ErrNotFound,
  290. errString("service: CancelSign 报名记录不存在或已取消(dump:游标无行 → success=0)"))
  291. }
  292. return e
  293. }
  294. // ② 3107:充值赠送 / 代金券活动审核通过后不可取消
  295. if signed.ActivityType == model.ActivityTypeRecharge || signed.ActivityType == model.ActivityTypeVoucher {
  296. if hasUserRef(signed.ResponseUserID) {
  297. return infraerrors.WithCause(apperr.ErrActivitySignedAudited,
  298. errString("service: CancelSign 活动已审核通过不可取消(3107,dump 763007)"))
  299. }
  300. } else if signed.ActivityType == model.ActivityTypeRecommend {
  301. // ③ 3108:老带新已有消费/结算记录 → 不可取消(**判存在性**,不看金额)
  302. used, ue := s.discountRepo().ExistsUsedLog(txCtx, in.ActivityID, userID)
  303. if ue != nil {
  304. return ue
  305. }
  306. if used {
  307. return infraerrors.WithCause(apperr.ErrRecommendUsed,
  308. errString("service: CancelSign 老带新已产生消费不可取消(3108,dump 763013)"))
  309. }
  310. }
  311. // ④ 三列同置 0(dump 763028:一条 UPDATE)
  312. affected, e := s.repo.CancelSigned(txCtx, tx, userID, in.ActivityID)
  313. if e != nil {
  314. return e
  315. }
  316. if affected == 0 {
  317. return infraerrors.WithCause(infraerrors.ErrNotFound,
  318. errString("service: CancelSign 报名行不可写(并发删除/作用域变化)"))
  319. }
  320. // ⑤ 报名人数 -1(dump 763030;Go 加下限 0,防并发把人数减成负数)
  321. if _, e := s.repo.IncreaseNumberSign(txCtx, tx, in.ActivityID, -1); e != nil {
  322. return e
  323. }
  324. // ⑥ 回读人数(响应体必须是**当刻库值**,不复用中间变量)
  325. after, e := s.repo.GetByID(txCtx, in.ActivityID)
  326. if e != nil {
  327. return e
  328. }
  329. out = CancelSignResult{
  330. ActivityID: in.ActivityID,
  331. UserID: userID,
  332. Status: model.ActivitySignedCancelled,
  333. NumberSign: after.NumberSign,
  334. ActivityType: signed.ActivityType,
  335. }
  336. return nil
  337. })
  338. if err != nil {
  339. return nil, err
  340. }
  341. return &out, nil
  342. }
  343. // ---------------------------------------------------------------- 内部助手
  344. // discountRepo 按需构造赠送池仓储(复用同一 DB/作用域;取消报名的 3108 判据需要它)。
  345. //
  346. // 为什么不在结构体里固定持有:AC-07 的取消路径只有 type=4 一支需要它,
  347. // 按需构造可让 `ActivityService` 的装配依赖保持最小(同一 DB 句柄,无额外连接)。
  348. func (s *ActivityService) discountRepo() *repository.ActivityDiscountRepo {
  349. return repository.NewActivityDiscountRepo(s.repo.DB, s.repo.Sc)
  350. }
  351. // assertInScope 对象级越权断言(语义同 FundService;未注入 checker 的系统任务模式直接放行)。
  352. func (s *ActivityService) assertInScope(ctx context.Context, userID string) error {
  353. if s == nil || s.member == nil {
  354. return nil
  355. }
  356. return s.member.AssertInScope(ctx, userID, 0, 0)
  357. }
  358. // hasUserRef 判断目标态的用户引用列是否"有值"(旧 int 型的 `>0` 在 ULID 列上的等价判据)。
  359. //
  360. // 口径:NULL / 空串 / "0" 都视为无值(与 §3.8-A17 的 `NOT IN ('', '0')` 同源)——
  361. // 迁移期可能把旧的 `0` 物化成字符串,只用 `IS NOT NULL` 会把它当"已审核"。
  362. func hasUserRef(ref *string) bool {
  363. if ref == nil {
  364. return false
  365. }
  366. v := strings.TrimSpace(*ref)
  367. return v != "" && v != "0"
  368. }
  369. // orderNoOf 把报课记录 ID 转成流水 `order_no`/`log_id` 的字符串口径(dump 的 VARCHAR(50) 入参)。
  370. //
  371. // 入参:id 数值主键(<=0 → 空串,调用方按"无单号"处理)。
  372. // 出参:十进制字符串(id<=0 → "")。
  373. func orderNoOf(id int64) string {
  374. if id <= 0 {
  375. return ""
  376. }
  377. return strconv.FormatInt(id, 10)
  378. }