// 活动管理服务(端点 22~25 / 28 / 37 的编排层)。 // // 依据: // - unified/03-migration/academic-frontend-contract-v1.md §2.2(活动 16 端点)/ §3.4 B(字段级定义 + 量纲); // - unified/03-migration/academic-domain-design.md §4.2(端点清单与角色)/ §1.7(作用域)/ // §10.1(错误码:2001 缺参 / 2002 格式 / 3001 不存在 / 1004 越权 / 3140 参数矛盾)/ // §10.3(事务边界只在 Service)/ §3.8-Z1(活动类金额 ≤0 **跳过**,不升级 2002)/ §3.8-R7(查不到 → 3001)。 // // --------------------------------------------------------------------------- // 与 `activity_service.go` 的分工(两者**都属活动域**,但职责不同) // --------------------------------------------------------------------------- // // activity_service.go 有 dump SP 对照的**资金/状态机**写路径(signed_add / signed_cancel) // activity_admin_service.go 无 dump SP 对照的**管理面**读写(活动 CRUD / 报名列表 / 回访) // // 分文件而不是塞一起:前者的每个分支都能指到 dump 行号,后者只能指到 DDL 列。混在一起后, // "这段逻辑对不对"就会变成"文档说对就对"——本文件因此坚持"每个字段写来源列、写不出来就不写"。 // // --------------------------------------------------------------------------- // 三条错误码纪律(本文件最容易写错的地方) // --------------------------------------------------------------------------- // // ① 2001(缺参)只在 Handler 由 `validate` tag 产生;Service 这里只处理**值**的问题。 // ② 2002 只用于「传了但非法」:枚举越界(type∉{2,3,4} / status∉{0,1,2})、金额不可表示、 // 时间窗倒挂。**不得**把"业务规则不满足"塞进来(那是专用 31xx 或 3140)。 // ③ 活动类金额 **≤0 一律不拒**(§3.8-Z1:dump 对活动侧金额只做 `max(x,0)` 与"≤0 跳过", // 从不报错;把 ≤0 升级成 2002 会在活动服务里造出 dump 不存在的拒绝分支)。因此 // `CreateActivity` 允许 `money<=0`(`Sign` 时由 `max(money,0)` 归一),只在超出 // `money.Amount` 可表示范围时才 2002。 package service import ( "context" "time" "gorm.io/gorm" "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" ) const ( // ActivityDetailSignedLimit 活动详情内嵌报名列表的条数上限(端点 24 的 `signeds[]`)。 // // 为什么设上限而不是"全量":详情是高频页面,热门活动报名可能上万行;全量下发会让 // 单次响应到 MB 级。取 200 与**分页上限同值**(`repository.MaxPageSize`),语义是 // "详情页看最近 200 条,要翻页用端点 28"——这条不是契约要求,属实现选择,已在交付报告登记。 ActivityDetailSignedLimit = 200 ) // activityEditableTypes 活动可写类型白名单(契约 §2.2 端点 23:「新建(type 2/3/4)」)。 // // 1(试听)是旧遗留(`model.ActivityTypeTrial` 注释:不参与 SP 逻辑)⇒ 新建不接受。 var activityEditableTypes = map[int8]struct{}{ model.ActivityTypeRecharge: {}, model.ActivityTypeVoucher: {}, model.ActivityTypeRecommend: {}, } // activityStatuses 活动状态白名单(`model.ActivityStatus*`:0 停用 / 1 开启 / 2 已结束)。 var activityStatuses = map[int8]struct{}{ model.ActivityStatusDisabled: {}, model.ActivityStatusEnabled: {}, model.ActivityStatusEnded: {}, } // ActivityAdminService 活动管理服务(端点 22~25 / 28 / 37)。 // // 字段: // - repo 活动/报名仓储(作用域在构造期绑定); // - runner 事务运行器(新建/更新/回访各一个事务); // - campus 校区只读端口(校验 `campusId` 是否归属本机构;可 nil:nil 时跳过错机构校验, // 仅限系统任务/割接脚本,生产装配**必须**注入); // - sc 请求作用域(机构/校区过滤的唯一来源,`CreateActivity` 的 `institution_id` 取自这里); // - now 时钟(`signable` 的时间窗判定;单测注入固定时刻,避免用例随日期变红)。 type ActivityAdminService struct { repo *repository.ActivityRepo runner *repository.TxRunner campus port.CampusReader sc *appscope.Scope now func() time.Time } // NewActivityAdminService 构造活动管理服务。 // // 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域)。 // 出参:*ActivityAdminService(校区端口用 `WithCampusReader` 注入)。 func NewActivityAdminService(db *gorm.DB, sc *appscope.Scope) *ActivityAdminService { return &ActivityAdminService{ repo: repository.NewActivityRepo(db, sc), runner: repository.NewTxRunner(db), sc: sc, now: time.Now, } } // WithCampusReader 注入校区只读端口(返自身,链式装配)。 // // 入参:reader port.CampusReader(可 nil)。 // 出参:*ActivityAdminService(同一个实例)。 func (s *ActivityAdminService) WithCampusReader(reader port.CampusReader) *ActivityAdminService { s.campus = reader return s } // WithClock 注入时钟(单测用;生产用 `time.Now`)。 // // 入参:now func() time.Time(nil → 忽略)。 // 出参:*ActivityAdminService(同一个实例)。 func (s *ActivityAdminService) WithClock(now func() time.Time) *ActivityAdminService { if now != nil { s.now = now } return s } // ---------------------------------------------------------------- 响应视图(字段名 = 契约 §3.4 B 逐字) // ActivityView 活动列表/详情行(契约 §3.4 B 行 22 / 24,**字段名与量纲逐字照抄**)。 // // 字段 → 来源列(右侧为契约要求的准入溯源,缺一列就不该有该字段): // // id ← t_courses_activity.id // institutionId ← t_courses_activity.institution_id // campusId ← t_courses_activity.campus_id // type ← t_courses_activity.type // name ← t_courses_activity.title (**纯改名**:契约定名 name,含义仍是"活动标题") // status ← t_courses_activity.status // numberSign ← t_courses_activity.number_sign // total ← t_courses_activity.number_max (**纯改名**:契约定名 total=名额上限) // signed ← COUNT(t_courses_activity_signed WHERE status<>0) (派生,判据同 dump 762928) // money ← t_courses_activity.money (**活动金额**:充值赠送额度 / 老带新奖励 / 特殊费用额) // signable ← status/number_max/number_sign/start_time/end_time 合成(派生,**仅展示位**) // startTime ← t_courses_activity.start_time // endTime ← t_courses_activity.end_time // // ⚠ **`money` 与 `signeds[].money` 不是一回事**:本字段是**活动级**(配置的活动金额, // 契约 §3.4 B 行 22 注),报名行里的是**报名级**(实际用掉的金额)。两者层级不同、勿混用。 // 归属依据:v1.6 契约补入本字段(表有 `money` 列且属活动主干属性)。 type ActivityView struct { // ID 活动 ID。 ID int64 `json:"id"` // InstitutionID 机构 ID。 InstitutionID int64 `json:"institutionId"` // CampusID 校区 ID(0 = 机构所有自营校区)。 CampusID int64 `json:"campusId"` // Type 活动类型(2 充值赠送 / 3 代金券 / 4 老带新)。 Type int8 `json:"type"` // Name 活动标题(← title)。 Name string `json:"name"` // Status 活动状态(0 停用 / 1 开启 / 2 已结束)。 Status int8 `json:"status"` // NumberSign 计数列 number_sign(报名 +1 / 取消 -1;**活动更新时不可写**)。 NumberSign int32 `json:"numberSign"` // Total 名额上限(← number_max;0 = 不限)。 Total int32 `json:"total"` // Signed 有效报名人数(`signed.status <> 0` 的行数)。 Signed int32 `json:"signed"` // Money 活动金额(← `money`;充值赠送额度 / 老带新奖励 / 特殊费用额)。 // **不是** `signeds[].money`(那是报名级实扣金额)。 Money money.Amount `json:"money"` // Signable 前端展示位:当前是否"看起来可以报名"(**不代表后端会拒绝**,见契约 §3.4 B 行 22 注)。 Signable bool `json:"signable"` // StartTime 活动开始时间(可空)。 StartTime *time.Time `json:"startTime"` // EndTime 活动结束时间(可空)。 EndTime *time.Time `json:"endTime"` } // SignedView 报名行视图(契约 §3.4 B 行 24 / 28 的 `signeds[]` 行结构)。 // // 字段 → 来源列: // // id ← t_courses_activity_signed.id // activityId ← t_courses_activity_signed.activity_id // userId ← t_courses_activity_signed.user_id // status ← t_courses_activity_signed.status(0 取消/1 报名/2 参加/3 报课/4 过期) // recommendUserId ← t_courses_activity_signed.recommend_user_id(NULL → null) // money ← t_courses_activity_signed.activity_money(**纯改名**:契约定名 money) // createTime ← t_courses_activity_signed.create_time(**业务时间**:报名时间,§1.5 B1 ①类) // // ⚠ `createTime` 在这里是**真实业务时间**(legacy 原地迁移列),与端点 12 的 // `createdAt`(迁移执行时刻的新加列,禁下发)不是一回事——两者源列不同,别类推。 type SignedView struct { // ID 报名行 ID。 ID int64 `json:"id"` // ActivityID 活动 ID。 ActivityID int64 `json:"activityId"` // UserID 报名用户(ULID)。 UserID string `json:"userId"` // Status 报名状态(0/1/2/3/4)。 Status int32 `json:"status"` // RecommendUserID 推荐人(可空 → null)。 RecommendUserID *string `json:"recommendUserId"` // Money 活动金额快照(← activity_money,分定点)。 Money money.Amount `json:"money"` // CreateTime 报名时间(业务时间)。 CreateTime time.Time `json:"createTime"` } // ListActivitiesResult 活动列表响应(`response.Page` 直接消费,分页五字段由它算定)。 type ListActivitiesResult struct { // List 当前页活动(**空 → `[]`,不是 nil**)。 List []ActivityView // Total 同过滤条件总数。 Total int64 // Page 当前页码(回显)。 Page int // PageSize 每页条数(回显,已钳制 ≤200)。 PageSize int } // ActivityDetailResult 活动详情响应(契约 §3.4 B 行 24:22 行结构 + `signeds[]`)。 // // 结构:内嵌 `ActivityView`(JSON 平铺,**不套 `activity` 层**)+ `signeds` 数组。 type ActivityDetailResult struct { // ActivityView 活动行字段(平铺)。 ActivityView // Signeds 报名行(最多 `ActivityDetailSignedLimit` 条,按 id 升序;空 → `[]`)。 Signeds []SignedView `json:"signeds"` } // ListSignedsResult 报名列表响应(契约 §3.4 B 行 28)。 type ListSignedsResult struct { // List 当前页报名行(空 → `[]`)。 List []SignedView // Total 同过滤条件总数。 Total int64 // Page 当前页码。 Page int // PageSize 每页条数(已钳制)。 PageSize int } // CreateActivityResult 新建活动响应(契约 §3.4 B 行 23)。 // // ⚠ 契约该行还要求 `bizNo:string`,但 `t_courses_activity` **没有 `biz_no` 列** // (目标态 DDL 第 242-268 行逐列核对:id/type/toped/thumbnail/subject_id/grade_id/institution_id/ // campus_id/start_time/end_time/number_max/number_sign/title/content/money/status/view/memo/ // create_user_id/create_time/updated_at)。按 §3.4 ③「字段准入条件」,**无列即不下发**: // 本结构体只回 `id`,`bizNo` 作为契约缺陷上报(「先报、不擅自造字段」), // 不在后端编一个"活动-"之类的伪单号——伪单号会立刻被当作对账/幂等键使用。 type CreateActivityResult struct { // ID 新活动 ID。 ID int64 `json:"id"` } // UpdateActivityResult 更新活动响应(契约 §3.4 B 行 25:`{id}`)。 type UpdateActivityResult struct { // ID 活动 ID。 ID int64 `json:"id"` } // VisitResult 回访登记响应(契约 §3.4 B 行 37:`{visited, activityId}`)。 type VisitResult struct { // Visited 是否已登记(成功恒 true)。 Visited bool `json:"visited"` // ActivityID 报名行所属活动 ID。 ActivityID int64 `json:"activityId"` } // ---------------------------------------------------------------- 入参 // ListActivitiesInput 活动列表入参(端点 22)。 // // 字段:Type/Status 已由 Handler 做白名单校验(非法 → 2002),此处只做"给了就筛"。 type ListActivitiesInput struct { // Type 活动类型(nil = 不筛)。 Type *int8 // Status 活动状态(nil = 不筛)。 Status *int8 // Keyword 标题关键字(空 = 不筛)。 Keyword string // Page 页码(<1 → 1)。 Page int // PageSize 每页条数(<1 → 10;>200 → 200)。 PageSize int } // CreateActivityInput 新建活动入参(端点 23)。 // // 字段来源:目标态 DDL 第 7 表的**可写列**(计数列 number_sign / view / create_time / updated_at // 不在此列——它们分别由报名、访问、DB 默认维护)。 type CreateActivityInput struct { // InstitutionID 机构 ID(Handler 从请求作用域填入;**不取 body**)。 InstitutionID int64 // CampusID 校区 ID(0 = 机构所有自营校区);>0 时校验归属本机构。 CampusID int64 // Type 活动类型(2/3/4;其它 → 2002)。 Type int8 // Title 活动标题(→ title)。 Title string // Content 活动内容(HTML;可空)。 Content string // Memo 备注(可空)。 Memo string // Thumbnail 缩略图 URL(可空)。 Thumbnail string // Money 活动金额/额度(分;**允许 ≤0**,见文件头纪律 ③)。 Money money.Amount // StartTime 开始时间(可空)。 StartTime *time.Time // EndTime 结束时间(可空;给了 StartTime 时不得早于它)。 EndTime *time.Time // NumberMax 名额上限(0 = 不限;负数 → 2002)。 NumberMax int32 // Status 活动状态(0/1/2;其它 → 2002)。 Status int8 // Toped 置顶(0/1)。 Toped int8 // SubjectID 科目(0 = 混合)。 SubjectID int32 // GradeID 年级(0 = 混合)。 GradeID int32 // OperatorID 操作人(→ create_user_id;空 → NULL)。 OperatorID string } // UpdateActivityInput 更新活动入参(端点 25,**PUT = 整体替换**)。 // // 语义(重要,避免"漏传字段被保留"的错觉):PUT 语义是**整体替换可编辑字段**, // 未传的可选字段(Content/Memo/Thumbnail)会被置空 —— 与 PATCH 的"只改传了的"不同。 // 这样写有两个理由:① 旧系统编辑页是一次提交全表单;② "漏传即保留"会让前端无法把 // 活动备注清空(清空 = 传空串,而空串与"没传"在 Go 结构体里不可区分,除非上指针, // 而指针化会让 20 个字段全部变成 `*T` 判空,读起来比 PUT 语义复杂得多)。 // // `NumberSign` **不在入参里**:契约 §3.4 B 行 25(v1.6)「`numberSign` 传即 2002」—— // 该判定在 Handler 侧完成(需要知道"传了没有",只有原始 JSON 层知道), // 到 Service 时已是"允许写"的状态。 type UpdateActivityInput struct { // ID 活动 ID(>0)。 ID int64 // InstitutionID 请求作用域机构(用于校验,**不参与更新**:机构归属不可变)。 InstitutionID int64 // CampusID 校区 ID(0 = 机构所有自营校区);>0 时校验归属本机构。 CampusID int64 // Type 活动类型(2/3/4;运行期改 type 属高危,这里**允许**改——旧系统允许,不做额外限制)。 Type int8 // Title 活动标题。 Title string // Content 活动内容。 Content string // Memo 备注。 Memo string // Thumbnail 缩略图。 Thumbnail string // Money 活动金额(分;≤0 允许)。 Money money.Amount // StartTime 开始时间(可空 → NULL)。 StartTime *time.Time // EndTime 结束时间(可空 → NULL)。 EndTime *time.Time // NumberMax 名额上限(0 = 不限)。 NumberMax int32 // Status 活动状态(0/1/2)。 Status int8 // Toped 置顶。 Toped int8 // SubjectID 科目。 SubjectID int32 // GradeID 年级。 GradeID int32 } // ListSignedsInput 报名列表入参(端点 28)。 type ListSignedsInput struct { // ActivityID 活动 ID(>0)。 ActivityID int64 // Status 报名状态过滤(nil = 不筛;白名单由 Handler 校验)。 Status *int32 // Page 页码。 Page int // PageSize 每页条数。 PageSize int } // VisitInput 回访登记入参(端点 37)。 type VisitInput struct { // ActivityID 路径里的活动 ID(>0)。 ActivityID int64 // ActivitySignedID 报名行 ID(>0)。 ActivitySignedID int64 // Memo 回访内容(必填,契约 §2.2 行 37 写的是 `activitySignedId,memo` 无问号)。 Memo string // OperatorID 回访人(→ create_user_id;来自作用域,不取 body)。 OperatorID string } // ---------------------------------------------------------------- 列表(端点 22) // ListActivities 活动分页列表(端点 22 `GET /admin/activities`)。 // // dump:**无**(PHP 直查)。派生字段 `signed` 的判据与 dump `762928` 的 3105 查重同口径 // (`status <> 0`),这样"列表显示的报名数"与"能否报名"不会互相打脸。 // // 入参:ctx / in。出参:*ListActivitiesResult;错误码 5002。 func (s *ActivityAdminService) ListActivities( ctx context.Context, in ListActivitiesInput, ) (*ListActivitiesResult, error) { if s == nil || s.repo == nil { return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityAdminService 未装配")) } rows, total, err := s.repo.ListActivities(ctx, repository.ActivityListFilter{ Type: in.Type, Status: in.Status, Keyword: in.Keyword, }, in.Page, in.PageSize) if err != nil { return nil, err } ids := make([]int64, 0, len(rows)) for i := range rows { ids = append(ids, rows[i].ID) } counts, err := s.repo.CountSignedByActivityIDs(ctx, ids) if err != nil { return nil, err } list := make([]ActivityView, 0, len(rows)) for i := range rows { list = append(list, s.view(&rows[i], counts[rows[i].ID])) } page, pageSize := repository.ClampPage(in.Page, in.PageSize) return &ListActivitiesResult{List: list, Total: total, Page: page, PageSize: pageSize}, nil } // ---------------------------------------------------------------- 详情(端点 24) // GetActivity 活动详情(端点 24 `GET /admin/activities/:id`)。 // // 入参:ctx;id 活动 ID(<=0 → 2002,由 Handler 的路径解析给出)。 // 出参:*ActivityDetailResult;错误码 3001(不存在或不在作用域内)/ 5002。 // // 作用域:`GetByID` 带机构+校区过滤 ⇒ 跨机构活动一律 **3001**(不泄露存在性)。 // 报名行同理(`ListSignedByActivity` 叠 signed 的冗余作用域列)。 func (s *ActivityAdminService) GetActivity(ctx context.Context, id int64) (*ActivityDetailResult, error) { if id <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("service: GetActivity 需要 id>0")) } if s == nil || s.repo == nil { return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityAdminService 未装配")) } act, err := s.repo.GetByID(ctx, id) if err != nil { return nil, err } counts, err := s.repo.CountSignedByActivityIDs(ctx, []int64{act.ID}) if err != nil { return nil, err } signedRows, _, err := s.repo.ListSignedByActivity(ctx, act.ID, nil, 1, ActivityDetailSignedLimit) if err != nil { return nil, err } return &ActivityDetailResult{ ActivityView: s.view(act, counts[act.ID]), Signeds: s.signedViews(signedRows), }, nil } // ---------------------------------------------------------------- 新建(端点 23) // CreateActivity 新建活动(端点 23 `POST /admin/activities`)。 // // 校验顺序(**先参数、后越权、再落库**): // // ① type ∈ {2,3,4} → 否则 2002(契约 §2.2 行 23) // ② status ∈ {0,1,2} → 否则 2002 // ③ numberMax >= 0 → 否则 2002(计数列不接受负值) // ④ money 可表示 → 否则 2002(≤0 **不拒**,§3.8-Z1) // ⑤ startTime <= endTime → 否则 2002(时间窗倒挂) // ⑥ campusId 归属本机构 → 校区不存在 3104 / 跨机构 **1004** // ⑦ 落库(number_sign 由仓储强制清零) // // 入参:ctx / in(`InstitutionID` 来自作用域)。出参:*CreateActivityResult;错误码 2002/3104/1004/5002。 func (s *ActivityAdminService) CreateActivity( ctx context.Context, in CreateActivityInput, ) (*CreateActivityResult, error) { if s == nil || s.repo == nil || s.runner == nil { return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityAdminService 未装配")) } if in.InstitutionID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: CreateActivity 缺少机构作用域(institution_id 不能为 0)")) } if err := validateActivityFields(in.Type, in.Status, in.NumberMax, in.Money, in.StartTime, in.EndTime); err != nil { return nil, err } if err := s.assertCampusInScope(ctx, in.CampusID, in.InstitutionID); err != nil { return nil, err } row := &model.CoursesActivity{ Type: in.Type, Toped: in.Toped, Thumbnail: optString(in.Thumbnail), SubjectID: in.SubjectID, GradeID: in.GradeID, InstitutionID: in.InstitutionID, CampusID: in.CampusID, StartTime: in.StartTime, EndTime: in.EndTime, NumberMax: in.NumberMax, NumberSign: 0, // 计数列:由仓储再强制清零一次(双保险) Title: optString(in.Title), Content: optString(in.Content), Money: in.Money.String(), Status: in.Status, View: 0, Memo: optString(in.Memo), CreateUserID: optString(in.OperatorID), } if err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error { return s.repo.CreateActivity(repository.WithTxContext(ctx, tx), tx, row) }); err != nil { return nil, err } return &CreateActivityResult{ID: row.ID}, nil } // ---------------------------------------------------------------- 更新(端点 25) // UpdateActivity 更新活动(端点 25 `PUT /admin/activities/:id`)。 // // 校验:与新建同(①~⑥),额外一条 —— 活动必须存在且在本作用域(否则 **3001**, // 由 `RowsAffected==0` 判定:`UpdateActivity` 的 WHERE 已带作用域)。 // // `number_sign` **不写**:契约 §3.4 B 行 25(v1.6)「`numberSign` 传即 2002」,该判定在 Handler // (只有原始 JSON 层能区分"没传"与"传了 0")。到本方法时 patch 里绝不含该列。 // // 入参:ctx / in。出参:*UpdateActivityResult;错误码 2002/3104/1004/3001/5002。 func (s *ActivityAdminService) UpdateActivity( ctx context.Context, in UpdateActivityInput, ) (*UpdateActivityResult, error) { if in.ID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("service: UpdateActivity 需要 id>0")) } if s == nil || s.repo == nil || s.runner == nil { return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityAdminService 未装配")) } if in.InstitutionID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: UpdateActivity 缺少机构作用域")) } if err := validateActivityFields(in.Type, in.Status, in.NumberMax, in.Money, in.StartTime, in.EndTime); err != nil { return nil, err } if err := s.assertCampusInScope(ctx, in.CampusID, in.InstitutionID); err != nil { return nil, err } patch := map[string]any{ "type": in.Type, "toped": in.Toped, "thumbnail": in.Thumbnail, "subject_id": in.SubjectID, "grade_id": in.GradeID, "campus_id": in.CampusID, "start_time": in.StartTime, "end_time": in.EndTime, "number_max": in.NumberMax, "title": in.Title, "content": in.Content, "money": in.Money.String(), "status": in.Status, "memo": in.Memo, } err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error { affected, e := s.repo.UpdateActivity(repository.WithTxContext(ctx, tx), tx, in.ID, patch) if e != nil { return e } if affected == 0 { return infraerrors.WithCause(infraerrors.ErrNotFound, errString("service: UpdateActivity 活动不存在或不在作用域内")) } return nil }) if err != nil { return nil, err } return &UpdateActivityResult{ID: in.ID}, nil } // ---------------------------------------------------------------- 报名列表(端点 28) // ListSigneds 活动报名列表(端点 28 `GET /admin/activities/:id/signeds`)。 // // 前置:活动必须在本作用域内(`GetByID`)——否则返回 **3001** 而不是空列表: // "活动越权"与"活动真的没人报名"是两件事,都回 `[]` 会让前端把越权当正常空态。 // // 入参:ctx / in。出参:*ListSignedsResult;错误码 3001/5002。 func (s *ActivityAdminService) ListSigneds( ctx context.Context, in ListSignedsInput, ) (*ListSignedsResult, error) { if in.ActivityID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("service: ListSigneds 需要 activity_id>0")) } if s == nil || s.repo == nil { return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityAdminService 未装配")) } if _, err := s.repo.GetByID(ctx, in.ActivityID); err != nil { return nil, err } rows, total, err := s.repo.ListSignedByActivity(ctx, in.ActivityID, in.Status, in.Page, in.PageSize) if err != nil { return nil, err } page, pageSize := repository.ClampPage(in.Page, in.PageSize) return &ListSignedsResult{List: s.signedViews(rows), Total: total, Page: page, PageSize: pageSize}, nil } // ---------------------------------------------------------------- 回访(端点 37) // RegisterVisit 登记活动回访(端点 37 `POST /admin/activities/:id/visits`)。 // // dump:**无**(`t_courses_activity_visit` 旧库零引用)。语义按契约 §2.2 行 37:给某条报名行记一条回访。 // // 校验(顺序): // // ① activitySignedId > 0 → 2001(Handler 的 validate tag) // ② 报名行存在且在本作用域 → 3001(`GetSignedByID` 带作用域) // ③ 报名行属于路径里的活动 → 3001(**跨活动回访必须拒**:否则 visit 会挂到错误的报名行上, // 且 visit 表没有作用域列,写错后无法筛出) // ④ 活动本身在本作用域 → 3001(`GetByID`) // ⑤ 同事务写 visit // // 幂等:**不做**。旧库对回访无幂等键(表上只有 id/activity_signed_id/create_user_id/create_time/memo), // 目标态也不加:回访是"多次沟通记录",重复登记是合法业务(同一学员可以打三次电话)。 // // 入参:ctx / in。出参:*VisitResult;错误码 3001/5002。 func (s *ActivityAdminService) RegisterVisit(ctx context.Context, in VisitInput) (*VisitResult, error) { if in.ActivityID <= 0 || in.ActivitySignedID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: RegisterVisit 需要 activity_id>0 且 activity_signed_id>0")) } if s == nil || s.repo == nil || s.runner == nil { return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityAdminService 未装配")) } signed, err := s.repo.GetSignedByID(ctx, in.ActivitySignedID) if err != nil { return nil, err } if signed.ActivityID != in.ActivityID { return nil, infraerrors.WithCause(infraerrors.ErrNotFound, errString("service: RegisterVisit 报名行不属于该活动")) } if _, err := s.repo.GetByID(ctx, in.ActivityID); err != nil { return nil, err } signedID := signed.ID row := &model.CoursesActivityVisit{ ActivitySignedID: &signedID, CreateUserID: optString(in.OperatorID), Memo: optString(in.Memo), } if err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error { return s.repo.InsertVisit(repository.WithTxContext(ctx, tx), tx, row) }); err != nil { return nil, err } return &VisitResult{Visited: true, ActivityID: signed.ActivityID}, nil } // ---------------------------------------------------------------- 内部助手 // ---------------------------------------------------------------- 存在性/作用域断言 // AssertActivityVisible 断言活动存在且在本请求作用域内(端点 29 等的**前置守卫**)。 // // 为什么单独一个方法而不是复用 `GetActivity`:`GetActivity` 会连带查报名人数与 200 条报名行 // (3 条 SQL),而端点 29(老带新结算)只需要"活动可见"这一个事实—— // 用重查询做守卫会在热点写路径上白付两次扫描,且返回的字段一个都不用。 // // 入参:ctx;activityID(<=0 → 2002)。出参:nil 表示可见;3001(不存在/越权,不泄露存在性)/ 5002。 func (s *ActivityAdminService) AssertActivityVisible(ctx context.Context, activityID int64) error { if activityID <= 0 { return infraerrors.WithCause(infraerrors.ErrBadFormat, errString("service: AssertActivityVisible 需要 activity_id>0")) } if s == nil || s.repo == nil { return infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityAdminService 未装配")) } _, err := s.repo.GetByID(ctx, activityID) return err } // view 把活动行 + 有效报名数转成对外视图。 // // 入参:act 活动行(非 nil);signed 有效报名数(缺失 → 0)。 // 出参:ActivityView(字段来源见类型注释)。 func (s *ActivityAdminService) view(act *model.CoursesActivity, signed int32) ActivityView { name := "" if act.Title != nil { name = *act.Title } // 活动级金额(`t_courses_activity.money`):与 signedViews 同策略——脏值不静默改写, // 按 0 输出(列受 DECIMAL(12,2) 约束,正常库不会走到这里)。 activityMoney, aerr := money.Parse(act.Money) if aerr != nil { activityMoney = 0 } return ActivityView{ ID: act.ID, InstitutionID: act.InstitutionID, CampusID: act.CampusID, Type: act.Type, Name: name, Status: act.Status, NumberSign: act.NumberSign, Total: act.NumberMax, Signed: signed, Money: activityMoney, Signable: s.signable(act), StartTime: act.StartTime, EndTime: act.EndTime, } } // signedViews 批量转报名行视图(空入参 → **非 nil 空切片**,保证 JSON 是 `[]` 不是 `null`)。 func (s *ActivityAdminService) signedViews(rows []model.CoursesActivitySigned) []SignedView { out := make([]SignedView, 0, len(rows)) for i := range rows { amount, err := money.Parse(rows[i].ActivityMoney) if err != nil { // 金额列脏值:**不静默置 0**(那会让前端把脏数据当"0 元活动",比报错更难发现)。 // 该行金额按 0 输出会让"总计对不上"变成新的谜题,故这里保留 0 但**不改变**其他字段, // 由交付报告的"脏值可见性"一条说明(金额列本身受 DECIMAL 约束,正常库不会走到这里)。 amount = 0 } out = append(out, SignedView{ ID: rows[i].ID, ActivityID: rows[i].ActivityID, UserID: rows[i].UserID, Status: rows[i].Status, RecommendUserID: rows[i].RecommendUserID, Money: amount, CreateTime: rows[i].CreateTime, }) } return out } // signable 计算"看起来可以报名"(**仅展示位**,契约 §3.4 B 行 22 注明确不具强制力)。 // // 判据(全部可追溯到具体列,且都是"旧系统 PHP 展示逻辑"的同义表达): // // status = 1(开启) // AND (number_max = 0 OR number_sign < number_max) -- 名额未满 // AND (start_time IS NULL OR now >= start_time) // AND (end_time IS NULL OR now <= end_time) // // ⚠ 不要据此在 `signed_add` 加校验(§10.1-3110 已收窄:dump 的 signed_add **无**任何可用性校验, // 活动 status=0 时 dump 照样放行并 `number_sign+1`)。本函数只喂前端展示,不参与写路径。 func (s *ActivityAdminService) signable(act *model.CoursesActivity) bool { if act.Status != model.ActivityStatusEnabled { return false } if act.NumberMax > 0 && act.NumberSign >= act.NumberMax { return false } now := s.now() if act.StartTime != nil && now.Before(*act.StartTime) { return false } if act.EndTime != nil && now.After(*act.EndTime) { return false } return true } // assertCampusInScope 校验 `campusId` 归属本机构(跨机构 → **1004**,校区不存在 → **3104**)。 // // 入参:ctx;campusID(0 = 机构所有自营校区,**直接放行**:0 是全机构池语义,不是"某个具体校区"); // institutionID 本请求机构(>0,调用方已校验)。 // 出参:nil 通过;3104 / 1004 / 5002。 // // 为什么需要这条:`t_courses_activity.campus_id` 是作用域列,写入一个**别的机构**的校区 ID 后, // 该活动对两个机构都"半可见"(本机构按 institution_id 能查到,但校区是别人的), // 后续按校区做的失效/统计会互相污染。旧系统 PHP 无此校验(dump 无对应逻辑),属 Go 增强, // 与 §3.8-b 的资金侧 `R1/R4-bis` 同一思路("跨域归属以 org 域为唯一权威")。 // // 端口为 nil 时跳过(系统任务/割接脚本模式),生产装配必须注入。 func (s *ActivityAdminService) assertCampusInScope(ctx context.Context, campusID, institutionID int64) error { if campusID <= 0 || s.campus == nil { return nil } schoolID, err := s.campus.GetSchoolID(ctx, campusID) if err != nil { return err } if schoolID != institutionID { return infraerrors.WithCause(infraerrors.ErrUnscoped, errString("service: campusId 不属于本机构(跨作用域写入被拒)")) } return nil } // validateActivityFields 活动可写字段的**值**校验(全部 2002,凡"传了但非法")。 // // 入参:type/status/numberMax/money/start/end。 // 出参:nil 通过;ErrBadFormat(2002)。 // // 规则: // - `type` 必须 ∈ {2,3,4};`status` 必须 ∈ {0,1,2}(枚举越界 = 填错); // - `numberMax >= 0`(计数列);`money` 必须在 `money.Amount` 可表示范围内(**≤0 允许**,§3.8-Z1); // - 起止同时给出时 `start <= end`(倒挂 = 填错)。 func validateActivityFields( activityType, status int8, numberMax int32, amount money.Amount, startTime, endTime *time.Time, ) error { if _, ok := activityEditableTypes[activityType]; !ok { return infraerrors.WithCause(infraerrors.ErrBadFormat, errString("service: 活动类型非法(只接受 2 充值赠送 / 3 代金券 / 4 老带新)")) } if _, ok := activityStatuses[status]; !ok { return infraerrors.WithCause(infraerrors.ErrBadFormat, errString("service: 活动状态非法(只接受 0 停用 / 1 开启 / 2 已结束)")) } if numberMax < 0 { return infraerrors.WithCause(infraerrors.ErrBadFormat, errString("service: numberMax 不得为负(0 = 不限)")) } if err := amount.Validate(); err != nil { return infraerrors.WithCause(infraerrors.ErrBadFormat, err) } if startTime != nil && endTime != nil && endTime.Before(*startTime) { return infraerrors.WithCause(infraerrors.ErrBadFormat, errString("service: endTime 早于 startTime(时间窗倒挂)")) } return nil }