| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833 |
- // 活动管理服务(端点 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` 作为契约缺陷上报(「先报、不擅自造字段」),
- // 不在后端编一个"活动-<id>"之类的伪单号——伪单号会立刻被当作对账/幂等键使用。
- 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
- }
|