Kaynağa Gözat

fix(academic): SumUsable campusID 语义按 dump 三重推翻重写(A7 二次修正)+ 注释/测试对齐

- SumUsable(activity_discount_repo.go):dump 763181-763185 的 campusid 条件是
  **嵌在 schoolid 分支内**的等值条件,且 schoolid<=0 时 selectCondition=''。
  旧实现 `if campusID > 0 { campus_id=? }` 有三处偏差:
    ① campusID=0 被当成"不筛"(实为等值命中机构级池 campus_id=0);
    ② institutionID<=0 时仍按 campusID 过滤(dump 该分支不加任何条件,campusID 应忽略);
    ③ campus 条件可脱离 school 单独生效(dump 无此路径)。
  → 改为 `if institutionID > 0 { q.Where("institution_id = ? AND campus_id = ?") }`,
    方法内保持"两条件恒一起写、institution<=0 一条不写"的 dump 形状。

- 测试期望重推(不沿用被实现带偏的现值):
  (1,2)=10.00 / **(1,0)=15.00**(原 125.00)/ (0,0)=175.00 / **(0,2)=175.00**(原 60.00)/
  (2,2)=50.00 / (-1,2)=ErrBadParam;补 campusID<0 参数防御。

- CountSignedByUser(activity_repo.go):SQL 注释重构为"两分支条件集互不重叠",
  显式标注 type4 分支**没有** t1.status 条件、signed.status<>0 只属 false 分支,
  并补"禁止把 signed.status 提到分支外"的红线说明(代码未改)。
  顺带修正文件头 SP 映射:ListSignedByStudentCourse(A11 已删)→ ListDiscountLogsByStudentCourse。

- 补 BFS 回归用例 TestAddLogBFSZeroQuotaMiddle(AC-09 服务层):A(10)>B(0)>C(2)、find=C
  → 恰 1 行(仅 A),B 的零额度只跳本层、不中断上行;含幂等复算。
  现有 TestAddLogBFSThreeLevels / TestAddLogBFSDepthBoundary 给每个节点都配了额度,绕过 D2 分支。
  (经变异验证:把 D2 的 return nil 改成返回错误、以及把 SumUsable 还原成旧语义,两条用例均变红。)

校验:go build ./... && go vet ./... && go test ./... -short 全绿;gofmt 对改动文件零差异。
Q 21 saat önce
ebeveyn
işleme
0211fadbd9

+ 27 - 17
internal/academic/repository/activity_discount_repo.go

@@ -5,7 +5,8 @@
 //
 // 覆盖 SP 映射:
 //
-//	user_discount_usable          → SumUsable(dump 3 参汇总口径:schoolid + campusid + userid)
+//	user_discount_usable          → SumUsable(dump 3 参汇总口径:schoolid + campusid + userid;
+//	                                campusid 条件**嵌在 schoolid 分支内**且恒为等值)
 //	user_discount_use             → ListUsable(ASC) + IncUsed/IncUsedBatch + InsertDiscountLog(负值)
 //	user_discount_refund          → ListUsable(DESC) + IncUsed(负 delta) + InsertDiscountLog(正值)
 //	user_discount_refund_user_activity → ListUsable(DESC)(同活动池优先退回)
@@ -130,21 +131,31 @@ func (r *ActivityDiscountRepo) ListUsable(ctx context.Context, userID string, ac
 // 入参:
 //   - ctx 请求上下文;
 //   - userID 学生(ULID;空 → ErrBadParam);
-//   - institutionID 机构 ID(原 schoolid;**<=0 = 平台视角:不加任何机构过滤**);
-//   - campusID 校区 ID(原 campusid;**>0 = 该校区等值**;0 = 不限定校区)。
+//   - institutionID 机构 ID(原 schoolid;**>0 = 机构+校区两个等值条件一起生效**;
+//     **<=0 = 平台视角:一条作用域条件都不加**,此时 campusID 被忽略);
+//   - campusID 校区 ID(原 campusid;**仅在 institutionID>0 时生效**,且为**等值**——
+//     `campusID=0` 命中"机构级池"(campus_id=0 的行),**不是**"不限校区");
+//     不得为负(→ ErrBadParam)。
 //
 // 出参:
 //   - money.Amount:可用额度合计(单位:分;无可用行为 0);
 //   - ErrBadParam / ErrDBError。
 //
-// 作用域(audit A7,dump `yuansiwei_finance.sql:763182-763183`):
-//
-//	dump 的 usable(schoolid, campusid, userid) 三个条件都是**等值**:`schoolid=? AND campusid=? AND userid=?`,
-//	且 schoolid/campusid 传 0 时不参与过滤(平台/机构级汇总视角)。
-//	- `institutionID > 0` → `institution_id = ?`(等值,**不再用 `IN (?,0)` 超集**——
-//	  超集会把"机构级池(campus=0)"混进"指定校区可用额",与 dump 差一个池);
-//	- `campusID > 0`     → `campus_id = ?`(等值);
-//	- `institutionID<=0` → **不加**机构/校区过滤(平台视角,字段语义见下);
+// 作用域(audit A7 二次修正,dump `yuansiwei_finance.sql:763181-763185` **逐字**):
+//
+//	IF inputSchoolid>0 THEN
+//	  SET selectCondition = CONCAT(" AND `schoolid`=",schoolid," AND `campusid`=",campusid);
+//	ELSE
+//	  SET selectCondition = '';
+//	END IF;
+//
+//	→ 两条条件**都是等值**,且 campusid 条件**嵌套在 schoolid 分支内部**(不在外面):
+//	- `institutionID > 0` → `institution_id = ? AND campus_id = ?`(**恒一起写**;此分支内
+//	  `campusID=0` 是"机构级池"这个**具体取值**,不是"不筛"——绝不能省略 campus 条件,
+//	  否则会把全校区池一起加进来,可用额虚高);
+//	- `institutionID<=0` → `selectCondition = ''` → **一条作用域条件都不写**,
+//	  传入的 `campusID` 一律**忽略**(dump 里 campusid 只出现在 school 分支内,
+//	  不存在"只按校区筛"的路径)。
 //	越权防线**不在本方法**:按裁定移到 Handler 角色白名单(平台视角只对 super_admin/academic 开放)。
 //
 // 实现说明(金额精度):**不用 SQL SUM()**。sqlite 把 decimal 存 NUMERIC 时 SUM 返回 REAL,
@@ -157,7 +168,7 @@ func (r *ActivityDiscountRepo) SumUsable(ctx context.Context, userID string, ins
 	}
 	if institutionID < 0 || campusID < 0 {
 		return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
-			errString("repository: SumUsable 机构/校区参数不得为负(0=不筛)"))
+			errString("repository: SumUsable 机构/校区参数不得为负(0 的语义见方法注释:平台视角 / 机构级池)"))
 	}
 	db, err := r.db(ctx)
 	if err != nil {
@@ -166,12 +177,11 @@ func (r *ActivityDiscountRepo) SumUsable(ctx context.Context, userID string, ins
 	q := db.Model(&model.CoursesActivityUserDiscount{}).
 		Where("user_id = ?", userID).
 		Where("discount_amount > discount_amount_used")
-	// dump 763182-763183:机构与校区都是**等值**条件;传 0 视为不筛(平台/机构级汇总视角)
+	// dump 763181-763185:两条作用域条件**都是等值**,且 campus 条件**嵌套在 school 分支内**——
+	// 故只在 institutionID>0 时把两个条件一起写(campusID=0 → 只命中 campus_id=0 的机构级池);
+	// institutionID<=0 → 不写任何作用域条件,campusID 被忽略。
 	if institutionID > 0 {
-		q = q.Where("institution_id = ?", institutionID)
-	}
-	if campusID > 0 {
-		q = q.Where("campus_id = ?", campusID)
+		q = q.Where("institution_id = ? AND campus_id = ?", institutionID, campusID)
 	}
 	var rows []model.CoursesActivityUserDiscount
 	if err := q.Find(&rows).Error; err != nil {

+ 24 - 13
internal/academic/repository/activity_discount_repo_test.go

@@ -139,10 +139,15 @@ func TestListUsableActivityIsolation(t *testing.T) {
 	}
 }
 
-// TestSumUsableThreeParamScoping dump usable 3 参**等值**口径(audit A7,dump 763182-763183)。
+// TestSumUsableThreeParamScoping dump usable 3 参**等值且 campus 嵌套在 school 分支内**口径
+// (audit A7 二次修正,dump 763181-763185 逐字):
 //
-// dump:`WHERE schoolid=? AND campusid=? AND userid=?`,两个作用域参数传 0 视为**不筛**(平台/机构级汇总)。
-// 关键差异(相对旧实现的 `IN (campus,0)` 超集):机构级池(campus=0)**不并入**指定校区可用额。
+//	IF inputSchoolid>0 THEN selectCondition = " AND `schoolid`=… AND `campusid`=…;
+//	ELSE selectCondition = '';   END IF;
+//
+// 两条推论(本用例的门禁点):
+//  1. `campusid=0` **不是"不限校区"**,而是等值匹配 `campus_id=0` 的"机构级池";
+//  2. `schoolid<=0` 时**一条作用域条件都不加**,传入的 `campusid` 被**忽略**(dump 无"只按校区筛"路径)。
 //
 // 池清单(用户 user,可用 = discount - used):
 //
@@ -162,30 +167,36 @@ func TestSumUsableThreeParamScoping(t *testing.T) {
 	repo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
 	ctx := context.Background()
 
-	// ① 本校区分池:inst=1 AND campus=2 → pool1(10) + pool5(0) = 10.00(**不含**机构级池 15)
+	// ① (inst=1, campus=2) → 两个等值条件 → pool1(10) + pool5(0) = 10.00(**不含**机构级池 15)
 	if got, err := repo.SumUsable(ctx, user, 1, 2); err != nil || got.Cents() != 1000 {
 		t.Fatalf("SumUsable(1,2) 应为 10.00(等值口径,不含 campus=0 池),实得 %s err=%v", got.String(), err)
 	}
-	// ② 不限定校区:inst=1 → 10 + 15 + 100 = 125.00
-	if got, err := repo.SumUsable(ctx, user, 1, 0); err != nil || got.Cents() != 12500 {
-		t.Fatalf("SumUsable(1,0) 应为 125.00,实得 %s err=%v", got.String(), err)
+	// ② (inst=1, campus=0) → `campus_id = 0` **等值** → 只有机构级池 pool2 = 15.00
+	//    (旧期望 125.00 是照"campusID=0 即不筛"的错误实现写的,dump 无此语义)
+	if got, err := repo.SumUsable(ctx, user, 1, 0); err != nil || got.Cents() != 1500 {
+		t.Fatalf("SumUsable(1,0) 应为 15.00(只命中 campus_id=0 的机构级池),实得 %s err=%v", got.String(), err)
 	}
-	// ③ 平台视角:inst=0 且 campus=0 → 不过滤机构/校区 → 10+15+100+50 = 175.00
+	// ③ (0,0) 平台视角:selectCondition='' → 不过滤机构/校区 → 10+15+100+50 = 175.00
 	if got, err := repo.SumUsable(ctx, user, 0, 0); err != nil || got.Cents() != 17500 {
 		t.Fatalf("SumUsable(0,0) 平台视角应为 175.00,实得 %s err=%v", got.String(), err)
 	}
-	// ④ 只按校区分池(跨机构同名校区):campus=2 → pool1(10) + pool4(50) = 60.00
-	if got, err := repo.SumUsable(ctx, user, 0, 2); err != nil || got.Cents() != 6000 {
-		t.Fatalf("SumUsable(0,2) 应为 60.00,实得 %s err=%v", got.String(), err)
+	// ④ (0,2) 平台视角下 **campusID 被忽略**(campusid 只出现在 school 分支内)→ 同 ③ = 175.00
+	//    (旧期望 60.00 是"单独按校区过滤"的错误实现;dump 里没有这条路径)
+	if got, err := repo.SumUsable(ctx, user, 0, 2); err != nil || got.Cents() != 17500 {
+		t.Fatalf("SumUsable(0,2) 应忽略 campusID → 175.00,实得 %s err=%v", got.String(), err)
 	}
-	// ⑤ 他机构等值:inst=2 → 50.00(越权防线已按裁定移到 Handler 角色白名单,仓储不再拦)
+	// ⑤ 他机构等值:inst=2 且 campus=2 → pool4 = 50.00
+	//    (越权防线已按裁定移到 Handler 角色白名单,仓储不再拦)
 	if got, err := repo.SumUsable(ctx, user, 2, 2); err != nil || got.Cents() != 5000 {
 		t.Fatalf("SumUsable(2,2) 应为 50.00,实得 %s err=%v", got.String(), err)
 	}
-	// 参数防御:负数非法(0 才是"不筛")
+	// 参数防御:负数非法(0 是合法取值,语义见上)
 	if _, err := repo.SumUsable(ctx, user, -1, 2); !errors.Is(err, infraerrors.ErrBadParam) {
 		t.Fatalf("institutionID<0 应 ErrBadParam,实得 %v", err)
 	}
+	if _, err := repo.SumUsable(ctx, user, 1, -1); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("campusID<0 应 ErrBadParam,实得 %v", err)
+	}
 	if _, err := repo.SumUsable(ctx, "", 1, 2); !errors.Is(err, infraerrors.ErrBadParam) {
 		t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", err)
 	}

+ 13 - 6
internal/academic/repository/activity_repo.go

@@ -7,8 +7,10 @@
 //
 //	signed_add        → UpsertSigned(DELETE+INSERT 幂等,对齐 dump)
 //	signed_cancel     → UpdateSignedStatus + IncreaseNumberSign(服务层组合成状态机)
+//	                    + CountSignedByUser(type4Only=false)(取消前置守卫)
 //	sign 限一次(3106) → CountSignedByUser(type4Only=true)
-//	退费回滚赠送金额   → ListSignedByStudentCourse(按报课记录定位报名行)
+//	退费回滚赠送金额   → ListDiscountLogsByStudentCourse(按**流水**定位,audit A11;旧 signed←pool←log
+//	                    三表 join 的 ListSignedByStudentCourse 在 dump 中无依据,已删除)
 //
 // 作用域(关键设计,与 BaseRepo.Scoped 的差异必须读):
 //
@@ -280,17 +282,22 @@ func (r *ActivityRepo) UpsertSigned(ctx context.Context, tx *gorm.DB, signed *mo
 //   - int64:命中条数(>0 即命中对应业务规则);
 //   - ErrBadParam / ErrDBError。
 //
-// SQL 口径(**逐字对齐 dump**):
+// SQL 口径(**逐字对齐 dump**,两分支条件集**互不重叠**):
 //
 //	type4Only=true(dump `yuansiwei_finance.sql:762947`,老带新限一次):
 //	  SELECT COUNT(1) FROM t_courses_activity_signed t1
 //	    INNER JOIN t_courses_activity t2 ON t1.activityid=t2.id AND t2.type=4
-//	   WHERE t1.userid=? AND t2.status != 0
-//	→ **判活动 t2.status**,且对报名 t1.status **不做过滤**(取消过也算参加过,防"取消后重报再领奖励");
+//	   WHERE t1.userid=? AND t2.status != 0          -- ← 只筛**活动** t2.status
+//	  (**本分支没有 `t1.status` 条件**——报名行状态一律不参与过滤,
+//	    取消过也算"参加过",以此堵住"取消报名后重报再领老带新奖励"的重复发钱口子)
 //
 //	type4Only=false(signed_cancel 第一道守卫):
-//	  SELECT count(*) FROM t_courses_activity_signed WHERE user_id=? AND status <> 0
-//	→ 只看报名行是否仍然有效。
+//	  SELECT count(*) FROM t_courses_activity_signed
+//	   WHERE user_id=? AND status <> 0                -- ← 这里筛的是**报名行** signed.status
+//	  (本分支不看活动表、不看活动类型/状态)
+//
+// 两个分支唯一共用的条件是 `user_id = ?`;因此**禁止**把 `signed.status <> 0` 提到分支外
+// (提到外面会让 type4 分支重新筛报名状态,退回 P0-1 的重复发钱缺陷)。
 //
 // 去重语义:跨活动去重由 UK(user_id, activity_id) 保证(一活动一行)。
 func (r *ActivityRepo) CountSignedByUser(ctx context.Context, userID string, type4Only bool) (int64, error) {

+ 81 - 0
internal/academic/service/divide_service_test.go

@@ -361,6 +361,87 @@ func TestAddLogBFSThreeLevels(t *testing.T) {
 	}
 }
 
+// TestAddLogBFSZeroQuotaMiddle 上行链**中间层 quota=0**:只跳该层,**不中断**上行(audit D2)。
+//
+// 链:A(quota 10.00, type=2) > B(quota 0.00, type=1) > C(quota 2.00, type=1),find=C。
+//
+// 期望(dump `yuansiwei_finance.sql:763760`:额度<=0 的节点不生成明细):
+//   - 起点是 C 的上级 B(本人不参与)→ B.quota=0 → **不生成明细**、total_income 不累加;
+//   - B 的 0 额度**不得当作断链**:上行必须继续到 A,A 照常入账 10.00。
+//     → 明细**恰 1 条**(仅 A),B/C 的 total_income 均为 0。
+//
+// 为什么单独需要这条用例:TestAddLogBFSThreeLevels / TestAddLogBFSDepthBoundary 给链上**每个**节点
+// 都配了额度,恰好绕过 D2 的"零额度跳层"分支;若实现把 `quota<=0` 写成 `return err`/`break`,
+// 上述两条仍全绿,只有本用例会红。
+func TestAddLogBFSZeroQuotaMiddle(t *testing.T) {
+	db := newDivideServiceDB(t)
+	svc := NewDivideService(db, nil)
+	ctx := context.Background()
+	a, b, c := nodeID(0), nodeID(1), nodeID(2)
+
+	seedRoot(t, db, a, model.DivideTypePerLesson, "10.00")
+	if err := svc.AddSubordinate(ctx, a, b); err != nil {
+		t.Fatalf("建链失败(%s→%s): %v", a, b, err)
+	}
+	if err := svc.AddSubordinate(ctx, b, c); err != nil {
+		t.Fatalf("建链失败(%s→%s): %v", b, c, err)
+	}
+	// 中间层 B 保持新建节点的默认额度 0.00(不配置)——正是本用例要覆盖的场景
+	if got := cents(t, loadDivideNode(t, db, b).Quota); got != 0 {
+		t.Fatalf("前置条件:B 额度应为 0.00(AddSubordinate 默认值),实得 %d 分", got)
+	}
+	// 链尾 C 的额度与入账无关(本人不参与分成),仅为贴合"末级也配了额度"的真实形态;
+	// 这里直接落库而不走 UpdateQuota:后者有"子不得超父"硬上限(C 的父 B=0.00),
+	// 配 2.00 会被 3111 拒绝,与本用例要验证的 D2 跳层逻辑无关。
+	if err := db.Model(&model.UserDivideInto{}).Where("user_id = ?", c).
+		UpdateColumn("quota", "2.00").Error; err != nil {
+		t.Fatalf("配置 C 额度失败: %v", err)
+	}
+
+	stu := nodeID(900)
+	if err := svc.AddLog(ctx, c, stu, 777, ""); err != nil {
+		t.Fatalf("AddLog 失败(中间层零额度不得报错): %v", err)
+	}
+
+	// 恰 1 条明细(仅 A):B 被 D2 跳过,且跳过不得终止上行
+	if count := countLogs(t, db); count != 1 {
+		t.Fatalf("中间层 quota=0 时应恰 1 条明细(仅 A),实得 %d", count)
+	}
+	var rows []model.UserDivideIntoLog
+	if err := db.Find(&rows).Error; err != nil {
+		t.Fatalf("读明细失败: %v", err)
+	}
+	if len(rows) != 1 {
+		t.Fatalf("明细应恰 1 条,实得 %d", len(rows))
+	}
+	if rows[0].UserID != a {
+		t.Fatalf("唯一明细应属于 A(跨过零额度中间层后的最上级),实得 %s", rows[0].UserID)
+	}
+	if got := cents(t, rows[0].Money); got != 1000 {
+		t.Fatalf("A 的分成应为 10.00(1000 分),实得 %d", got)
+	}
+	if rows[0].OrderNo == nil || *rows[0].OrderNo != "777" {
+		t.Fatalf("order_no 应为 777,实得 %v", rows[0].OrderNo)
+	}
+
+	// 收入口径:只有 A 累加;B(零额度被跳过)与 C(本人)都必须为 0
+	if got := cents(t, loadDivideNode(t, db, a).TotalIncome); got != 1000 {
+		t.Fatalf("A.total_income 应为 1000 分,实得 %d", got)
+	}
+	for _, u := range []string{b, c} {
+		if got := cents(t, loadDivideNode(t, db, u).TotalIncome); got != 0 {
+			t.Fatalf("%s.total_income 应为 0(B 零额度不入账 / C 本人不参与),实得 %d 分", u, got)
+		}
+	}
+	// 幂等不因"跳过 B"而失效:重复调用不得为 A 再写一条
+	if err := svc.AddLog(ctx, c, stu, 777, ""); err != nil {
+		t.Fatalf("AddLog 重复调用应幂等成功,实得 %v", err)
+	}
+	if count := countLogs(t, db); count != 1 {
+		t.Fatalf("重复调用后仍应 1 条明细(幂等),实得 %d", count)
+	}
+}
+
 // TestAddLogTwoLevelIdempotency 两级幂等:type=1 忽略订单;type=2 含订单。
 //
 // 结构:root(拿分成的一方)+ child(报课/被推荐人)—— **分成只入 root**(本人不参与,dump 口径)。