// 课酬试算 + 维护型端点处理器(端点 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 ( // PathPayrollPlans 端点 58:课酬方案列表 / 新建(`/admin/payroll/plans`)。 PathPayrollPlans = "/admin/payroll/plans" // PathPayrollPlanDetail 端点 58:更新方案(`PUT /admin/payroll/plans/:id`,`:id` = **方案 id**)。 PathPayrollPlanDetail = "/admin/payroll/plans/:id" // 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" // ---------------------------------------------------------------- 端点 58:课酬方案 // ListPayrollPlans 课酬方案列表(端点 58 `GET /admin/payroll/plans`)。 // // 入参(query):`institutionId`(必填 >0)、`campusId?`、`actived?`(0/1)、`keyword?`、分页。 // 出参(成功):`{list,total,page,pageSize,pages}`,行为**两级形状**(方案 + `details[]`)。 // 出参(失败):1001/2001(缺 institutionId)/2002(campusId/actived 非法)/**1004**(跨机构)/5002。 // // 为什么要显式 `institutionId`:`t_course_payment_detail` 没有机构列、`PayrollRepo` 又不走 `Scoped` // ⇒ 机构维度只能由调用方给出,并由 `assertInstitution` 校验(非超管必须等于作用域机构)。 func (h *Handler) ListPayrollPlans(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } institutionID, err := parseIntQuery(c, "institutionId") if err != nil { return response.Fail(c, err) } if institutionID <= 0 { return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("institutionId 必填且必须 > 0"))) } campusID, err := parseIntQuery(c, "campusId") if err != nil { return response.Fail(c, err) } if campusID < 0 { return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("campusId 不得为负"))) } var campusPtr *int64 if strings.TrimSpace(c.Query("campusId")) != "" { v := campusID campusPtr = &v } activedFilter, _, err := parseEnumQuery(c, "actived", map[int64]struct{}{0: {}, 1: {}}) if err != nil { return response.Fail(c, err) } var activedPtr *bool if activedFilter != nil { v := *activedFilter == 1 activedPtr = &v } page, pageSize := parsePage(c) svc := h.servicesFor(sc) plans, total, err := svc.payrollPlan.ListPlans(c.Context(), service.PayrollPlanQuery{ InstitutionID: institutionID, CampusID: campusPtr, Actived: activedPtr, Keyword: c.Query("keyword"), Page: page, PageSize: pageSize, }) if err != nil { return response.Fail(c, err) } views := make([]payrollPlanView, 0, len(plans)) for i := range plans { views = append(views, toPayrollPlanView(&plans[i])) } return response.Page(c, views, page, pageSize, total) } // CreatePayrollPlan 新建课酬方案(端点 58 `POST /admin/payroll/plans`)。 // // 入参(body):见 `CreatePayrollPlanReq`(`schemeName` 必填,`details[]` 可空)。 // 出参(成功):`payrollPlanView`(含新方案 id 与回读的明细)。 // 出参(失败):1001/2001/2002(比例非 0~100、教龄区间倒挂、金额非法)/1004/5002。 // // 事务:方案 + 全部明细在**同一事务**内写入(明细写失败 ⇒ 方案不落库,不会留下"空方案")。 func (h *Handler) CreatePayrollPlan(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } var req CreatePayrollPlanReq if !validateReq(c, &req) { return nil } institutionID, err := resolveInstitutionID(sc, req.InstitutionID) if err != nil { return response.Fail(c, err) } effectDate, hasEffect, err := parseTimeParam(req.EffectDate, "effectDate") if err != nil { return response.Fail(c, err) } details, ok := h.parsePayrollPlanDetails(c, req.Details) if !ok { return nil } in := service.CreatePlanInput{ InstitutionID: institutionID, CampusID: req.CampusID, SchemeName: req.SchemeName, Actived: req.Actived, Details: details, OperatorID: sc.UserID, } if hasEffect { in.EffectDate = &effectDate } svc := h.servicesFor(sc) plan, err := svc.payrollPlan.CreatePlan(c.Context(), in) if err != nil { return response.Fail(c, err) } return response.OK(c, toPayrollPlanView(plan)) } // UpdatePayrollPlan 更新课酬方案(端点 58 `PUT /admin/payroll/plans/:id`,`:id` = **方案 id**)。 // // 入参(body):见 `UpdatePayrollPlanReq`(nil = 不改;`details` 给了就**整体替换**)。 // 出参(成功):`payrollPlanView`(更新后回读)。 // 出参(失败):1001/2001(没有任何待更新字段)/2002/3001/1004/5002。 // // **整体替换明细**(D17 裁决):先按 `course_payment_id` 删除、再插入,同事务。 // 不做逐条 diff —— 明细是**配置**不是流水,重写终态即可;diff 会引入"哪条被谁改了"的对账难题。 func (h *Handler) UpdatePayrollPlan(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } var req UpdatePayrollPlanReq if !validateReq(c, &req) { return nil } institutionID, err := resolveInstitutionID(sc, req.InstitutionID) if err != nil { return response.Fail(c, err) } in := service.UpdatePlanInput{ ID: parseIDParam(c.Params("id")), InstitutionID: institutionID, CampusID: req.CampusID, SchemeName: req.SchemeName, Actived: req.Actived, } if req.EffectDate != nil { at, has, perr := parseTimeParam(*req.EffectDate, "effectDate") if perr != nil { return response.Fail(c, perr) } if has { in.EffectDate = &at } } if req.Details != nil { details, ok := h.parsePayrollPlanDetails(c, *req.Details) if !ok { return nil } in.Details = details in.ReplaceDetail = true // 给了 details(哪怕是 `[]`)= 整体替换 / 清空 } svc := h.servicesFor(sc) plan, err := svc.payrollPlan.UpdatePlan(c.Context(), in) if err != nil { return response.Fail(c, err) } return response.OK(c, toPayrollPlanView(plan)) } // parsePayrollPlanDetails 解析明细入参(金额字符串 → money.Amount;其余原样透传给 Service 校验)。 // // 入参:c(仅用于写失败响应);raw 明细入参。出参:(服务入参, 是否成功);失败时 body code=2002。 // // 金额用 `parseMoneyOrZero`(空串 = 0.00 = 不限制),**不用** `parseMoney`: // `min`/`max`/`reward` 都是可空字段,空串不等于"填错"。 func (h *Handler) parsePayrollPlanDetails(c *fiber.Ctx, raw []PayrollPlanDetailReq) ([]service.PayrollPlanDetailInput, bool) { out := make([]service.PayrollPlanDetailInput, 0, len(raw)) for i := range raw { item := &raw[i] minAmount, ok := parseMoneyOrZero(c, item.Min, "details.min") if !ok { return nil, false } maxAmount, ok := parseMoneyOrZero(c, item.Max, "details.max") if !ok { return nil, false } reward, ok := parseMoneyOrZero(c, item.Reward, "details.reward") if !ok { return nil, false } out = append(out, service.PayrollPlanDetailInput{ TeacherID: strings.TrimSpace(item.TeacherID), ClassifyID: item.ClassifyID, ClassType: item.ClassType, GradeID: item.GradeID, SubjectID: item.SubjectID, TeacherCert: item.TeacherCert, TeacherExpMin: item.TeacherExpMin, TeacherExpMax: item.TeacherExpMax, Percentage: item.Percentage, Min: minAmount, Max: maxAmount, Reward: reward, DisplayName: item.DisplayName, Actived: item.DetailActived, }) } return out, true } // toPayrollPlanView 把服务视图转成响应(金额两位小数字符串;明细空 → [])。 func toPayrollPlanView(plan *service.PayrollPlan) payrollPlanView { details := make([]payrollPlanDetailView, 0, len(plan.Details)) for i := range plan.Details { d := &plan.Details[i] details = append(details, payrollPlanDetailView{ DetailID: d.DetailID, TeacherID: d.TeacherID, // 已是 *string:NULL 原样下发 null,不折叠成 "" ClassifyID: d.ClassifyID, ClassType: d.ClassType, GradeID: d.GradeID, SubjectID: d.SubjectID, TeacherCert: d.TeacherCert, TeacherExpMin: d.TeacherExpMin, TeacherExpMax: d.TeacherExpMax, Percentage: d.Percentage, Min: d.Min.String(), Max: d.Max.String(), Reward: d.Reward.String(), DisplayName: d.DisplayName, DetailActived: d.DetailActived, }) } return payrollPlanView{ ID: plan.ID, InstitutionID: plan.InstitutionID, CampusID: plan.CampusID, SchemeName: plan.SchemeName, EffectDate: plan.EffectDate, Actived: plan.Actived, Details: details, } } // 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 } // 出口定点化:`percentage` 恒 1 位小数(`decimal(5,1)`)。不定点化的话 SQLite 回 "85"、 // MySQL 回 "85.0",前端 diff 会把它当成"用户改过",且单测断言随数据库方言摇摆。 percentage, perr := service.FormatPercentage(quote.Percentage) if perr != nil { return response.Fail(c, infraerrors.WithCause(infraerrors.ErrDBError, perr)) } return response.OK(c, payrollQuoteView{ DetailID: quote.DetailID, SchemeID: quote.SchemeID, SchemeCampusID: quote.SchemeCampusID, SchemeScope: quote.SchemeScope, RuleTier: quote.RuleTier, Percentage: 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;传了非法 → 2002**(团队裁决,与端点 25 一致)。 // 出参(成功):`{affected, message}`。 // 出参(失败):1001/2001(mode 缺失)/2002(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) if mode == "" { // **缺失 → 2001**(未提供必要参数) return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("mode 不能为空(used-log 或 signed)"))) } if mode != activitySyncModeUsedLog && mode != activitySyncModeSigned { // **传了但不在白名单 → 2002**(枚举越界;与端点 25 的 `numberSign` 同一口径)。 // 与"缺失"分开的理由:前端提示不同("请选择同步模式" vs "取值非法"),且全局规则是 // "枚举越界 = 2002"——同一错误类别不该在两个端点用不同码。 return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("mode 取值非法 "+mode+"(仅支持 used-log / signed)"))) } 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: // 不可达:上面已把"缺失/非法"分别拦成 2001/2002。保留兜底是为了让"新增 mode 却忘了 // 加 case"变成显式错误,而不是静默落进某个分支(尤其不能静默走 used-log —— 那会清表)。 return response.Fail(c, infraerrors.WithCause(infraerrors.ErrInternal, errString("mode 分支未实现 "+mode))) } }