activity_admin_service.go 35 KB

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