payroll_handler.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490
  1. // 课酬试算 + 维护型端点处理器(端点 59、61、62、63)——AC-13d 第一批。
  2. //
  3. // 依据:unified/03-migration/academic-frontend-contract-v1.md §2.5 / §3.4 D 表;
  4. // 设计 `academic-domain-design.md` §4.5(角色白名单)/ §3.5(SP28/SP29)/ §3.6(SP30/SP31)/
  5. // §4.6(`PayrollQuote` 9 字段,**不得裁掉溯源字段**)。
  6. //
  7. // ---------------------------------------------------------------------------
  8. // 端点 → 服务方法对照
  9. // ---------------------------------------------------------------------------
  10. //
  11. // 59 POST /admin/payroll/quote PayrollService.QuoteTeacherReward(AC-10,SP26)
  12. // 61 POST /admin/maintenance/courses-max-people MaintenanceService.BackfillCoursesMaxPeople(SP28)
  13. // 62 POST /admin/maintenance/courses-campus MaintenanceService.BackfillCoursesCampus(SP29)
  14. // 63 POST /admin/maintenance/activity-sync RebuildActivityUsedLog(SP30)/ ReplayActivitySigned(SP31)
  15. //
  16. // 本批**不含** 58(课酬方案 CRUD)与 60(收支字典/记录):契约把"方案+明细"、"字典+记录"
  17. // 各自压成一行,字段无法逐列溯源(详见 `dto_payroll.go` 文件头),已报偏差等裁决。
  18. //
  19. // ---------------------------------------------------------------------------
  20. // 三条纪律(与 course_handler.go 同源)
  21. // ---------------------------------------------------------------------------
  22. //
  23. // 1. **61~63 仅 `super_admin`**:路由白名单是第一道(契约 §2.5),Service 层 `assertSuperAdmin`
  24. // 是第二道(AC-11 已交付)。**两层都要有**——白名单写错时第二层仍拦得住。
  25. // 2. **维护端点是"全表级"动作**(SP30 会清 `used_log` 整表):Handler 不提供任何"部分执行"参数,
  26. // 也没有 dry-run;要重跑就再调一次(三个 SP 都幂等)。
  27. // 3. 维护类不做作用域过滤(跨机构修数据是**目的**),因此**越权防线只有白名单 + 超管断言**两条。
  28. package handler
  29. import (
  30. "strconv"
  31. "strings"
  32. "github.com/gofiber/fiber/v2"
  33. "yuansiwei/unified-backend/internal/academic/service"
  34. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  35. "yuansiwei/unified-backend/internal/infra/response"
  36. )
  37. // ---------------------------------------------------------------- 路径常量(**不含 `/api`**)
  38. const (
  39. // PathPayrollPlans 端点 58:课酬方案列表 / 新建(`/admin/payroll/plans`)。
  40. PathPayrollPlans = "/admin/payroll/plans"
  41. // PathPayrollPlanDetail 端点 58:更新方案(`PUT /admin/payroll/plans/:id`,`:id` = **方案 id**)。
  42. PathPayrollPlanDetail = "/admin/payroll/plans/:id"
  43. // PathPayrollQuote 端点 59:课酬试算(`POST /admin/payroll/quote`)。
  44. PathPayrollQuote = "/admin/payroll/quote"
  45. // PathMaintenanceCoursesMaxPeople 端点 61:修复课程 max_people(SP28)。
  46. PathMaintenanceCoursesMaxPeople = "/admin/maintenance/courses-max-people"
  47. // PathMaintenanceCoursesCampus 端点 62:修复课程 campus_id(SP29)。
  48. PathMaintenanceCoursesCampus = "/admin/maintenance/courses-campus"
  49. // PathMaintenanceActivitySync 端点 63:活动数据同步(SP30/SP31)。
  50. PathMaintenanceActivitySync = "/admin/maintenance/activity-sync"
  51. )
  52. // activitySyncModeUsedLog 端点 63 的 mode 取值:重建 `t_courses_activity_used_log`(SP30)。
  53. const activitySyncModeUsedLog = "used-log"
  54. // activitySyncModeSigned 端点 63 的 mode 取值:重放 `t_courses_activity_signed`(SP31)。
  55. const activitySyncModeSigned = "signed"
  56. // ---------------------------------------------------------------- 端点 58:课酬方案
  57. // ListPayrollPlans 课酬方案列表(端点 58 `GET /admin/payroll/plans`)。
  58. //
  59. // 入参(query):`institutionId`(必填 >0)、`campusId?`、`actived?`(0/1)、`keyword?`、分页。
  60. // 出参(成功):`{list,total,page,pageSize,pages}`,行为**两级形状**(方案 + `details[]`)。
  61. // 出参(失败):1001/2001(缺 institutionId)/2002(campusId/actived 非法)/**1004**(跨机构)/5002。
  62. //
  63. // 为什么要显式 `institutionId`:`t_course_payment_detail` 没有机构列、`PayrollRepo` 又不走 `Scoped`
  64. // ⇒ 机构维度只能由调用方给出,并由 `assertInstitution` 校验(非超管必须等于作用域机构)。
  65. func (h *Handler) ListPayrollPlans(c *fiber.Ctx) error {
  66. sc, err := mustScope(c)
  67. if err != nil {
  68. return response.Fail(c, err)
  69. }
  70. institutionID, err := parseIntQuery(c, "institutionId")
  71. if err != nil {
  72. return response.Fail(c, err)
  73. }
  74. if institutionID <= 0 {
  75. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
  76. errString("institutionId 必填且必须 > 0")))
  77. }
  78. campusID, err := parseIntQuery(c, "campusId")
  79. if err != nil {
  80. return response.Fail(c, err)
  81. }
  82. if campusID < 0 {
  83. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
  84. errString("campusId 不得为负")))
  85. }
  86. var campusPtr *int64
  87. if strings.TrimSpace(c.Query("campusId")) != "" {
  88. v := campusID
  89. campusPtr = &v
  90. }
  91. activedFilter, _, err := parseEnumQuery(c, "actived", map[int64]struct{}{0: {}, 1: {}})
  92. if err != nil {
  93. return response.Fail(c, err)
  94. }
  95. var activedPtr *bool
  96. if activedFilter != nil {
  97. v := *activedFilter == 1
  98. activedPtr = &v
  99. }
  100. page, pageSize := parsePage(c)
  101. svc := h.servicesFor(sc)
  102. plans, total, err := svc.payrollPlan.ListPlans(c.Context(), service.PayrollPlanQuery{
  103. InstitutionID: institutionID,
  104. CampusID: campusPtr,
  105. Actived: activedPtr,
  106. Keyword: c.Query("keyword"),
  107. Page: page,
  108. PageSize: pageSize,
  109. })
  110. if err != nil {
  111. return response.Fail(c, err)
  112. }
  113. views := make([]payrollPlanView, 0, len(plans))
  114. for i := range plans {
  115. views = append(views, toPayrollPlanView(&plans[i]))
  116. }
  117. return response.Page(c, views, page, pageSize, total)
  118. }
  119. // CreatePayrollPlan 新建课酬方案(端点 58 `POST /admin/payroll/plans`)。
  120. //
  121. // 入参(body):见 `CreatePayrollPlanReq`(`schemeName` 必填,`details[]` 可空)。
  122. // 出参(成功):`payrollPlanView`(含新方案 id 与回读的明细)。
  123. // 出参(失败):1001/2001/2002(比例非 0~100、教龄区间倒挂、金额非法)/1004/5002。
  124. //
  125. // 事务:方案 + 全部明细在**同一事务**内写入(明细写失败 ⇒ 方案不落库,不会留下"空方案")。
  126. func (h *Handler) CreatePayrollPlan(c *fiber.Ctx) error {
  127. sc, err := mustScope(c)
  128. if err != nil {
  129. return response.Fail(c, err)
  130. }
  131. var req CreatePayrollPlanReq
  132. if !validateReq(c, &req) {
  133. return nil
  134. }
  135. institutionID, err := resolveInstitutionID(sc, req.InstitutionID)
  136. if err != nil {
  137. return response.Fail(c, err)
  138. }
  139. effectDate, hasEffect, err := parseTimeParam(req.EffectDate, "effectDate")
  140. if err != nil {
  141. return response.Fail(c, err)
  142. }
  143. details, ok := h.parsePayrollPlanDetails(c, req.Details)
  144. if !ok {
  145. return nil
  146. }
  147. in := service.CreatePlanInput{
  148. InstitutionID: institutionID,
  149. CampusID: req.CampusID,
  150. SchemeName: req.SchemeName,
  151. Actived: req.Actived,
  152. Details: details,
  153. OperatorID: sc.UserID,
  154. }
  155. if hasEffect {
  156. in.EffectDate = &effectDate
  157. }
  158. svc := h.servicesFor(sc)
  159. plan, err := svc.payrollPlan.CreatePlan(c.Context(), in)
  160. if err != nil {
  161. return response.Fail(c, err)
  162. }
  163. return response.OK(c, toPayrollPlanView(plan))
  164. }
  165. // UpdatePayrollPlan 更新课酬方案(端点 58 `PUT /admin/payroll/plans/:id`,`:id` = **方案 id**)。
  166. //
  167. // 入参(body):见 `UpdatePayrollPlanReq`(nil = 不改;`details` 给了就**整体替换**)。
  168. // 出参(成功):`payrollPlanView`(更新后回读)。
  169. // 出参(失败):1001/2001(没有任何待更新字段)/2002/3001/1004/5002。
  170. //
  171. // **整体替换明细**(D17 裁决):先按 `course_payment_id` 删除、再插入,同事务。
  172. // 不做逐条 diff —— 明细是**配置**不是流水,重写终态即可;diff 会引入"哪条被谁改了"的对账难题。
  173. func (h *Handler) UpdatePayrollPlan(c *fiber.Ctx) error {
  174. sc, err := mustScope(c)
  175. if err != nil {
  176. return response.Fail(c, err)
  177. }
  178. var req UpdatePayrollPlanReq
  179. if !validateReq(c, &req) {
  180. return nil
  181. }
  182. institutionID, err := resolveInstitutionID(sc, req.InstitutionID)
  183. if err != nil {
  184. return response.Fail(c, err)
  185. }
  186. in := service.UpdatePlanInput{
  187. ID: parseIDParam(c.Params("id")),
  188. InstitutionID: institutionID,
  189. CampusID: req.CampusID,
  190. SchemeName: req.SchemeName,
  191. Actived: req.Actived,
  192. }
  193. if req.EffectDate != nil {
  194. at, has, perr := parseTimeParam(*req.EffectDate, "effectDate")
  195. if perr != nil {
  196. return response.Fail(c, perr)
  197. }
  198. if has {
  199. in.EffectDate = &at
  200. }
  201. }
  202. if req.Details != nil {
  203. details, ok := h.parsePayrollPlanDetails(c, *req.Details)
  204. if !ok {
  205. return nil
  206. }
  207. in.Details = details
  208. in.ReplaceDetail = true // 给了 details(哪怕是 `[]`)= 整体替换 / 清空
  209. }
  210. svc := h.servicesFor(sc)
  211. plan, err := svc.payrollPlan.UpdatePlan(c.Context(), in)
  212. if err != nil {
  213. return response.Fail(c, err)
  214. }
  215. return response.OK(c, toPayrollPlanView(plan))
  216. }
  217. // parsePayrollPlanDetails 解析明细入参(金额字符串 → money.Amount;其余原样透传给 Service 校验)。
  218. //
  219. // 入参:c(仅用于写失败响应);raw 明细入参。出参:(服务入参, 是否成功);失败时 body code=2002。
  220. //
  221. // 金额用 `parseMoneyOrZero`(空串 = 0.00 = 不限制),**不用** `parseMoney`:
  222. // `min`/`max`/`reward` 都是可空字段,空串不等于"填错"。
  223. func (h *Handler) parsePayrollPlanDetails(c *fiber.Ctx, raw []PayrollPlanDetailReq) ([]service.PayrollPlanDetailInput, bool) {
  224. out := make([]service.PayrollPlanDetailInput, 0, len(raw))
  225. for i := range raw {
  226. item := &raw[i]
  227. minAmount, ok := parseMoneyOrZero(c, item.Min, "details.min")
  228. if !ok {
  229. return nil, false
  230. }
  231. maxAmount, ok := parseMoneyOrZero(c, item.Max, "details.max")
  232. if !ok {
  233. return nil, false
  234. }
  235. reward, ok := parseMoneyOrZero(c, item.Reward, "details.reward")
  236. if !ok {
  237. return nil, false
  238. }
  239. out = append(out, service.PayrollPlanDetailInput{
  240. TeacherID: strings.TrimSpace(item.TeacherID),
  241. ClassifyID: item.ClassifyID,
  242. ClassType: item.ClassType,
  243. GradeID: item.GradeID,
  244. SubjectID: item.SubjectID,
  245. TeacherCert: item.TeacherCert,
  246. TeacherExpMin: item.TeacherExpMin,
  247. TeacherExpMax: item.TeacherExpMax,
  248. Percentage: item.Percentage,
  249. Min: minAmount,
  250. Max: maxAmount,
  251. Reward: reward,
  252. DisplayName: item.DisplayName,
  253. Actived: item.DetailActived,
  254. })
  255. }
  256. return out, true
  257. }
  258. // toPayrollPlanView 把服务视图转成响应(金额两位小数字符串;明细空 → [])。
  259. func toPayrollPlanView(plan *service.PayrollPlan) payrollPlanView {
  260. details := make([]payrollPlanDetailView, 0, len(plan.Details))
  261. for i := range plan.Details {
  262. d := &plan.Details[i]
  263. details = append(details, payrollPlanDetailView{
  264. DetailID: d.DetailID,
  265. TeacherID: d.TeacherID, // 已是 *string:NULL 原样下发 null,不折叠成 ""
  266. ClassifyID: d.ClassifyID,
  267. ClassType: d.ClassType,
  268. GradeID: d.GradeID,
  269. SubjectID: d.SubjectID,
  270. TeacherCert: d.TeacherCert,
  271. TeacherExpMin: d.TeacherExpMin,
  272. TeacherExpMax: d.TeacherExpMax,
  273. Percentage: d.Percentage,
  274. Min: d.Min.String(),
  275. Max: d.Max.String(),
  276. Reward: d.Reward.String(),
  277. DisplayName: d.DisplayName,
  278. DetailActived: d.DetailActived,
  279. })
  280. }
  281. return payrollPlanView{
  282. ID: plan.ID,
  283. InstitutionID: plan.InstitutionID,
  284. CampusID: plan.CampusID,
  285. SchemeName: plan.SchemeName,
  286. EffectDate: plan.EffectDate,
  287. Actived: plan.Actived,
  288. Details: details,
  289. }
  290. }
  291. // QuotePayroll 课酬试算(端点 59 `POST /admin/payroll/quote`,SP26 dump 763600~763670)。
  292. //
  293. // 入参(body):见 `PayrollQuoteReq`(六个字段全必填)。
  294. // 出参(成功):`payrollQuoteView`(9 字段:4 业务列 + 4 溯源字段 + detailId)。
  295. // 出参(失败):1001/2001(缺参/日期格式非法)/1004(schoolId 与作用域机构不一致)/
  296. // **3112**(两级方案都没有 or 四级规则一条都没命中)/5002。
  297. //
  298. // **与端点 51 的关系(重要)**:本端点返回的是**规则**(percentage/min/max/reward),
  299. // **不是最终课酬金额**——最终金额还需要 `income`/`studentCount`/`teacherRewardType`,
  300. // 契约没给这三个入参。二者共用同一 `computeRecordReward`(结课侧),本端点**不重复实现公式**,
  301. // 因此不会出现"试算一套、结课另一套"。
  302. func (h *Handler) QuotePayroll(c *fiber.Ctx) error {
  303. sc, err := mustScope(c)
  304. if err != nil {
  305. return response.Fail(c, err)
  306. }
  307. var req PayrollQuoteReq
  308. if !validateReq(c, &req) {
  309. return nil // body code=2001 已写出
  310. }
  311. date, hasDate, derr := parseTimeParam(req.Date, "date")
  312. if derr != nil {
  313. return response.Fail(c, derr)
  314. }
  315. if !hasDate {
  316. // `validate:"required"` 已挡住空串;这里只兜"传了空白串被 trim 掉"的情形
  317. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
  318. errString("date 不能为空")))
  319. }
  320. if req.TeacherExp < 0 {
  321. // dump 763609:负数按 0 兜底(不报错)。此处**不拒绝**,只归一,保持与旧系统一致。
  322. req.TeacherExp = 0
  323. }
  324. svc := h.servicesFor(sc)
  325. quote, err := svc.payroll.QuoteTeacherReward(c.Context(), service.QuotePayrollInput{
  326. TeacherID: strings.TrimSpace(req.TeacherID),
  327. TeacherExp: req.TeacherExp,
  328. InstitutionID: req.SchoolID,
  329. CampusID: req.CampusID,
  330. ClassifyID: req.ClassifyID,
  331. Date: date,
  332. })
  333. if err != nil {
  334. return response.Fail(c, err) // 2001/1004/3112/5002
  335. }
  336. // 出口定点化:`percentage` 恒 1 位小数(`decimal(5,1)`)。不定点化的话 SQLite 回 "85"、
  337. // MySQL 回 "85.0",前端 diff 会把它当成"用户改过",且单测断言随数据库方言摇摆。
  338. percentage, perr := service.FormatPercentage(quote.Percentage)
  339. if perr != nil {
  340. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrDBError, perr))
  341. }
  342. return response.OK(c, payrollQuoteView{
  343. DetailID: quote.DetailID,
  344. SchemeID: quote.SchemeID,
  345. SchemeCampusID: quote.SchemeCampusID,
  346. SchemeScope: quote.SchemeScope,
  347. RuleTier: quote.RuleTier,
  348. Percentage: percentage,
  349. Min: quote.Min.String(),
  350. Max: quote.Max.String(),
  351. Reward: quote.Reward.String(),
  352. })
  353. }
  354. // BackfillCoursesMaxPeople 修复课程 `max_people`(端点 61 `POST /admin/maintenance/courses-max-people`,SP28)。
  355. //
  356. // 入参:无 body。
  357. // 出参(成功):`{affected, message}`(**可重跑**:第二次 `affected=0`)。
  358. // 出参(失败):1001/**1004**(非超管)/5002。
  359. //
  360. // 幂等判据(AC-11 已实现):只处理 `max_people = 0` 的行;分类也没有值时**跳过不写 0**
  361. // (写 0 等于不改,却会让运维以为"已修复")。
  362. func (h *Handler) BackfillCoursesMaxPeople(c *fiber.Ctx) error {
  363. sc, err := mustScope(c)
  364. if err != nil {
  365. return response.Fail(c, err)
  366. }
  367. svc := h.servicesFor(sc)
  368. res, err := svc.maintenance.BackfillCoursesMaxPeople(c.Context())
  369. if err != nil {
  370. return response.Fail(c, err) // 1004 非超管 / 5002
  371. }
  372. msg := "已回填 " + strconv.FormatInt(int64(res.Filled), 10) + " 门课程的 max_people"
  373. if res.Filled == 0 {
  374. msg = "无待回填课程(可重跑:已是最新)"
  375. }
  376. if res.Skipped > 0 {
  377. msg += ";跳过 " + strconv.FormatInt(int64(res.Skipped), 10) + " 门(分类未配置,未写 0)"
  378. }
  379. return response.OK(c, MaintenanceResultResp{Affected: res.Affected, Message: msg})
  380. }
  381. // BackfillCoursesCampus 修复课程 `campus_id`(端点 62 `POST /admin/maintenance/courses-campus`,SP29)。
  382. //
  383. // 入参:无 body。
  384. // 出参(成功):`{affected, message}`(**可重跑**)。
  385. // 出参(失败):1001/**1004**(非超管)/5002。
  386. //
  387. // **反直觉口径(必须保留)**:多校区课程(去重校区数 ≥ 2)**不回填、保持 0**("机构级课程");
  388. // 无排班规则的课程(计数 = 0)同样不回填(0 ≠ 1)。
  389. func (h *Handler) BackfillCoursesCampus(c *fiber.Ctx) error {
  390. sc, err := mustScope(c)
  391. if err != nil {
  392. return response.Fail(c, err)
  393. }
  394. svc := h.servicesFor(sc)
  395. res, err := svc.maintenance.BackfillCoursesCampus(c.Context())
  396. if err != nil {
  397. return response.Fail(c, err)
  398. }
  399. msg := "已回填 " + strconv.FormatInt(int64(res.Updated), 10) + " 门课程的 campus_id"
  400. if res.Updated == 0 {
  401. msg = "无待回填课程(可重跑:已是最新)"
  402. }
  403. if res.SkippedMultiCampus > 0 {
  404. msg += ";多校区课程 " + strconv.FormatInt(int64(res.SkippedMultiCampus), 10) + " 门保持 0(机构级,不填)"
  405. }
  406. if res.SkippedNoRule > 0 {
  407. msg += ";无排班规则 " + strconv.FormatInt(int64(res.SkippedNoRule), 10) + " 门未动"
  408. }
  409. return response.OK(c, MaintenanceResultResp{Affected: int64(res.Updated), Message: msg})
  410. }
  411. // SyncActivityData 活动数据同步(端点 63 `POST /admin/maintenance/activity-sync`,SP30 / SP31)。
  412. //
  413. // 入参(body):`mode` ∈ {`used-log`, `signed`};**缺失 → 2001;传了非法 → 2002**(团队裁决,与端点 25 一致)。
  414. // 出参(成功):`{affected, message}`。
  415. // 出参(失败):1001/2001(mode 缺失)/2002(mode 非法)/**1004**(非超管)/5002。
  416. //
  417. // 两个模式各自幂等:
  418. //
  419. // used-log → `RebuildActivityUsedLog`:**同事务 DELETE + 批量 INSERT**(不用 TRUNCATE —— DDL 隐式提交
  420. // 会"清空成功、重建失败"时数据全丢,见 §3.8-BUG#3);重跑结果一致。
  421. // signed → `ReplayActivitySigned`:只更新 `signed.status > 0` 的行(已取消报名不得被历史工单"复活")。
  422. func (h *Handler) SyncActivityData(c *fiber.Ctx) error {
  423. sc, err := mustScope(c)
  424. if err != nil {
  425. return response.Fail(c, err)
  426. }
  427. var req ActivitySyncReq
  428. if !validateReq(c, &req) {
  429. return nil // body code=2001 已写出
  430. }
  431. mode := strings.TrimSpace(req.Mode)
  432. if mode == "" {
  433. // **缺失 → 2001**(未提供必要参数)
  434. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
  435. errString("mode 不能为空(used-log 或 signed)")))
  436. }
  437. if mode != activitySyncModeUsedLog && mode != activitySyncModeSigned {
  438. // **传了但不在白名单 → 2002**(枚举越界;与端点 25 的 `numberSign` 同一口径)。
  439. // 与"缺失"分开的理由:前端提示不同("请选择同步模式" vs "取值非法"),且全局规则是
  440. // "枚举越界 = 2002"——同一错误类别不该在两个端点用不同码。
  441. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
  442. errString("mode 取值非法 "+mode+"(仅支持 used-log / signed)")))
  443. }
  444. svc := h.servicesFor(sc)
  445. switch mode {
  446. case activitySyncModeUsedLog:
  447. res, serr := svc.maintenance.RebuildActivityUsedLog(c.Context())
  448. if serr != nil {
  449. return response.Fail(c, serr)
  450. }
  451. msg := "used_log 已重建:" + strconv.FormatInt(res.Inserted, 10) + " 行(扫描 " + strconv.FormatInt(int64(res.Scanned), 10) +
  452. ",删除旧行 " + strconv.FormatInt(res.Deleted, 10)
  453. if res.Skipped > 0 {
  454. msg += ";跳过 " + strconv.FormatInt(int64(res.Skipped), 10) + " 行(活动/用户为空,目标态 NOT NULL 无法表达)"
  455. }
  456. return response.OK(c, MaintenanceResultResp{Affected: res.Inserted, Message: msg + ")"})
  457. case activitySyncModeSigned:
  458. res, serr := svc.maintenance.ReplayActivitySigned(c.Context())
  459. if serr != nil {
  460. return response.Fail(c, serr)
  461. }
  462. msg := "signed 已重放:更新 " + strconv.FormatInt(res.Updated, 10) + " 行(扫描 " + strconv.FormatInt(int64(res.Scanned), 10) + ")"
  463. if res.SkippedCanceled > 0 {
  464. msg += ";跳过 " + strconv.FormatInt(res.SkippedCanceled, 10) + " 行(已取消报名,不得复活)"
  465. }
  466. return response.OK(c, MaintenanceResultResp{Affected: res.Updated, Message: msg})
  467. default:
  468. // 不可达:上面已把"缺失/非法"分别拦成 2001/2002。保留兜底是为了让"新增 mode 却忘了
  469. // 加 case"变成显式错误,而不是静默落进某个分支(尤其不能静默走 used-log —— 那会清表)。
  470. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrInternal,
  471. errString("mode 分支未实现 "+mode)))
  472. }
  473. }