// 课酬试算 + 维护型端点处理器(端点 59、61、62、63)——AC-13d 第一批。 // // 依据:unified/03-migration/academic-frontend-contract-v1.md §2.5 / §3.4 D 表; // 设计 `academic-domain-design.md` §4.5(角色白名单)/ §3.5(SP28/SP29)/ §3.6(SP30/SP31)/ // §4.6(`PayrollQuote` 9 字段,**不得裁掉溯源字段**)。 // // --------------------------------------------------------------------------- // 端点 → 服务方法对照 // --------------------------------------------------------------------------- // // 59 POST /admin/payroll/quote PayrollService.QuoteTeacherReward(AC-10,SP26) // 61 POST /admin/maintenance/courses-max-people MaintenanceService.BackfillCoursesMaxPeople(SP28) // 62 POST /admin/maintenance/courses-campus MaintenanceService.BackfillCoursesCampus(SP29) // 63 POST /admin/maintenance/activity-sync RebuildActivityUsedLog(SP30)/ ReplayActivitySigned(SP31) // // 本批**不含** 58(课酬方案 CRUD)与 60(收支字典/记录):契约把"方案+明细"、"字典+记录" // 各自压成一行,字段无法逐列溯源(详见 `dto_payroll.go` 文件头),已报偏差等裁决。 // // --------------------------------------------------------------------------- // 三条纪律(与 course_handler.go 同源) // --------------------------------------------------------------------------- // // 1. **61~63 仅 `super_admin`**:路由白名单是第一道(契约 §2.5),Service 层 `assertSuperAdmin` // 是第二道(AC-11 已交付)。**两层都要有**——白名单写错时第二层仍拦得住。 // 2. **维护端点是"全表级"动作**(SP30 会清 `used_log` 整表):Handler 不提供任何"部分执行"参数, // 也没有 dry-run;要重跑就再调一次(三个 SP 都幂等)。 // 3. 维护类不做作用域过滤(跨机构修数据是**目的**),因此**越权防线只有白名单 + 超管断言**两条。 package handler import ( "strconv" "strings" "github.com/gofiber/fiber/v2" "yuansiwei/unified-backend/internal/academic/service" infraerrors "yuansiwei/unified-backend/internal/infra/errors" "yuansiwei/unified-backend/internal/infra/response" ) // ---------------------------------------------------------------- 路径常量(**不含 `/api`**) const ( // PathPayrollQuote 端点 59:课酬试算(`POST /admin/payroll/quote`)。 PathPayrollQuote = "/admin/payroll/quote" // PathMaintenanceCoursesMaxPeople 端点 61:修复课程 max_people(SP28)。 PathMaintenanceCoursesMaxPeople = "/admin/maintenance/courses-max-people" // PathMaintenanceCoursesCampus 端点 62:修复课程 campus_id(SP29)。 PathMaintenanceCoursesCampus = "/admin/maintenance/courses-campus" // PathMaintenanceActivitySync 端点 63:活动数据同步(SP30/SP31)。 PathMaintenanceActivitySync = "/admin/maintenance/activity-sync" ) // activitySyncModeUsedLog 端点 63 的 mode 取值:重建 `t_courses_activity_used_log`(SP30)。 const activitySyncModeUsedLog = "used-log" // activitySyncModeSigned 端点 63 的 mode 取值:重放 `t_courses_activity_signed`(SP31)。 const activitySyncModeSigned = "signed" // QuotePayroll 课酬试算(端点 59 `POST /admin/payroll/quote`,SP26 dump 763600~763670)。 // // 入参(body):见 `PayrollQuoteReq`(六个字段全必填)。 // 出参(成功):`payrollQuoteView`(9 字段:4 业务列 + 4 溯源字段 + detailId)。 // 出参(失败):1001/2001(缺参/日期格式非法)/1004(schoolId 与作用域机构不一致)/ // **3112**(两级方案都没有 or 四级规则一条都没命中)/5002。 // // **与端点 51 的关系(重要)**:本端点返回的是**规则**(percentage/min/max/reward), // **不是最终课酬金额**——最终金额还需要 `income`/`studentCount`/`teacherRewardType`, // 契约没给这三个入参。二者共用同一 `computeRecordReward`(结课侧),本端点**不重复实现公式**, // 因此不会出现"试算一套、结课另一套"。 func (h *Handler) QuotePayroll(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } var req PayrollQuoteReq if !validateReq(c, &req) { return nil // body code=2001 已写出 } date, hasDate, derr := parseTimeParam(req.Date, "date") if derr != nil { return response.Fail(c, derr) } if !hasDate { // `validate:"required"` 已挡住空串;这里只兜"传了空白串被 trim 掉"的情形 return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("date 不能为空"))) } if req.TeacherExp < 0 { // dump 763609:负数按 0 兜底(不报错)。此处**不拒绝**,只归一,保持与旧系统一致。 req.TeacherExp = 0 } svc := h.servicesFor(sc) quote, err := svc.payroll.QuoteTeacherReward(c.Context(), service.QuotePayrollInput{ TeacherID: strings.TrimSpace(req.TeacherID), TeacherExp: req.TeacherExp, InstitutionID: req.SchoolID, CampusID: req.CampusID, ClassifyID: req.ClassifyID, Date: date, }) if err != nil { return response.Fail(c, err) // 2001/1004/3112/5002 } return response.OK(c, payrollQuoteView{ DetailID: quote.DetailID, SchemeID: quote.SchemeID, SchemeCampusID: quote.SchemeCampusID, SchemeScope: quote.SchemeScope, RuleTier: quote.RuleTier, Percentage: quote.Percentage, Min: quote.Min.String(), Max: quote.Max.String(), Reward: quote.Reward.String(), }) } // BackfillCoursesMaxPeople 修复课程 `max_people`(端点 61 `POST /admin/maintenance/courses-max-people`,SP28)。 // // 入参:无 body。 // 出参(成功):`{affected, message}`(**可重跑**:第二次 `affected=0`)。 // 出参(失败):1001/**1004**(非超管)/5002。 // // 幂等判据(AC-11 已实现):只处理 `max_people = 0` 的行;分类也没有值时**跳过不写 0** // (写 0 等于不改,却会让运维以为"已修复")。 func (h *Handler) BackfillCoursesMaxPeople(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } svc := h.servicesFor(sc) res, err := svc.maintenance.BackfillCoursesMaxPeople(c.Context()) if err != nil { return response.Fail(c, err) // 1004 非超管 / 5002 } msg := "已回填 " + strconv.FormatInt(int64(res.Filled), 10) + " 门课程的 max_people" if res.Filled == 0 { msg = "无待回填课程(可重跑:已是最新)" } if res.Skipped > 0 { msg += ";跳过 " + strconv.FormatInt(int64(res.Skipped), 10) + " 门(分类未配置,未写 0)" } return response.OK(c, MaintenanceResultResp{Affected: res.Affected, Message: msg}) } // BackfillCoursesCampus 修复课程 `campus_id`(端点 62 `POST /admin/maintenance/courses-campus`,SP29)。 // // 入参:无 body。 // 出参(成功):`{affected, message}`(**可重跑**)。 // 出参(失败):1001/**1004**(非超管)/5002。 // // **反直觉口径(必须保留)**:多校区课程(去重校区数 ≥ 2)**不回填、保持 0**("机构级课程"); // 无排班规则的课程(计数 = 0)同样不回填(0 ≠ 1)。 func (h *Handler) BackfillCoursesCampus(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } svc := h.servicesFor(sc) res, err := svc.maintenance.BackfillCoursesCampus(c.Context()) if err != nil { return response.Fail(c, err) } msg := "已回填 " + strconv.FormatInt(int64(res.Updated), 10) + " 门课程的 campus_id" if res.Updated == 0 { msg = "无待回填课程(可重跑:已是最新)" } if res.SkippedMultiCampus > 0 { msg += ";多校区课程 " + strconv.FormatInt(int64(res.SkippedMultiCampus), 10) + " 门保持 0(机构级,不填)" } if res.SkippedNoRule > 0 { msg += ";无排班规则 " + strconv.FormatInt(int64(res.SkippedNoRule), 10) + " 门未动" } return response.OK(c, MaintenanceResultResp{Affected: int64(res.Updated), Message: msg}) } // SyncActivityData 活动数据同步(端点 63 `POST /admin/maintenance/activity-sync`,SP30 / SP31)。 // // 入参(body):`mode` ∈ {`used-log`, `signed`};**缺省/非法 → 2001**(契约 §3.4 D 表原文)。 // 出参(成功):`{affected, message}`。 // 出参(失败):1001/2001(mode 缺失或非法)/**1004**(非超管)/5002。 // // 两个模式各自幂等: // // used-log → `RebuildActivityUsedLog`:**同事务 DELETE + 批量 INSERT**(不用 TRUNCATE —— DDL 隐式提交 // 会"清空成功、重建失败"时数据全丢,见 §3.8-BUG#3);重跑结果一致。 // signed → `ReplayActivitySigned`:只更新 `signed.status > 0` 的行(已取消报名不得被历史工单"复活")。 func (h *Handler) SyncActivityData(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } var req ActivitySyncReq if !validateReq(c, &req) { return nil // body code=2001 已写出 } mode := strings.TrimSpace(req.Mode) svc := h.servicesFor(sc) switch mode { case activitySyncModeUsedLog: res, serr := svc.maintenance.RebuildActivityUsedLog(c.Context()) if serr != nil { return response.Fail(c, serr) } msg := "used_log 已重建:" + strconv.FormatInt(res.Inserted, 10) + " 行(扫描 " + strconv.FormatInt(int64(res.Scanned), 10) + ",删除旧行 " + strconv.FormatInt(res.Deleted, 10) if res.Skipped > 0 { msg += ";跳过 " + strconv.FormatInt(int64(res.Skipped), 10) + " 行(活动/用户为空,目标态 NOT NULL 无法表达)" } return response.OK(c, MaintenanceResultResp{Affected: res.Inserted, Message: msg + ")"}) case activitySyncModeSigned: res, serr := svc.maintenance.ReplayActivitySigned(c.Context()) if serr != nil { return response.Fail(c, serr) } msg := "signed 已重放:更新 " + strconv.FormatInt(res.Updated, 10) + " 行(扫描 " + strconv.FormatInt(int64(res.Scanned), 10) + ")" if res.SkippedCanceled > 0 { msg += ";跳过 " + strconv.FormatInt(res.SkippedCanceled, 10) + " 行(已取消报名,不得复活)" } return response.OK(c, MaintenanceResultResp{Affected: res.Updated, Message: msg}) default: // 契约 §3.4 D 表:mode 缺省/非法 → **2001**(不是 2002)。 // 理由:这里的语义是"必须指定同步哪种数据",属**未提供必要参数**,与"提供了非法值"在 // 前端提示上是同一动作(去选一个模式),而契约明文写了 2001 ⇒ 按契约,不按 2002 通则。 return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("mode 必须是 used-log 或 signed(收到 "+mode+")"))) } }