Bladeren bron

fix(academic): AC-13b 回执——端点25 错误码 3140→2002 + 端点22/24 补活动级 money

按契约 v1.6 的两处裁决落地:

1) 端点 25(PUT /admin/activities/:id):`numberSign` 传即 **2002**,不再是 3140。
   依据设计 §10.1:3140 只收「两个及以上参数互相矛盾」;"传了不可写字段"属单一
   参数取值非法 ⇒ 2002(`infraerrors.ErrBadFormat`)。若塞进 3140,3140 会退化成
   垃圾桶(禁令见 §10.1 v0.1.19)。连带把 4 处注释/用例名从 3140 更正为 2002,
   并移除因此变为未使用的 `apperr` 导入。

2) 端点 22/24(活动列表/详情):补活动级 `money`(← `t_courses_activity.money`),
   两位小数字符串。契约 v1.6 新增该字段,判定依据 = 「有列 + 属主干属性 → 加入」,
   与先前拒绝的 `campusAccountExists`(无列 + 纯派生)区分。
   注释中写明 **活动级 money ≠ signeds[].money**(前者是配置额度、后者是报名实扣)。

用例守护(都会在改错时变红):
- 端点25:断言 2002 且 number_sign 一字不动(2);把错误码改回 3140 即红。
- 端点22:`ActivityViewForTest.Money` 声明为 string ⇒ 后端若下发 number 则 unmarshal
  失败;另用「无报名的停用活动 102」断言 money=0.00,防止拿 signeds[].money 顶上。

gofmt 零差异;go build/vet 干净;academic 7/7 包全绿(handler 25.7s / service 96.6s /
repository 16.4s)。
Q 18 uur geleden
bovenliggende
commit
e5c21fff15

+ 9 - 8
internal/academic/handler/activity_handler.go

@@ -63,7 +63,6 @@ import (
 
 	"github.com/gofiber/fiber/v2"
 
-	"yuansiwei/unified-backend/internal/academic/apperr"
 	"yuansiwei/unified-backend/internal/academic/money"
 	"yuansiwei/unified-backend/internal/academic/service"
 	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
@@ -255,11 +254,12 @@ func (h *Handler) GetActivity(c *fiber.Ctx) error {
 //
 // 入参(body):见 `UpdateActivityReq`。
 // 出参(成功):`{id}`。
-// 出参(失败):2001/2002(含枚举越界、时间窗倒挂)/3001(活动不存在/越权)/3104/1004/**3140**。
+// 出参(失败):2001/2002(含枚举越界、时间窗倒挂、传 numberSign)/3001(活动不存在/越权)/3104/1004。
 //
-// **3140 的唯一触发点**:body 里出现 `numberSign`(契约 §3.4 B 行 25「`numberSign` 传即 3140」)。
-// 注意这里是**契约原话**,与设计 §10.1「3140 仅收两参数互相矛盾」的口径**不完全一致**——
-// 按"契约是前端唯一对接依据"取契约,已在交付报告登记该口径冲突(不是实现自由裁量)。
+// **`numberSign` 传即 2002(不是 3140)**:契约 §3.4 B 行 25 已改为 2002(v1.6)。
+// 判据 = 设计 §10.1「**3140 仅收两个及以上参数互相矛盾**;单一参数非法 → 2001/2002」——
+// "传了一个不可写字段"属**单一参数取值非法**,归 2002;若塞进 3140,3140 会退化成垃圾桶
+// (该禁令见设计 §10.1 v0.1.19)。
 func (h *Handler) UpdateActivity(c *fiber.Ctx) error {
 	sc, err := mustScope(c)
 	if err != nil {
@@ -270,9 +270,10 @@ func (h *Handler) UpdateActivity(c *fiber.Ctx) error {
 		return nil
 	}
 	if req.NumberSign != nil {
-		// 3140(`apperr.ErrBusinessParam`):契约 §3.4 B 行 25 原文「`numberSign` 传即 3140」。
-		// 引用常量而非数字,理由见 `internal/academic/apperr/codes.go` 包注释(31xx 唯一定义点)。
-		return response.Fail(c, infraerrors.WithCause(apperr.ErrBusinessParam,
+		// 2002(`infraerrors.ErrBadFormat`):契约 §3.4 B 行 25(v1.6 起为 2002)。
+		// 为什么不是 3140:设计 §10.1 限定「3140 只收参数之间互相矛盾」,而这里是
+		// **单一字段不可写** ⇒ 属取值非法,归 2002。
+		return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
 			errString("numberSign 是报名计数列,不允许通过活动更新写入")))
 	}
 	institutionID, err := resolveInstitutionID(sc, req.InstitutionID)

+ 28 - 9
internal/academic/handler/activity_handler_test.go

@@ -12,7 +12,7 @@
 //	③ 新建:2002/3104/1004/成功   → TestCreateActivityValidationAndCrossCampus
 //	④ 详情:3001/字段映射/空数组   → TestActivityDetailScopeAndShape
 //	⑤ 报名列表:2002/空列表       → TestListSignedsInvalidStatusIs2002
-//	⑥ 更新:3140(numberSign)/3001  → TestUpdateActivityNumberSignIs3140AndCrossScopeIs3001
+//	⑥ 更新:2002(numberSign)/3001  → TestUpdateActivityNumberSignIs2002AndCrossScopeIs3001
 //	⑦ 报名/取消:3105/跨表三列归零 → TestSignThenCancelCrossTableEffects
 //	⑧ 取消:3107 审核态零副作用    → TestCancelSignAuditedIs3107WithZeroSideEffects
 //	⑨ 取消:3121 type 读不到       → TestCancelSignUnresolvableTypeIs3121
@@ -296,6 +296,18 @@ func TestListActivitiesScopesAndDerives(t *testing.T) {
 		t.Fatalf("signed 应为 2(status<>0),实得 %d——"+
 			"若为 3 说明把 status=0(已取消)也统计进来了", first.Signed)
 	}
+	// 活动级 money(v1.6 契约补入):来自 `t_courses_activity.money`,两位小数字符串。
+	// 会变红的条件:① DTO 漏掉 Money 字段 ⇒ 得 "";② 用 float 输出 ⇒ 声明为 string 此处 unmarshal 失败;
+	// ③ 拿 signeds[].money 顶上 ⇒ 值可能仍是 100.00,故另用活动 102(money=0.00、无报名)区分层级。
+	if first.Money != "100.00" {
+		t.Fatalf("活动级 money 应为 \"100.00\"(← t_courses_activity.money),实得 %q", first.Money)
+	}
+	if stopped, ok := byID[102]; ok {
+		if stopped.Money != "0.00" {
+			t.Fatalf("无报名的停用活动 money 应为 \"0.00\"(活动级、与报名无关),实得 %q——"+
+				"若非 0.00 说明把 signeds[].money 当成了活动金额", stopped.Money)
+		}
+	}
 	if !first.Signable {
 		t.Fatalf("活动 101 应为 signable(status=1、未满员、无时间窗约束)")
 	}
@@ -316,8 +328,11 @@ type ActivityViewForTest struct {
 	Total      int32  `json:"total"`
 	NumberSign int32  `json:"numberSign"`
 	Signed     int32  `json:"signed"`
-	Signable   bool   `json:"signable"`
-	Type       int8   `json:"type"`
+	// Money 声明为 **string**:若后端下发数值(number)会在此处 unmarshal 失败 ⇒
+	// "金额必须是两位小数字符串"这条不用额外断言也守住了。
+	Money    string `json:"money"`
+	Signable bool   `json:"signable"`
+	Type     int8   `json:"type"`
 }
 
 // TestListActivitiesInvalidEnumsAre2002 type/status 过滤枚举越界 → **2002**(不是 2001、不是静默忽略)。
@@ -505,12 +520,16 @@ func TestListSignedsInvalidStatusIs2002(t *testing.T) {
 
 // ---------------------------------------------------------------- ⑥ 更新
 
-// TestUpdateActivityNumberSignIs3140AndCrossScopeIs3001 更新:`numberSign` 传即 3140;跨机构 3001。
+// TestUpdateActivityNumberSignIs2002AndCrossScopeIs3001 更新:`numberSign` 传即 2002;跨机构 3001。
+//
+// 为什么是 2002 不是 3140(v1.6 契约更正):设计 §10.1 限定「3140 只收**参数之间**互相矛盾」;
+// "传了一个不可写字段"属**单一字段取值非法** ⇒ 2002。3140 若被塞这类判断,会退化成垃圾桶。
 //
 // 会变红的条件:
-//   - 把 `req.NumberSign` 判断删掉(或用值类型接)⇒ 3140 变 200,且 DB 里 number_sign 被改写 → 红;
+//   - 把 `req.NumberSign` 判断删掉(或用值类型接)⇒ 2002 变 200,且 DB 里 number_sign 被改写 → 红;
+//   - 把错误码改回 3140 ⇒ 实得 3140 ≠ 2002 → 红(守住"契约 v1.6 的这一处更正");
 //   - 去掉 `UpdateActivity` 的 `RowsAffected==0` 守卫 ⇒ 更新别家机构活动返回 200 → 红。
-func TestUpdateActivityNumberSignIs3140AndCrossScopeIs3001(t *testing.T) {
+func TestUpdateActivityNumberSignIs2002AndCrossScopeIs3001(t *testing.T) {
 	db := newHandlerTestDB(t)
 	app := newActivityTestApp(t, db)
 	seedActivityRow(t, db, model.CoursesActivity{
@@ -524,11 +543,11 @@ func TestUpdateActivityNumberSignIs3140AndCrossScopeIs3001(t *testing.T) {
 
 	body := map[string]any{"type": 2, "title": "改标题", "money": "10.00", "status": 1, "numberSign": 7}
 	code, msg, _ := doJSON(t, app, "PUT", "/admin/activities/401", body)
-	if code != 3140 {
-		t.Fatalf("numberSign 传即 3140(契约 §3.4 B 行 25),实得 %d(%s)", code, msg)
+	if code != 2002 {
+		t.Fatalf("numberSign 传即 2002(契约 §3.4 B 行 25,v1.6),实得 %d(%s)", code, msg)
 	}
 	if got := readActivityByID(t, db, 401).NumberSign; got != 2 {
-		t.Fatalf("3140 被拒后 number_sign 必须一字不动(2),实得 %d", got)
+		t.Fatalf("2002 被拒后 number_sign 必须一字不动(2),实得 %d", got)
 	}
 
 	// 去掉 numberSign 后正常更新

+ 3 - 3
internal/academic/handler/dto_activity.go

@@ -15,7 +15,7 @@
 //  2. **缺参 → 2001 由 `validate` tag 表达;值非法 → 2002 由 Handler 显式判**。
 //     因此**枚举字段不写 `oneof=`**(`oneof` 失败会被 `infra/validator` 归成 2001,
 //     而"传了非法枚举"按设计 §10.1 属 2002)。枚举白名单一律在 Handler 里判。
-//  3. **可选与必填要能区分**:`numberSign` 用 `*int32`(契约 §3.4 B 行 25「传即 3140」,
+//  3. **可选与必填要能区分**:`numberSign` 用 `*int32`(契约 §3.4 B 行 25(v1.6)「传即 2002」,
 //     "传了 0"与"没传"必须区分开,只有指针能表达);其它可选字段用值类型 + 空串/0 语义。
 //
 // 命名:camelCase JSON(与契约逐字一致);Go 侧 ID 后缀全大写。
@@ -71,7 +71,7 @@ type CreateActivityReq struct {
 // UpdateActivityReq 端点 25 更新活动请求体(`PUT /admin/activities/:id`,**整体替换**语义)。
 //
 // 与 `CreateActivityReq` 的差异只有两处:
-//   - `NumberSign *int32`:**只要出现即 3140**(契约 §3.4 B 行 25)。用指针是为了区分
+//   - `NumberSign *int32`:**只要出现即 2002**(契约 §3.4 B 行 25,v1.6)。用指针是为了区分
 //     "没传"与"传了 0"——`{"numberSign":0}` 同样是调用方试图写计数列,必须拒;
 //   - 字段必填性更松:`title` 仍必填(整体替换后不能没有标题),其余同新建。
 type UpdateActivityReq struct {
@@ -105,7 +105,7 @@ type UpdateActivityReq struct {
 	SubjectID int32 `json:"subjectId"`
 	// GradeID 年级。
 	GradeID int32 `json:"gradeId"`
-	// NumberSign **禁写列**:非 nil 即 3140("传了"的判据,见类型注释)。
+	// NumberSign **禁写列**:非 nil 即 2002("传了"的判据,见类型注释)。
 	NumberSign *int32 `json:"numberSign"`
 }
 

+ 16 - 6
internal/academic/service/activity_admin_service.go

@@ -133,14 +133,14 @@ func (s *ActivityAdminService) WithClock(now func() time.Time) *ActivityAdminSer
 //	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`**(活动额度)。本结构体因此也**不下发**金额:按 §3.4 ③「找不到列
-// 的一律不进契约」的对偶纪律,**也不能因为"表里有列"就自行加字段**——契约是冻结件,
-// 加字段会让"前端 mock 与实际响应不一致"重新变成不可预期状态。已在交付报告里作为
-// 「契约缺口(非缺陷)」上报,等契约升版后再加。
+// ⚠ **`money` 与 `signeds[].money` 不是一回事**:本字段是**活动级**(配置的活动金额,
+// 契约 §3.4 B 行 22 注),报名行里的是**报名级**(实际用掉的金额)。两者层级不同、勿混用。
+// 归属依据:v1.6 契约补入本字段(表有 `money` 列且属活动主干属性)。
 type ActivityView struct {
 	// ID 活动 ID。
 	ID int64 `json:"id"`
@@ -160,6 +160,9 @@ type ActivityView struct {
 	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 活动开始时间(可空)。
@@ -325,7 +328,7 @@ type CreateActivityInput struct {
 // 活动备注清空(清空 = 传空串,而空串与"没传"在 Go 结构体里不可区分,除非上指针,
 // 而指针化会让 20 个字段全部变成 `*T` 判空,读起来比 PUT 语义复杂得多)。
 //
-// `NumberSign` **不在入参里**:契约 §3.4 B 行 25 明确「`numberSign` 传即 3140」——
+// `NumberSign` **不在入参里**:契约 §3.4 B 行 25(v1.6)「`numberSign` 传即 2002」——
 // 该判定在 Handler 侧完成(需要知道"传了没有",只有原始 JSON 层知道),
 // 到 Service 时已是"允许写"的状态。
 type UpdateActivityInput struct {
@@ -523,7 +526,7 @@ func (s *ActivityAdminService) CreateActivity(
 // 校验:与新建同(①~⑥),额外一条 —— 活动必须存在且在本作用域(否则 **3001**,
 // 由 `RowsAffected==0` 判定:`UpdateActivity` 的 WHERE 已带作用域)。
 //
-// `number_sign` **不写**:契约 §3.4 B 行 25「`numberSign` 传即 3140」,该判定在 Handler
+// `number_sign` **不写**:契约 §3.4 B 行 25(v1.6)「`numberSign` 传即 2002」,该判定在 Handler
 // (只有原始 JSON 层能区分"没传"与"传了 0")。到本方法时 patch 里绝不含该列。
 //
 // 入参:ctx / in。出参:*UpdateActivityResult;错误码 2002/3104/1004/3001/5002。
@@ -692,6 +695,12 @@ func (s *ActivityAdminService) view(act *model.CoursesActivity, signed int32) Ac
 	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,
@@ -702,6 +711,7 @@ func (s *ActivityAdminService) view(act *model.CoursesActivity, signed int32) Ac
 		NumberSign:    act.NumberSign,
 		Total:         act.NumberMax,
 		Signed:        signed,
+		Money:         activityMoney,
 		Signable:      s.signable(act),
 		StartTime:     act.StartTime,
 		EndTime:       act.EndTime,