Procházet zdrojové kódy

test(academic): 按契约 v1.3 钉死账单两个对账字段名(flowExclusiveEnd / flowInclusiveEnd)

前端契约 v1.3 端点 17 注 ① 已把这两个字段名**定死**(契约定名、后端照做)。
实现侧无需改 service 层(`BillSummary.FlowExclusiveEnd` / `FlowInclusiveEnd` 的 json tag
本来就是这两个名字),本次补的是**可判定的守护**:

- handler 测试 `TestBillEndpointIdentity` 强化:
  · 刻意造一笔**恰好落在 `to` 当天**的流水(300.00),断言
    期末(700.00)与 `flowExclusiveEnd`(200.00)**不含**它、
    `flowInclusiveEnd`(500.00)**含**它、
    且 `flowInclusiveEnd − flowExclusiveEnd` **恰等于该笔金额**(300.00)。
  · 同时守住字段名:谁把 json tag 改名,解出来是零值 ⇒ 差值断言立刻红
    (变异实测:把 flowInclusiveEnd 改成 flowInclusive → 实得 -200.00,用例红;还原即绿)。
- `bill_handler.GetBill` 文档补契约出处 + 落地位置(tag 在 service.BillSummary、由上述用例守),
  并写明"两个都必须下发"的理由:只留一个就没法让前端**看见**两种口径的不对称,
  而契约 v1.2 把两件事写成同一句 `< to` 的根因正是"看不见"。

go build / go vet / go test -short ./internal/academic/... 全绿。
Alex před 18 hodinami
rodič
revize
e6c0491057

+ 10 - 0
internal/academic/handler/bill_handler.go

@@ -48,6 +48,16 @@ var billSelfRoles = map[string]struct{}{
 // 出参(成功):`{summary{9 列 + 2 个对账辅助列 + campusIds}, list[], total, page, pageSize, pages, from, to}`。
 // 出参(失败):1001(无作用域)/2001(缺 from/to、from>to)/2002(campusIds 或时间格式非法)/1003/1004/5002。
 //
+// **两个对账字段名由前端契约 v1.3 端点 17 注 ① 定死**(契约定名、后端照做,不是实现产出后回填):
+//   - `flowExclusiveEnd`:与「期末 − 期初」对齐的当期发生额(右开口径,**不含** `to` 当天);
+//   - `flowInclusiveEnd`:**含 `to` 当天**的当期发生额(dump 的 `BETWEEN` 两端闭口径)。
+//     二者之差 == 落在 `to` 当天的流水金额。**两个都必须下发**——只留一个就没法让前端"看见"
+//     这个不对称(契约 v1.2 把两件事写成同一句 `< to`,根因正是"看不见")。
+//
+// 字段名的落地位置与守护方式:JSON tag 在 `service.BillSummary`(本 handler 直接透传该对象),
+// 由 `TestBillEndpointIdentity` 断言——用例刻意造一笔落在 `to` 当天的流水,检查两个键都能解出、
+// 且`inclusive − exclusive` 恰好等于该笔金额。谁把 tag 改名,该用例立刻红(解出零值 ⇒ 差值断言失败)。
+//
 // 响应形状说明:账单是"汇总 + 明细"两段,**不能**用 `response.Page`(那会把 summary 挤掉),
 // 因此这里用 `response.OK` 手工拼 `PageData` 的 5 个键(list/total/page/pageSize/pages),
 // 保证前端 `useListPage` 的读法一致(分页元信息仍在 data 内,不散落顶层)。

+ 46 - 9
internal/academic/handler/fund_handler_test.go

@@ -610,7 +610,23 @@ func TestRequestApproveTouchesFunds(t *testing.T) {
 	}
 }
 
-// TestBillEndpointIdentity 端点 17:账单汇总恒等式(期末−期初 = 窗口内流水)+ 分页元信息。
+// TestBillEndpointIdentity 端点 17:账单恒等式 + **契约 v1.3 两个对账字段名** + 分页元信息。
+//
+// 契约口径(前端契约 v1.3 端点 17 注 ①,**字段名由契约定、后端照做**):
+//   - `flowExclusiveEnd`:与「期末 − 期初」**对齐**的当期发生额(右开口径,不含 `to` 当天);
+//   - `flowInclusiveEnd`:**含 `to` 当天**的当期发生额(dump 的 `BETWEEN` 口径)。
+//     二者之差 = 落在 `to` 当天的流水金额。
+//
+// 为什么两个都要下发(不能只留一个):只留一个就没法让前端**看见**不对称,
+// 而契约 v1.2 那句"期末余额与当期发生额都按 `< to`"的错句,根因正是"看不见"。
+//
+// 本用例因此**刻意造一笔落在 `to` 当天的流水**(300.00),断言:
+//  1. 期末不含它(700.00)、`flowExclusiveEnd` 不含它(200.00);
+//  2. `flowInclusiveEnd` 含它(500.00);
+//  3. 二者之差 **恰好等于** 该笔金额(300.00)—— 也就是契约说的"落在 to 当天的流水"。
+//
+// 顺带守字段名:若有人把 service.BillSummary 的 json tag 改名,本用例立刻红
+// (`flowExclusiveEnd` 解出来是零值 ⇒ 差值断言必失败)。
 func TestBillEndpointIdentity(t *testing.T) {
 	db := newHandlerTestDB(t)
 	app := newTestApp(t, db)
@@ -623,8 +639,9 @@ func TestBillEndpointIdentity(t *testing.T) {
 			t.Fatalf("seed 流水失败: %v", err)
 		}
 	}
-	seedLog("500.00", time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC))
-	seedLog("200.00", time.Date(2026, 3, 5, 10, 0, 0, 0, time.UTC))
+	seedLog("500.00", time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC)) // 期初
+	seedLog("200.00", time.Date(2026, 3, 5, 10, 0, 0, 0, time.UTC))  // 窗口内
+	seedLog("300.00", time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC))   // **恰好落在 to 当天**
 
 	code, msg, data := doJSON(t, app, http.MethodGet,
 		"/student/bill?userId="+user+"&from=2026-03-01&to=2026-04-01", nil)
@@ -636,6 +653,7 @@ func TestBillEndpointIdentity(t *testing.T) {
 			PrevBalance      string  `json:"prevBalance"`
 			EndBalance       string  `json:"endBalance"`
 			FlowExclusiveEnd string  `json:"flowExclusiveEnd"`
+			FlowInclusiveEnd string  `json:"flowInclusiveEnd"`
 			CampusIDs        []int64 `json:"campusIds"`
 		} `json:"summary"`
 		List     []map[string]any `json:"list"`
@@ -648,16 +666,25 @@ func TestBillEndpointIdentity(t *testing.T) {
 		t.Fatalf("账单体解码失败: %v", err)
 	}
 	if bill.Summary.PrevBalance != "500.00" || bill.Summary.EndBalance != "700.00" {
-		t.Fatalf("期初/期末异常:%+v", bill.Summary)
+		t.Fatalf("期初/期末异常(期末必须**不含** to 当天那笔 300.00):%+v", bill.Summary)
 	}
-	// 恒等式:期末 − 期初 == 窗口内流水
+	// 恒等式:期末 − 期初 == flowExclusiveEnd(右开口径)
 	prev, _ := money.Parse(bill.Summary.PrevBalance)
 	end, _ := money.Parse(bill.Summary.EndBalance)
-	flow, _ := money.Parse(bill.Summary.FlowExclusiveEnd)
-	if end.Sub(prev) != flow {
-		t.Fatalf("恒等式破:期末−期初=%s,窗口流水=%s", end.Sub(prev).String(), flow.String())
+	exclusive, _ := money.Parse(bill.Summary.FlowExclusiveEnd)
+	inclusive, _ := money.Parse(bill.Summary.FlowInclusiveEnd)
+	if end.Sub(prev) != exclusive {
+		t.Fatalf("恒等式破:期末−期初=%s,flowExclusiveEnd=%s", end.Sub(prev).String(), exclusive.String())
+	}
+	// 契约 v1.3:两口径差值 == 落在 to 当天的流水
+	if got, want := inclusive.Sub(exclusive), int64Money(t, "300.00"); got != want {
+		t.Fatalf("flowInclusiveEnd−flowExclusiveEnd = %s,期望 %s(= 落在 to 当天的流水)",
+			got.String(), want.String())
+	}
+	if got, want := inclusive.String(), "500.00"; got != want {
+		t.Fatalf("flowInclusiveEnd = %s,期望 %s(含 to 当天那笔)", got, want)
 	}
-	if bill.Total != 2 || bill.PageSize != 10 || bill.Pages != 1 || bill.List == nil {
+	if bill.Total != 3 || bill.PageSize != 10 || bill.Pages != 1 || bill.List == nil {
 		t.Fatalf("分页元信息异常:total=%d pageSize=%d pages=%d list=%v", bill.Total, bill.PageSize, bill.Pages, bill.List)
 	}
 	if bill.Summary.CampusIDs == nil {
@@ -665,6 +692,16 @@ func TestBillEndpointIdentity(t *testing.T) {
 	}
 }
 
+// int64Money 金额字面量解析(测试内断言用;解析失败直接 fail)。
+func int64Money(t *testing.T, s string) money.Amount {
+	t.Helper()
+	amount, err := money.Parse(s)
+	if err != nil {
+		t.Fatalf("金额解析失败 %q: %v", s, err)
+	}
+	return amount
+}
+
 // TestDashboardEndpoint 端点 21:聚合校区账户累计列 + 明确告知"不支持按校区拆流水"。
 func TestDashboardEndpoint(t *testing.T) {
 	db := newHandlerTestDB(t)