activity_admin_service.go 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833
  1. // 活动管理服务(端点 22~25 / 28 / 37 的编排层)。
  2. //
  3. // 依据:
  4. // - unified/03-migration/academic-frontend-contract-v1.md §2.2(活动 16 端点)/ §3.4 B(字段级定义 + 量纲);
  5. // - unified/03-migration/academic-domain-design.md §4.2(端点清单与角色)/ §1.7(作用域)/
  6. // §10.1(错误码:2001 缺参 / 2002 格式 / 3001 不存在 / 1004 越权 / 3140 参数矛盾)/
  7. // §10.3(事务边界只在 Service)/ §3.8-Z1(活动类金额 ≤0 **跳过**,不升级 2002)/ §3.8-R7(查不到 → 3001)。
  8. //
  9. // ---------------------------------------------------------------------------
  10. // 与 `activity_service.go` 的分工(两者**都属活动域**,但职责不同)
  11. // ---------------------------------------------------------------------------
  12. //
  13. // activity_service.go 有 dump SP 对照的**资金/状态机**写路径(signed_add / signed_cancel)
  14. // activity_admin_service.go 无 dump SP 对照的**管理面**读写(活动 CRUD / 报名列表 / 回访)
  15. //
  16. // 分文件而不是塞一起:前者的每个分支都能指到 dump 行号,后者只能指到 DDL 列。混在一起后,
  17. // "这段逻辑对不对"就会变成"文档说对就对"——本文件因此坚持"每个字段写来源列、写不出来就不写"。
  18. //
  19. // ---------------------------------------------------------------------------
  20. // 三条错误码纪律(本文件最容易写错的地方)
  21. // ---------------------------------------------------------------------------
  22. //
  23. // ① 2001(缺参)只在 Handler 由 `validate` tag 产生;Service 这里只处理**值**的问题。
  24. // ② 2002 只用于「传了但非法」:枚举越界(type∉{2,3,4} / status∉{0,1,2})、金额不可表示、
  25. // 时间窗倒挂。**不得**把"业务规则不满足"塞进来(那是专用 31xx 或 3140)。
  26. // ③ 活动类金额 **≤0 一律不拒**(§3.8-Z1:dump 对活动侧金额只做 `max(x,0)` 与"≤0 跳过",
  27. // 从不报错;把 ≤0 升级成 2002 会在活动服务里造出 dump 不存在的拒绝分支)。因此
  28. // `CreateActivity` 允许 `money<=0`(`Sign` 时由 `max(money,0)` 归一),只在超出
  29. // `money.Amount` 可表示范围时才 2002。
  30. package service
  31. import (
  32. "context"
  33. "time"
  34. "gorm.io/gorm"
  35. "yuansiwei/unified-backend/internal/academic/model"
  36. "yuansiwei/unified-backend/internal/academic/money"
  37. "yuansiwei/unified-backend/internal/academic/port"
  38. "yuansiwei/unified-backend/internal/academic/repository"
  39. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  40. appscope "yuansiwei/unified-backend/internal/infra/scope"
  41. )
  42. const (
  43. // ActivityDetailSignedLimit 活动详情内嵌报名列表的条数上限(端点 24 的 `signeds[]`)。
  44. //
  45. // 为什么设上限而不是"全量":详情是高频页面,热门活动报名可能上万行;全量下发会让
  46. // 单次响应到 MB 级。取 200 与**分页上限同值**(`repository.MaxPageSize`),语义是
  47. // "详情页看最近 200 条,要翻页用端点 28"——这条不是契约要求,属实现选择,已在交付报告登记。
  48. ActivityDetailSignedLimit = 200
  49. )
  50. // activityEditableTypes 活动可写类型白名单(契约 §2.2 端点 23:「新建(type 2/3/4)」)。
  51. //
  52. // 1(试听)是旧遗留(`model.ActivityTypeTrial` 注释:不参与 SP 逻辑)⇒ 新建不接受。
  53. var activityEditableTypes = map[int8]struct{}{
  54. model.ActivityTypeRecharge: {},
  55. model.ActivityTypeVoucher: {},
  56. model.ActivityTypeRecommend: {},
  57. }
  58. // activityStatuses 活动状态白名单(`model.ActivityStatus*`:0 停用 / 1 开启 / 2 已结束)。
  59. var activityStatuses = map[int8]struct{}{
  60. model.ActivityStatusDisabled: {},
  61. model.ActivityStatusEnabled: {},
  62. model.ActivityStatusEnded: {},
  63. }
  64. // ActivityAdminService 活动管理服务(端点 22~25 / 28 / 37)。
  65. //
  66. // 字段:
  67. // - repo 活动/报名仓储(作用域在构造期绑定);
  68. // - runner 事务运行器(新建/更新/回访各一个事务);
  69. // - campus 校区只读端口(校验 `campusId` 是否归属本机构;可 nil:nil 时跳过错机构校验,
  70. // 仅限系统任务/割接脚本,生产装配**必须**注入);
  71. // - sc 请求作用域(机构/校区过滤的唯一来源,`CreateActivity` 的 `institution_id` 取自这里);
  72. // - now 时钟(`signable` 的时间窗判定;单测注入固定时刻,避免用例随日期变红)。
  73. type ActivityAdminService struct {
  74. repo *repository.ActivityRepo
  75. runner *repository.TxRunner
  76. campus port.CampusReader
  77. sc *appscope.Scope
  78. now func() time.Time
  79. }
  80. // NewActivityAdminService 构造活动管理服务。
  81. //
  82. // 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域)。
  83. // 出参:*ActivityAdminService(校区端口用 `WithCampusReader` 注入)。
  84. func NewActivityAdminService(db *gorm.DB, sc *appscope.Scope) *ActivityAdminService {
  85. return &ActivityAdminService{
  86. repo: repository.NewActivityRepo(db, sc),
  87. runner: repository.NewTxRunner(db),
  88. sc: sc,
  89. now: time.Now,
  90. }
  91. }
  92. // WithCampusReader 注入校区只读端口(返自身,链式装配)。
  93. //
  94. // 入参:reader port.CampusReader(可 nil)。
  95. // 出参:*ActivityAdminService(同一个实例)。
  96. func (s *ActivityAdminService) WithCampusReader(reader port.CampusReader) *ActivityAdminService {
  97. s.campus = reader
  98. return s
  99. }
  100. // WithClock 注入时钟(单测用;生产用 `time.Now`)。
  101. //
  102. // 入参:now func() time.Time(nil → 忽略)。
  103. // 出参:*ActivityAdminService(同一个实例)。
  104. func (s *ActivityAdminService) WithClock(now func() time.Time) *ActivityAdminService {
  105. if now != nil {
  106. s.now = now
  107. }
  108. return s
  109. }
  110. // ---------------------------------------------------------------- 响应视图(字段名 = 契约 §3.4 B 逐字)
  111. // ActivityView 活动列表/详情行(契约 §3.4 B 行 22 / 24,**字段名与量纲逐字照抄**)。
  112. //
  113. // 字段 → 来源列(右侧为契约要求的准入溯源,缺一列就不该有该字段):
  114. //
  115. // id ← t_courses_activity.id
  116. // institutionId ← t_courses_activity.institution_id
  117. // campusId ← t_courses_activity.campus_id
  118. // type ← t_courses_activity.type
  119. // name ← t_courses_activity.title (**纯改名**:契约定名 name,含义仍是"活动标题")
  120. // status ← t_courses_activity.status
  121. // numberSign ← t_courses_activity.number_sign
  122. // total ← t_courses_activity.number_max (**纯改名**:契约定名 total=名额上限)
  123. // signed ← COUNT(t_courses_activity_signed WHERE status<>0) (派生,判据同 dump 762928)
  124. // money ← t_courses_activity.money (**活动金额**:充值赠送额度 / 老带新奖励 / 特殊费用额)
  125. // signable ← status/number_max/number_sign/start_time/end_time 合成(派生,**仅展示位**)
  126. // startTime ← t_courses_activity.start_time
  127. // endTime ← t_courses_activity.end_time
  128. //
  129. // ⚠ **`money` 与 `signeds[].money` 不是一回事**:本字段是**活动级**(配置的活动金额,
  130. // 契约 §3.4 B 行 22 注),报名行里的是**报名级**(实际用掉的金额)。两者层级不同、勿混用。
  131. // 归属依据:v1.6 契约补入本字段(表有 `money` 列且属活动主干属性)。
  132. type ActivityView struct {
  133. // ID 活动 ID。
  134. ID int64 `json:"id"`
  135. // InstitutionID 机构 ID。
  136. InstitutionID int64 `json:"institutionId"`
  137. // CampusID 校区 ID(0 = 机构所有自营校区)。
  138. CampusID int64 `json:"campusId"`
  139. // Type 活动类型(2 充值赠送 / 3 代金券 / 4 老带新)。
  140. Type int8 `json:"type"`
  141. // Name 活动标题(← title)。
  142. Name string `json:"name"`
  143. // Status 活动状态(0 停用 / 1 开启 / 2 已结束)。
  144. Status int8 `json:"status"`
  145. // NumberSign 计数列 number_sign(报名 +1 / 取消 -1;**活动更新时不可写**)。
  146. NumberSign int32 `json:"numberSign"`
  147. // Total 名额上限(← number_max;0 = 不限)。
  148. Total int32 `json:"total"`
  149. // Signed 有效报名人数(`signed.status <> 0` 的行数)。
  150. Signed int32 `json:"signed"`
  151. // Money 活动金额(← `money`;充值赠送额度 / 老带新奖励 / 特殊费用额)。
  152. // **不是** `signeds[].money`(那是报名级实扣金额)。
  153. Money money.Amount `json:"money"`
  154. // Signable 前端展示位:当前是否"看起来可以报名"(**不代表后端会拒绝**,见契约 §3.4 B 行 22 注)。
  155. Signable bool `json:"signable"`
  156. // StartTime 活动开始时间(可空)。
  157. StartTime *time.Time `json:"startTime"`
  158. // EndTime 活动结束时间(可空)。
  159. EndTime *time.Time `json:"endTime"`
  160. }
  161. // SignedView 报名行视图(契约 §3.4 B 行 24 / 28 的 `signeds[]` 行结构)。
  162. //
  163. // 字段 → 来源列:
  164. //
  165. // id ← t_courses_activity_signed.id
  166. // activityId ← t_courses_activity_signed.activity_id
  167. // userId ← t_courses_activity_signed.user_id
  168. // status ← t_courses_activity_signed.status(0 取消/1 报名/2 参加/3 报课/4 过期)
  169. // recommendUserId ← t_courses_activity_signed.recommend_user_id(NULL → null)
  170. // money ← t_courses_activity_signed.activity_money(**纯改名**:契约定名 money)
  171. // createTime ← t_courses_activity_signed.create_time(**业务时间**:报名时间,§1.5 B1 ①类)
  172. //
  173. // ⚠ `createTime` 在这里是**真实业务时间**(legacy 原地迁移列),与端点 12 的
  174. // `createdAt`(迁移执行时刻的新加列,禁下发)不是一回事——两者源列不同,别类推。
  175. type SignedView struct {
  176. // ID 报名行 ID。
  177. ID int64 `json:"id"`
  178. // ActivityID 活动 ID。
  179. ActivityID int64 `json:"activityId"`
  180. // UserID 报名用户(ULID)。
  181. UserID string `json:"userId"`
  182. // Status 报名状态(0/1/2/3/4)。
  183. Status int32 `json:"status"`
  184. // RecommendUserID 推荐人(可空 → null)。
  185. RecommendUserID *string `json:"recommendUserId"`
  186. // Money 活动金额快照(← activity_money,分定点)。
  187. Money money.Amount `json:"money"`
  188. // CreateTime 报名时间(业务时间)。
  189. CreateTime time.Time `json:"createTime"`
  190. }
  191. // ListActivitiesResult 活动列表响应(`response.Page` 直接消费,分页五字段由它算定)。
  192. type ListActivitiesResult struct {
  193. // List 当前页活动(**空 → `[]`,不是 nil**)。
  194. List []ActivityView
  195. // Total 同过滤条件总数。
  196. Total int64
  197. // Page 当前页码(回显)。
  198. Page int
  199. // PageSize 每页条数(回显,已钳制 ≤200)。
  200. PageSize int
  201. }
  202. // ActivityDetailResult 活动详情响应(契约 §3.4 B 行 24:22 行结构 + `signeds[]`)。
  203. //
  204. // 结构:内嵌 `ActivityView`(JSON 平铺,**不套 `activity` 层**)+ `signeds` 数组。
  205. type ActivityDetailResult struct {
  206. // ActivityView 活动行字段(平铺)。
  207. ActivityView
  208. // Signeds 报名行(最多 `ActivityDetailSignedLimit` 条,按 id 升序;空 → `[]`)。
  209. Signeds []SignedView `json:"signeds"`
  210. }
  211. // ListSignedsResult 报名列表响应(契约 §3.4 B 行 28)。
  212. type ListSignedsResult struct {
  213. // List 当前页报名行(空 → `[]`)。
  214. List []SignedView
  215. // Total 同过滤条件总数。
  216. Total int64
  217. // Page 当前页码。
  218. Page int
  219. // PageSize 每页条数(已钳制)。
  220. PageSize int
  221. }
  222. // CreateActivityResult 新建活动响应(契约 §3.4 B 行 23)。
  223. //
  224. // ⚠ 契约该行还要求 `bizNo:string`,但 `t_courses_activity` **没有 `biz_no` 列**
  225. // (目标态 DDL 第 242-268 行逐列核对:id/type/toped/thumbnail/subject_id/grade_id/institution_id/
  226. // campus_id/start_time/end_time/number_max/number_sign/title/content/money/status/view/memo/
  227. // create_user_id/create_time/updated_at)。按 §3.4 ③「字段准入条件」,**无列即不下发**:
  228. // 本结构体只回 `id`,`bizNo` 作为契约缺陷上报(「先报、不擅自造字段」),
  229. // 不在后端编一个"活动-<id>"之类的伪单号——伪单号会立刻被当作对账/幂等键使用。
  230. type CreateActivityResult struct {
  231. // ID 新活动 ID。
  232. ID int64 `json:"id"`
  233. }
  234. // UpdateActivityResult 更新活动响应(契约 §3.4 B 行 25:`{id}`)。
  235. type UpdateActivityResult struct {
  236. // ID 活动 ID。
  237. ID int64 `json:"id"`
  238. }
  239. // VisitResult 回访登记响应(契约 §3.4 B 行 37:`{visited, activityId}`)。
  240. type VisitResult struct {
  241. // Visited 是否已登记(成功恒 true)。
  242. Visited bool `json:"visited"`
  243. // ActivityID 报名行所属活动 ID。
  244. ActivityID int64 `json:"activityId"`
  245. }
  246. // ---------------------------------------------------------------- 入参
  247. // ListActivitiesInput 活动列表入参(端点 22)。
  248. //
  249. // 字段:Type/Status 已由 Handler 做白名单校验(非法 → 2002),此处只做"给了就筛"。
  250. type ListActivitiesInput struct {
  251. // Type 活动类型(nil = 不筛)。
  252. Type *int8
  253. // Status 活动状态(nil = 不筛)。
  254. Status *int8
  255. // Keyword 标题关键字(空 = 不筛)。
  256. Keyword string
  257. // Page 页码(<1 → 1)。
  258. Page int
  259. // PageSize 每页条数(<1 → 10;>200 → 200)。
  260. PageSize int
  261. }
  262. // CreateActivityInput 新建活动入参(端点 23)。
  263. //
  264. // 字段来源:目标态 DDL 第 7 表的**可写列**(计数列 number_sign / view / create_time / updated_at
  265. // 不在此列——它们分别由报名、访问、DB 默认维护)。
  266. type CreateActivityInput struct {
  267. // InstitutionID 机构 ID(Handler 从请求作用域填入;**不取 body**)。
  268. InstitutionID int64
  269. // CampusID 校区 ID(0 = 机构所有自营校区);>0 时校验归属本机构。
  270. CampusID int64
  271. // Type 活动类型(2/3/4;其它 → 2002)。
  272. Type int8
  273. // Title 活动标题(→ title)。
  274. Title string
  275. // Content 活动内容(HTML;可空)。
  276. Content string
  277. // Memo 备注(可空)。
  278. Memo string
  279. // Thumbnail 缩略图 URL(可空)。
  280. Thumbnail string
  281. // Money 活动金额/额度(分;**允许 ≤0**,见文件头纪律 ③)。
  282. Money money.Amount
  283. // StartTime 开始时间(可空)。
  284. StartTime *time.Time
  285. // EndTime 结束时间(可空;给了 StartTime 时不得早于它)。
  286. EndTime *time.Time
  287. // NumberMax 名额上限(0 = 不限;负数 → 2002)。
  288. NumberMax int32
  289. // Status 活动状态(0/1/2;其它 → 2002)。
  290. Status int8
  291. // Toped 置顶(0/1)。
  292. Toped int8
  293. // SubjectID 科目(0 = 混合)。
  294. SubjectID int32
  295. // GradeID 年级(0 = 混合)。
  296. GradeID int32
  297. // OperatorID 操作人(→ create_user_id;空 → NULL)。
  298. OperatorID string
  299. }
  300. // UpdateActivityInput 更新活动入参(端点 25,**PUT = 整体替换**)。
  301. //
  302. // 语义(重要,避免"漏传字段被保留"的错觉):PUT 语义是**整体替换可编辑字段**,
  303. // 未传的可选字段(Content/Memo/Thumbnail)会被置空 —— 与 PATCH 的"只改传了的"不同。
  304. // 这样写有两个理由:① 旧系统编辑页是一次提交全表单;② "漏传即保留"会让前端无法把
  305. // 活动备注清空(清空 = 传空串,而空串与"没传"在 Go 结构体里不可区分,除非上指针,
  306. // 而指针化会让 20 个字段全部变成 `*T` 判空,读起来比 PUT 语义复杂得多)。
  307. //
  308. // `NumberSign` **不在入参里**:契约 §3.4 B 行 25(v1.6)「`numberSign` 传即 2002」——
  309. // 该判定在 Handler 侧完成(需要知道"传了没有",只有原始 JSON 层知道),
  310. // 到 Service 时已是"允许写"的状态。
  311. type UpdateActivityInput struct {
  312. // ID 活动 ID(>0)。
  313. ID int64
  314. // InstitutionID 请求作用域机构(用于校验,**不参与更新**:机构归属不可变)。
  315. InstitutionID int64
  316. // CampusID 校区 ID(0 = 机构所有自营校区);>0 时校验归属本机构。
  317. CampusID int64
  318. // Type 活动类型(2/3/4;运行期改 type 属高危,这里**允许**改——旧系统允许,不做额外限制)。
  319. Type int8
  320. // Title 活动标题。
  321. Title string
  322. // Content 活动内容。
  323. Content string
  324. // Memo 备注。
  325. Memo string
  326. // Thumbnail 缩略图。
  327. Thumbnail string
  328. // Money 活动金额(分;≤0 允许)。
  329. Money money.Amount
  330. // StartTime 开始时间(可空 → NULL)。
  331. StartTime *time.Time
  332. // EndTime 结束时间(可空 → NULL)。
  333. EndTime *time.Time
  334. // NumberMax 名额上限(0 = 不限)。
  335. NumberMax int32
  336. // Status 活动状态(0/1/2)。
  337. Status int8
  338. // Toped 置顶。
  339. Toped int8
  340. // SubjectID 科目。
  341. SubjectID int32
  342. // GradeID 年级。
  343. GradeID int32
  344. }
  345. // ListSignedsInput 报名列表入参(端点 28)。
  346. type ListSignedsInput struct {
  347. // ActivityID 活动 ID(>0)。
  348. ActivityID int64
  349. // Status 报名状态过滤(nil = 不筛;白名单由 Handler 校验)。
  350. Status *int32
  351. // Page 页码。
  352. Page int
  353. // PageSize 每页条数。
  354. PageSize int
  355. }
  356. // VisitInput 回访登记入参(端点 37)。
  357. type VisitInput struct {
  358. // ActivityID 路径里的活动 ID(>0)。
  359. ActivityID int64
  360. // ActivitySignedID 报名行 ID(>0)。
  361. ActivitySignedID int64
  362. // Memo 回访内容(必填,契约 §2.2 行 37 写的是 `activitySignedId,memo` 无问号)。
  363. Memo string
  364. // OperatorID 回访人(→ create_user_id;来自作用域,不取 body)。
  365. OperatorID string
  366. }
  367. // ---------------------------------------------------------------- 列表(端点 22)
  368. // ListActivities 活动分页列表(端点 22 `GET /admin/activities`)。
  369. //
  370. // dump:**无**(PHP 直查)。派生字段 `signed` 的判据与 dump `762928` 的 3105 查重同口径
  371. // (`status <> 0`),这样"列表显示的报名数"与"能否报名"不会互相打脸。
  372. //
  373. // 入参:ctx / in。出参:*ListActivitiesResult;错误码 5002。
  374. func (s *ActivityAdminService) ListActivities(
  375. ctx context.Context, in ListActivitiesInput,
  376. ) (*ListActivitiesResult, error) {
  377. if s == nil || s.repo == nil {
  378. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityAdminService 未装配"))
  379. }
  380. rows, total, err := s.repo.ListActivities(ctx, repository.ActivityListFilter{
  381. Type: in.Type, Status: in.Status, Keyword: in.Keyword,
  382. }, in.Page, in.PageSize)
  383. if err != nil {
  384. return nil, err
  385. }
  386. ids := make([]int64, 0, len(rows))
  387. for i := range rows {
  388. ids = append(ids, rows[i].ID)
  389. }
  390. counts, err := s.repo.CountSignedByActivityIDs(ctx, ids)
  391. if err != nil {
  392. return nil, err
  393. }
  394. list := make([]ActivityView, 0, len(rows))
  395. for i := range rows {
  396. list = append(list, s.view(&rows[i], counts[rows[i].ID]))
  397. }
  398. page, pageSize := repository.ClampPage(in.Page, in.PageSize)
  399. return &ListActivitiesResult{List: list, Total: total, Page: page, PageSize: pageSize}, nil
  400. }
  401. // ---------------------------------------------------------------- 详情(端点 24)
  402. // GetActivity 活动详情(端点 24 `GET /admin/activities/:id`)。
  403. //
  404. // 入参:ctx;id 活动 ID(<=0 → 2002,由 Handler 的路径解析给出)。
  405. // 出参:*ActivityDetailResult;错误码 3001(不存在或不在作用域内)/ 5002。
  406. //
  407. // 作用域:`GetByID` 带机构+校区过滤 ⇒ 跨机构活动一律 **3001**(不泄露存在性)。
  408. // 报名行同理(`ListSignedByActivity` 叠 signed 的冗余作用域列)。
  409. func (s *ActivityAdminService) GetActivity(ctx context.Context, id int64) (*ActivityDetailResult, error) {
  410. if id <= 0 {
  411. return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("service: GetActivity 需要 id>0"))
  412. }
  413. if s == nil || s.repo == nil {
  414. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityAdminService 未装配"))
  415. }
  416. act, err := s.repo.GetByID(ctx, id)
  417. if err != nil {
  418. return nil, err
  419. }
  420. counts, err := s.repo.CountSignedByActivityIDs(ctx, []int64{act.ID})
  421. if err != nil {
  422. return nil, err
  423. }
  424. signedRows, _, err := s.repo.ListSignedByActivity(ctx, act.ID, nil, 1, ActivityDetailSignedLimit)
  425. if err != nil {
  426. return nil, err
  427. }
  428. return &ActivityDetailResult{
  429. ActivityView: s.view(act, counts[act.ID]),
  430. Signeds: s.signedViews(signedRows),
  431. }, nil
  432. }
  433. // ---------------------------------------------------------------- 新建(端点 23)
  434. // CreateActivity 新建活动(端点 23 `POST /admin/activities`)。
  435. //
  436. // 校验顺序(**先参数、后越权、再落库**):
  437. //
  438. // ① type ∈ {2,3,4} → 否则 2002(契约 §2.2 行 23)
  439. // ② status ∈ {0,1,2} → 否则 2002
  440. // ③ numberMax >= 0 → 否则 2002(计数列不接受负值)
  441. // ④ money 可表示 → 否则 2002(≤0 **不拒**,§3.8-Z1)
  442. // ⑤ startTime <= endTime → 否则 2002(时间窗倒挂)
  443. // ⑥ campusId 归属本机构 → 校区不存在 3104 / 跨机构 **1004**
  444. // ⑦ 落库(number_sign 由仓储强制清零)
  445. //
  446. // 入参:ctx / in(`InstitutionID` 来自作用域)。出参:*CreateActivityResult;错误码 2002/3104/1004/5002。
  447. func (s *ActivityAdminService) CreateActivity(
  448. ctx context.Context, in CreateActivityInput,
  449. ) (*CreateActivityResult, error) {
  450. if s == nil || s.repo == nil || s.runner == nil {
  451. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityAdminService 未装配"))
  452. }
  453. if in.InstitutionID <= 0 {
  454. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  455. errString("service: CreateActivity 缺少机构作用域(institution_id 不能为 0)"))
  456. }
  457. if err := validateActivityFields(in.Type, in.Status, in.NumberMax, in.Money, in.StartTime, in.EndTime); err != nil {
  458. return nil, err
  459. }
  460. if err := s.assertCampusInScope(ctx, in.CampusID, in.InstitutionID); err != nil {
  461. return nil, err
  462. }
  463. row := &model.CoursesActivity{
  464. Type: in.Type,
  465. Toped: in.Toped,
  466. Thumbnail: optString(in.Thumbnail),
  467. SubjectID: in.SubjectID,
  468. GradeID: in.GradeID,
  469. InstitutionID: in.InstitutionID,
  470. CampusID: in.CampusID,
  471. StartTime: in.StartTime,
  472. EndTime: in.EndTime,
  473. NumberMax: in.NumberMax,
  474. NumberSign: 0, // 计数列:由仓储再强制清零一次(双保险)
  475. Title: optString(in.Title),
  476. Content: optString(in.Content),
  477. Money: in.Money.String(),
  478. Status: in.Status,
  479. View: 0,
  480. Memo: optString(in.Memo),
  481. CreateUserID: optString(in.OperatorID),
  482. }
  483. if err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  484. return s.repo.CreateActivity(repository.WithTxContext(ctx, tx), tx, row)
  485. }); err != nil {
  486. return nil, err
  487. }
  488. return &CreateActivityResult{ID: row.ID}, nil
  489. }
  490. // ---------------------------------------------------------------- 更新(端点 25)
  491. // UpdateActivity 更新活动(端点 25 `PUT /admin/activities/:id`)。
  492. //
  493. // 校验:与新建同(①~⑥),额外一条 —— 活动必须存在且在本作用域(否则 **3001**,
  494. // 由 `RowsAffected==0` 判定:`UpdateActivity` 的 WHERE 已带作用域)。
  495. //
  496. // `number_sign` **不写**:契约 §3.4 B 行 25(v1.6)「`numberSign` 传即 2002」,该判定在 Handler
  497. // (只有原始 JSON 层能区分"没传"与"传了 0")。到本方法时 patch 里绝不含该列。
  498. //
  499. // 入参:ctx / in。出参:*UpdateActivityResult;错误码 2002/3104/1004/3001/5002。
  500. func (s *ActivityAdminService) UpdateActivity(
  501. ctx context.Context, in UpdateActivityInput,
  502. ) (*UpdateActivityResult, error) {
  503. if in.ID <= 0 {
  504. return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("service: UpdateActivity 需要 id>0"))
  505. }
  506. if s == nil || s.repo == nil || s.runner == nil {
  507. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityAdminService 未装配"))
  508. }
  509. if in.InstitutionID <= 0 {
  510. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  511. errString("service: UpdateActivity 缺少机构作用域"))
  512. }
  513. if err := validateActivityFields(in.Type, in.Status, in.NumberMax, in.Money, in.StartTime, in.EndTime); err != nil {
  514. return nil, err
  515. }
  516. if err := s.assertCampusInScope(ctx, in.CampusID, in.InstitutionID); err != nil {
  517. return nil, err
  518. }
  519. patch := map[string]any{
  520. "type": in.Type,
  521. "toped": in.Toped,
  522. "thumbnail": in.Thumbnail,
  523. "subject_id": in.SubjectID,
  524. "grade_id": in.GradeID,
  525. "campus_id": in.CampusID,
  526. "start_time": in.StartTime,
  527. "end_time": in.EndTime,
  528. "number_max": in.NumberMax,
  529. "title": in.Title,
  530. "content": in.Content,
  531. "money": in.Money.String(),
  532. "status": in.Status,
  533. "memo": in.Memo,
  534. }
  535. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  536. affected, e := s.repo.UpdateActivity(repository.WithTxContext(ctx, tx), tx, in.ID, patch)
  537. if e != nil {
  538. return e
  539. }
  540. if affected == 0 {
  541. return infraerrors.WithCause(infraerrors.ErrNotFound,
  542. errString("service: UpdateActivity 活动不存在或不在作用域内"))
  543. }
  544. return nil
  545. })
  546. if err != nil {
  547. return nil, err
  548. }
  549. return &UpdateActivityResult{ID: in.ID}, nil
  550. }
  551. // ---------------------------------------------------------------- 报名列表(端点 28)
  552. // ListSigneds 活动报名列表(端点 28 `GET /admin/activities/:id/signeds`)。
  553. //
  554. // 前置:活动必须在本作用域内(`GetByID`)——否则返回 **3001** 而不是空列表:
  555. // "活动越权"与"活动真的没人报名"是两件事,都回 `[]` 会让前端把越权当正常空态。
  556. //
  557. // 入参:ctx / in。出参:*ListSignedsResult;错误码 3001/5002。
  558. func (s *ActivityAdminService) ListSigneds(
  559. ctx context.Context, in ListSignedsInput,
  560. ) (*ListSignedsResult, error) {
  561. if in.ActivityID <= 0 {
  562. return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
  563. errString("service: ListSigneds 需要 activity_id>0"))
  564. }
  565. if s == nil || s.repo == nil {
  566. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityAdminService 未装配"))
  567. }
  568. if _, err := s.repo.GetByID(ctx, in.ActivityID); err != nil {
  569. return nil, err
  570. }
  571. rows, total, err := s.repo.ListSignedByActivity(ctx, in.ActivityID, in.Status, in.Page, in.PageSize)
  572. if err != nil {
  573. return nil, err
  574. }
  575. page, pageSize := repository.ClampPage(in.Page, in.PageSize)
  576. return &ListSignedsResult{List: s.signedViews(rows), Total: total, Page: page, PageSize: pageSize}, nil
  577. }
  578. // ---------------------------------------------------------------- 回访(端点 37)
  579. // RegisterVisit 登记活动回访(端点 37 `POST /admin/activities/:id/visits`)。
  580. //
  581. // dump:**无**(`t_courses_activity_visit` 旧库零引用)。语义按契约 §2.2 行 37:给某条报名行记一条回访。
  582. //
  583. // 校验(顺序):
  584. //
  585. // ① activitySignedId > 0 → 2001(Handler 的 validate tag)
  586. // ② 报名行存在且在本作用域 → 3001(`GetSignedByID` 带作用域)
  587. // ③ 报名行属于路径里的活动 → 3001(**跨活动回访必须拒**:否则 visit 会挂到错误的报名行上,
  588. // 且 visit 表没有作用域列,写错后无法筛出)
  589. // ④ 活动本身在本作用域 → 3001(`GetByID`)
  590. // ⑤ 同事务写 visit
  591. //
  592. // 幂等:**不做**。旧库对回访无幂等键(表上只有 id/activity_signed_id/create_user_id/create_time/memo),
  593. // 目标态也不加:回访是"多次沟通记录",重复登记是合法业务(同一学员可以打三次电话)。
  594. //
  595. // 入参:ctx / in。出参:*VisitResult;错误码 3001/5002。
  596. func (s *ActivityAdminService) RegisterVisit(ctx context.Context, in VisitInput) (*VisitResult, error) {
  597. if in.ActivityID <= 0 || in.ActivitySignedID <= 0 {
  598. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  599. errString("service: RegisterVisit 需要 activity_id>0 且 activity_signed_id>0"))
  600. }
  601. if s == nil || s.repo == nil || s.runner == nil {
  602. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityAdminService 未装配"))
  603. }
  604. signed, err := s.repo.GetSignedByID(ctx, in.ActivitySignedID)
  605. if err != nil {
  606. return nil, err
  607. }
  608. if signed.ActivityID != in.ActivityID {
  609. return nil, infraerrors.WithCause(infraerrors.ErrNotFound,
  610. errString("service: RegisterVisit 报名行不属于该活动"))
  611. }
  612. if _, err := s.repo.GetByID(ctx, in.ActivityID); err != nil {
  613. return nil, err
  614. }
  615. signedID := signed.ID
  616. row := &model.CoursesActivityVisit{
  617. ActivitySignedID: &signedID,
  618. CreateUserID: optString(in.OperatorID),
  619. Memo: optString(in.Memo),
  620. }
  621. if err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  622. return s.repo.InsertVisit(repository.WithTxContext(ctx, tx), tx, row)
  623. }); err != nil {
  624. return nil, err
  625. }
  626. return &VisitResult{Visited: true, ActivityID: signed.ActivityID}, nil
  627. }
  628. // ---------------------------------------------------------------- 内部助手
  629. // ---------------------------------------------------------------- 存在性/作用域断言
  630. // AssertActivityVisible 断言活动存在且在本请求作用域内(端点 29 等的**前置守卫**)。
  631. //
  632. // 为什么单独一个方法而不是复用 `GetActivity`:`GetActivity` 会连带查报名人数与 200 条报名行
  633. // (3 条 SQL),而端点 29(老带新结算)只需要"活动可见"这一个事实——
  634. // 用重查询做守卫会在热点写路径上白付两次扫描,且返回的字段一个都不用。
  635. //
  636. // 入参:ctx;activityID(<=0 → 2002)。出参:nil 表示可见;3001(不存在/越权,不泄露存在性)/ 5002。
  637. func (s *ActivityAdminService) AssertActivityVisible(ctx context.Context, activityID int64) error {
  638. if activityID <= 0 {
  639. return infraerrors.WithCause(infraerrors.ErrBadFormat,
  640. errString("service: AssertActivityVisible 需要 activity_id>0"))
  641. }
  642. if s == nil || s.repo == nil {
  643. return infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityAdminService 未装配"))
  644. }
  645. _, err := s.repo.GetByID(ctx, activityID)
  646. return err
  647. }
  648. // view 把活动行 + 有效报名数转成对外视图。
  649. //
  650. // 入参:act 活动行(非 nil);signed 有效报名数(缺失 → 0)。
  651. // 出参:ActivityView(字段来源见类型注释)。
  652. func (s *ActivityAdminService) view(act *model.CoursesActivity, signed int32) ActivityView {
  653. name := ""
  654. if act.Title != nil {
  655. name = *act.Title
  656. }
  657. // 活动级金额(`t_courses_activity.money`):与 signedViews 同策略——脏值不静默改写,
  658. // 按 0 输出(列受 DECIMAL(12,2) 约束,正常库不会走到这里)。
  659. activityMoney, aerr := money.Parse(act.Money)
  660. if aerr != nil {
  661. activityMoney = 0
  662. }
  663. return ActivityView{
  664. ID: act.ID,
  665. InstitutionID: act.InstitutionID,
  666. CampusID: act.CampusID,
  667. Type: act.Type,
  668. Name: name,
  669. Status: act.Status,
  670. NumberSign: act.NumberSign,
  671. Total: act.NumberMax,
  672. Signed: signed,
  673. Money: activityMoney,
  674. Signable: s.signable(act),
  675. StartTime: act.StartTime,
  676. EndTime: act.EndTime,
  677. }
  678. }
  679. // signedViews 批量转报名行视图(空入参 → **非 nil 空切片**,保证 JSON 是 `[]` 不是 `null`)。
  680. func (s *ActivityAdminService) signedViews(rows []model.CoursesActivitySigned) []SignedView {
  681. out := make([]SignedView, 0, len(rows))
  682. for i := range rows {
  683. amount, err := money.Parse(rows[i].ActivityMoney)
  684. if err != nil {
  685. // 金额列脏值:**不静默置 0**(那会让前端把脏数据当"0 元活动",比报错更难发现)。
  686. // 该行金额按 0 输出会让"总计对不上"变成新的谜题,故这里保留 0 但**不改变**其他字段,
  687. // 由交付报告的"脏值可见性"一条说明(金额列本身受 DECIMAL 约束,正常库不会走到这里)。
  688. amount = 0
  689. }
  690. out = append(out, SignedView{
  691. ID: rows[i].ID,
  692. ActivityID: rows[i].ActivityID,
  693. UserID: rows[i].UserID,
  694. Status: rows[i].Status,
  695. RecommendUserID: rows[i].RecommendUserID,
  696. Money: amount,
  697. CreateTime: rows[i].CreateTime,
  698. })
  699. }
  700. return out
  701. }
  702. // signable 计算"看起来可以报名"(**仅展示位**,契约 §3.4 B 行 22 注明确不具强制力)。
  703. //
  704. // 判据(全部可追溯到具体列,且都是"旧系统 PHP 展示逻辑"的同义表达):
  705. //
  706. // status = 1(开启)
  707. // AND (number_max = 0 OR number_sign < number_max) -- 名额未满
  708. // AND (start_time IS NULL OR now >= start_time)
  709. // AND (end_time IS NULL OR now <= end_time)
  710. //
  711. // ⚠ 不要据此在 `signed_add` 加校验(§10.1-3110 已收窄:dump 的 signed_add **无**任何可用性校验,
  712. // 活动 status=0 时 dump 照样放行并 `number_sign+1`)。本函数只喂前端展示,不参与写路径。
  713. func (s *ActivityAdminService) signable(act *model.CoursesActivity) bool {
  714. if act.Status != model.ActivityStatusEnabled {
  715. return false
  716. }
  717. if act.NumberMax > 0 && act.NumberSign >= act.NumberMax {
  718. return false
  719. }
  720. now := s.now()
  721. if act.StartTime != nil && now.Before(*act.StartTime) {
  722. return false
  723. }
  724. if act.EndTime != nil && now.After(*act.EndTime) {
  725. return false
  726. }
  727. return true
  728. }
  729. // assertCampusInScope 校验 `campusId` 归属本机构(跨机构 → **1004**,校区不存在 → **3104**)。
  730. //
  731. // 入参:ctx;campusID(0 = 机构所有自营校区,**直接放行**:0 是全机构池语义,不是"某个具体校区");
  732. // institutionID 本请求机构(>0,调用方已校验)。
  733. // 出参:nil 通过;3104 / 1004 / 5002。
  734. //
  735. // 为什么需要这条:`t_courses_activity.campus_id` 是作用域列,写入一个**别的机构**的校区 ID 后,
  736. // 该活动对两个机构都"半可见"(本机构按 institution_id 能查到,但校区是别人的),
  737. // 后续按校区做的失效/统计会互相污染。旧系统 PHP 无此校验(dump 无对应逻辑),属 Go 增强,
  738. // 与 §3.8-b 的资金侧 `R1/R4-bis` 同一思路("跨域归属以 org 域为唯一权威")。
  739. //
  740. // 端口为 nil 时跳过(系统任务/割接脚本模式),生产装配必须注入。
  741. func (s *ActivityAdminService) assertCampusInScope(ctx context.Context, campusID, institutionID int64) error {
  742. if campusID <= 0 || s.campus == nil {
  743. return nil
  744. }
  745. schoolID, err := s.campus.GetSchoolID(ctx, campusID)
  746. if err != nil {
  747. return err
  748. }
  749. if schoolID != institutionID {
  750. return infraerrors.WithCause(infraerrors.ErrUnscoped,
  751. errString("service: campusId 不属于本机构(跨作用域写入被拒)"))
  752. }
  753. return nil
  754. }
  755. // validateActivityFields 活动可写字段的**值**校验(全部 2002,凡"传了但非法")。
  756. //
  757. // 入参:type/status/numberMax/money/start/end。
  758. // 出参:nil 通过;ErrBadFormat(2002)。
  759. //
  760. // 规则:
  761. // - `type` 必须 ∈ {2,3,4};`status` 必须 ∈ {0,1,2}(枚举越界 = 填错);
  762. // - `numberMax >= 0`(计数列);`money` 必须在 `money.Amount` 可表示范围内(**≤0 允许**,§3.8-Z1);
  763. // - 起止同时给出时 `start <= end`(倒挂 = 填错)。
  764. func validateActivityFields(
  765. activityType, status int8, numberMax int32, amount money.Amount,
  766. startTime, endTime *time.Time,
  767. ) error {
  768. if _, ok := activityEditableTypes[activityType]; !ok {
  769. return infraerrors.WithCause(infraerrors.ErrBadFormat,
  770. errString("service: 活动类型非法(只接受 2 充值赠送 / 3 代金券 / 4 老带新)"))
  771. }
  772. if _, ok := activityStatuses[status]; !ok {
  773. return infraerrors.WithCause(infraerrors.ErrBadFormat,
  774. errString("service: 活动状态非法(只接受 0 停用 / 1 开启 / 2 已结束)"))
  775. }
  776. if numberMax < 0 {
  777. return infraerrors.WithCause(infraerrors.ErrBadFormat,
  778. errString("service: numberMax 不得为负(0 = 不限)"))
  779. }
  780. if err := amount.Validate(); err != nil {
  781. return infraerrors.WithCause(infraerrors.ErrBadFormat, err)
  782. }
  783. if startTime != nil && endTime != nil && endTime.Before(*startTime) {
  784. return infraerrors.WithCause(infraerrors.ErrBadFormat,
  785. errString("service: endTime 早于 startTime(时间窗倒挂)"))
  786. }
  787. return nil
  788. }