回退 f1f79df:抓取线上前端源码证实数组下标确为方括号 fields[0]

上一版 f1f79df 把 xsgzFlatten 的数组下标从 fields[0] 改成 fields.0,
依据是函数注释/提交说明/「needCurlyBraces 语义」三处都写点号。
本次从 sdau 线上静态资源(无需登录)拿到真实前端源码,证明该判断错误。

证据(xsgz.sdau.edu.cn/assets/,免登录可取):
  index-B6fhXjS6.js      10.7MB 主包,含 1354 个 chunk 清单
  studentForm-Dx5Fpjfs.js       序列化与提交实现
  LeaveApplyDrawer-BeaVSGRQ.js  请假提交调用点

前端真实链路(LeaveApplyDrawer 的 P()):
  c = ee(表单值, 字段定义)
  L = ae(c, 字段定义)                      // fields 数组
  T = {projectId, requestType:"LEAVE", startTime, endTime,
       fields:L, address, longitude, latitude}
  f = le(T, ext)                          // le → Ne → v → pe,转 FormData
  await te(f)                             // POST application_submit (multipart)

键名规则(studentForm-Dx5Fpjfs.js 原文):
  function pe(e,t,a){return e? a? `${e}[${t}]` : `${e}.${t}` : t}
  v(): if(Array.isArray(s)) s.forEach((l,p)=>{
         const c=`${i}[${p}]`;            // 数组下标永远方括号,与 a 无关
         ... v(l,r,c,o)
       })
  Ne(e, t={}, a=false) → v(..., a=false)

即:对象键用点号,数组下标硬编码方括号。实际提交形如
fields[0].fieldId / fields[0].value。原实现正确,f1f79df 改错了。

另外查明:注释里长期引用的 toFormatFormData / needCurlyBraces
在整个 bundle 中并不存在(grep 计数为 0),是早期臆造的符号,
现已在注释中改为真实函数名 v()/pe()/Ne() 并附调用链。

改动:
- xsgzFlatten 数组分支还原为 "%s[%d]",注释给出源码依据;
- XsgzSubmitApplication 注释改为真实前端链路与规则;
- TestXsgzFlatten 期望还原为 fields[0].*;
- 契约测试期望同步为 fields[0].*,并加断言:括号需配对、
  不得出现 fields[0][fieldId] 这类二维下标形态。
This commit is contained in:
becomer
2026-10-17 18:15:35 +08:00
parent f1f79dfb6c
commit 568329890b
3 changed files with 74 additions and 53 deletions
+32 -15
View File
@@ -273,11 +273,19 @@ func (s *XsgzSession) XsgzSubmitCancel(ctx context.Context, p XsgzSubmitPayload)
return s.XsgzSubmitApplication(ctx, p) return s.XsgzSubmitApplication(ctx, p)
} }
// xsgzFlatten 按前端 toFormatFormData(needCurlyBraces=false) 的规则摊平嵌套结构: // xsgzFlatten 复刻前端 studentForm-Dx5Fpjfs.js 的 FormData 序列化规则:
// - 键路径用点号连接(fields.0.value); //
// - nil 与空字符串跳过; // function pe(e,t,a){return e?a?`${e}[${t}]`:`${e}.${t}`:t}
// - 数组按下标展开(fields.0 / fields.1); // function v(e,t,a,o=false){...}
// - map 递归展开;标量直接输出(bool 用 true/false)。 //
// 规则:
// - 对象键用点号连接(address / fields[0].fieldId);
// - 数组下标固定用方括号(fields[0]、fields[1]),与参数 o 无关;
// - undefined / null 与空字符串跳过;
// - map 递归展开;标量直接输出(bool 用 true/false,Date 转 ISO)。
//
// 注:早期注释写的 toFormatFormData(needCurlyBraces=false) 是臆造的符号,
// 该 bundle 中并不存在(已由线上源码核实)。
func xsgzFlatten(prefix string, v interface{}, out *[][2]string) { func xsgzFlatten(prefix string, v interface{}, out *[][2]string) {
switch val := v.(type) { switch val := v.(type) {
case nil: case nil:
@@ -291,12 +299,15 @@ func xsgzFlatten(prefix string, v interface{}, out *[][2]string) {
xsgzFlatten(key, child, out) xsgzFlatten(key, child, out)
} }
case []interface{}: case []interface{}:
// 下标也用点号连接(fields.0 / fields.1),与前端 // 数组下标用方括号,对象键用点号——这是前端源码的硬编码行为:
// toFormatFormData(needCurlyBraces=false) 一致。 // studentForm-Dx5Fpjfs.js:
// 注意:此处曾误用方括号 "%s[%d]",与同函数注释及提交说明矛盾, // function pe(e,t,a){return e?a?`${e}[${t}]`:`${e}.${t}`:t}
// 会让学校端收到 fields[0].value 而取不到 fields.0.value。 // v(): s.forEach((l,p)=>{const c=`${i}[${p}]`; ... })
// 调用方 LeaveApplyDrawer: le(T, D.value) → Ne(e,t={},a=false) → v(...,a=false)
// 即使 a=false(对象键走点号),数组下标依然固定为 [n]。
// 实际提交形如:fields[0].fieldId / fields[0].value / fields[1].value
for i, child := range val { for i, child := range val {
key := fmt.Sprintf("%s.%d", prefix, i) key := fmt.Sprintf("%s[%d]", prefix, i)
xsgzFlatten(key, child, out) xsgzFlatten(key, child, out)
} }
case string: case string:
@@ -313,11 +324,17 @@ func xsgzFlatten(prefix string, v interface{}, out *[][2]string) {
// XsgzSubmitApplication 统一的申请提交(请假/销假/续假共用)。 // XsgzSubmitApplication 统一的申请提交(请假/销假/续假共用)。
// //
// 序列化规则严格对齐前端 toFormatFormData(T, needCurlyBraces=false): // 序列化规则对齐前端 studentForm-Dx5Fpjfs.js 的 v()/pe():
// - 嵌套/数组用**点号**展开(fields.0.value),不用方括号; // - 对象键用点号展开(address、fields[0].fieldId);
// - 值为 null 或空字符串时**跳过**(不提交该键); // - 数组下标用方括号(fields[0].value),见 xsgzFlatten 注释;
// - 数组按下标逐个展开; // - 值为 null 或空字符串时跳过(不提交该键);
// - 其余值为对象时递归展开。 // - 其余对象递归展开。
//
// 前端对应链路:LeaveApplyDrawer 的 P()
//
// T = {projectId, requestType, startTime, endTime, fields, address, longitude, latitude}
// f = Ne(T, ext) → v(...) → FormData
// POST /leave/student_action/application_submit (multipart)
func (s *XsgzSession) XsgzSubmitApplication(ctx context.Context, p XsgzSubmitPayload) ([]byte, error) { func (s *XsgzSession) XsgzSubmitApplication(ctx context.Context, p XsgzSubmitPayload) ([]byte, error) {
if p.ProjectID == "" { if p.ProjectID == "" {
return nil, fmt.Errorf("缺少请假类型(projectId)") return nil, fmt.Errorf("缺少请假类型(projectId)")
@@ -13,7 +13,15 @@ import (
) )
// 端到端契约回归:用假学工服务器接收真实请求,断言我们发出的 multipart 表单 // 端到端契约回归:用假学工服务器接收真实请求,断言我们发出的 multipart 表单
// 与前端 toFormatFormData + application_submit 的行为一致。 // 与前端 studentForm-Dx5Fpjfs.js 的 v()/pe() 序列化 + application_submit 行为一致。
//
// 前端真实链路(LeaveApplyDrawer-BeaVSGRQ.js 的 P()):
//
// c = ee(l.value, r.value) // 表单值
// L = ae(c, r.value) // fields 数组
// T = {projectId, requestType:"LEAVE", startTime, endTime, fields:L, address, longitude, latitude}
// f = le(T, D.value) // → Ne→v→pe,转 FormData
// await te(f) // POST application_submit (multipart)
// //
// 真实学校接口无法在未登录场景下验证,但请求体的**形状**是纯客户端产物, // 真实学校接口无法在未登录场景下验证,但请求体的**形状**是纯客户端产物,
// 可以用本地假服务器精确断言,覆盖「参数名、点号摊平、空值跳过、数组下标」。 // 可以用本地假服务器精确断言,覆盖「参数名、点号摊平、空值跳过、数组下标」。
@@ -148,28 +156,33 @@ func TestXsgzSubmitLeaveMultipartContract(t *testing.T) {
} }
} }
// fields 必须是点号摊平的下标键(不是方括号,也不是 JSON 串) // fields 必须是「数组下标方括号 + 对象键点号」:
if got := cap.form["fields.0.fieldId"]; got != "f1" { // studentForm-Dx5Fpjfs.js: const c = `${i}[${p}]`(数组下标硬编码方括号)
t.Fatalf("fields.0.fieldId = %q, want f1(点号摊平)", got) // pe(e,t,a) → a? `${e}[${t}]` : `${e}.${t}`
if got := cap.form["fields[0].fieldId"]; got != "f1" {
t.Fatalf("fields[0].fieldId = %q, want f1", got)
} }
if got := cap.form["fields.0.value"]; got != "身体不适" { if got := cap.form["fields[0].value"]; got != "身体不适" {
t.Fatalf("fields.0.value = %q", got) t.Fatalf("fields[0].value = %q", got)
} }
if got := cap.form["fields.1.value"]; got != "回家休息" { if got := cap.form["fields[1].value"]; got != "回家休息" {
t.Fatalf("fields.1.value = %q", got) t.Fatalf("fields[1].value = %q", got)
} }
// 空值字段的 value 不应出现 // 空值字段的 value 不应出现
if _, ok := cap.form["fields.2.value"]; ok { if _, ok := cap.form["fields[2].value"]; ok {
t.Fatal("空字符串 value 应被跳过,但出现了 fields.2.value") t.Fatal("空字符串 value 应被跳过,但出现了 fields[2].value")
} }
// 但该字段的非空属性仍应提交 // 但该字段的非空属性仍应提交
if got := cap.form["fields.2.fieldId"]; got != "f3" { if got := cap.form["fields[2].fieldId"]; got != "f3" {
t.Fatalf("fields.2.fieldId = %q", got) t.Fatalf("fields[2].fieldId = %q", got)
} }
// 不应出现方括号形式 // 数组下标之后必须紧跟点号再接对象键,不能出现 fields[0][fieldId] 之类
for k := range cap.form { for k := range cap.form {
if strings.Contains(k, "[") { if strings.Count(k, "[") != strings.Count(k, "]") {
t.Fatalf("不应出现方括号键(前端用点号): %s", k) t.Fatalf("键 %q 括号不配对", k)
}
if strings.Contains(k, "][") && !strings.HasPrefix(k, "fields[") {
t.Fatalf("键 %q 形如二维数组下标,前端不会这样提交", k)
} }
} }
// 请假请求不应带销假专有字段 // 请假请求不应带销假专有字段
@@ -224,8 +237,8 @@ func TestXsgzSubmitCancelMultipartContract(t *testing.T) {
if _, ok := cap.form["startTime"]; ok { if _, ok := cap.form["startTime"]; ok {
t.Fatal("销假未提供 startTime 时不应提交该键") t.Fatal("销假未提供 startTime 时不应提交该键")
} }
if got := cap.form["fields.0.value"]; got != "已返校" { if got := cap.form["fields[0].value"]; got != "已返校" {
t.Fatalf("fields.0.value = %q", got) t.Fatalf("fields[0].value = %q", got)
} }
} }
@@ -1,9 +1,6 @@
package cpdaily package cpdaily
import ( import "testing"
"strings"
"testing"
)
// 参考向量由真实前端 security.js(RSAUtils.encryptedString)在 Node 中生成, // 参考向量由真实前端 security.js(RSAUtils.encryptedString)在 Node 中生成,
// 用于确保 Go 实现与浏览器行为逐字节一致。 // 用于确保 Go 实现与浏览器行为逐字节一致。
@@ -101,8 +98,8 @@ func TestIsXsgzSchool(t *testing.T) {
} }
} }
// xsgzFlatten 必须与前端 toFormatFormData(needCurlyBraces=false) 规则一致: // xsgzFlatten 必须与前端 studentForm-Dx5Fpjfs.js 的 v()/pe() 规则一致:
// 点号展开、跳过空值、数组按下标。 // 对象键用点号、数组下标用方括号、跳过空值。
func TestXsgzFlatten(t *testing.T) { func TestXsgzFlatten(t *testing.T) {
in := map[string]interface{}{ in := map[string]interface{}{
"projectId": "uuid-1", "projectId": "uuid-1",
@@ -125,14 +122,14 @@ func TestXsgzFlatten(t *testing.T) {
} }
want := map[string]string{ want := map[string]string{
"projectId": "uuid-1", "projectId": "uuid-1",
"requestType": "CANCELLATION", "requestType": "CANCELLATION",
"longitude": "115.8", "longitude": "115.8",
"inArea": "true", "inArea": "true",
"fields.0.fieldId": "f1", "fields[0].fieldId": "f1",
"fields.0.value": "v1", "fields[0].value": "v1",
"fields.1.fieldId": "f2", "fields[1].fieldId": "f2",
"fields.1.value": "v2", "fields[1].value": "v2",
} }
for k, v := range want { for k, v := range want {
if got[k] != v { if got[k] != v {
@@ -140,15 +137,9 @@ func TestXsgzFlatten(t *testing.T) {
} }
} }
// 空值不应出现 // 空值不应出现
for _, bad := range []string{"address", "empty", "fields.0.blank"} { for _, bad := range []string{"address", "empty", "fields[0].blank"} {
if _, ok := got[bad]; ok { if _, ok := got[bad]; ok {
t.Fatalf("空值键 %q 不应被提交", bad) t.Fatalf("空值键 %q 不应被提交", bad)
} }
} }
// 数组下标必须是点号,不能是方括号(needCurlyBraces=false 的语义)
for k := range got {
if strings.ContainsAny(k, "[]") {
t.Fatalf("键 %q 使用了方括号,前端用点号连接", k)
}
}
} }