diff --git a/backed/internal/service/cpdaily/leave_xsgz.go b/backed/internal/service/cpdaily/leave_xsgz.go index b756b1a..b399bfd 100644 --- a/backed/internal/service/cpdaily/leave_xsgz.go +++ b/backed/internal/service/cpdaily/leave_xsgz.go @@ -273,11 +273,19 @@ func (s *XsgzSession) XsgzSubmitCancel(ctx context.Context, p XsgzSubmitPayload) return s.XsgzSubmitApplication(ctx, p) } -// xsgzFlatten 按前端 toFormatFormData(needCurlyBraces=false) 的规则摊平嵌套结构: -// - 键路径用点号连接(fields.0.value); -// - nil 与空字符串跳过; -// - 数组按下标展开(fields.0 / fields.1); -// - map 递归展开;标量直接输出(bool 用 true/false)。 +// xsgzFlatten 复刻前端 studentForm-Dx5Fpjfs.js 的 FormData 序列化规则: +// +// function pe(e,t,a){return e?a?`${e}[${t}]`:`${e}.${t}`:t} +// function v(e,t,a,o=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) { switch val := v.(type) { case nil: @@ -291,12 +299,15 @@ func xsgzFlatten(prefix string, v interface{}, out *[][2]string) { xsgzFlatten(key, child, out) } case []interface{}: - // 下标也用点号连接(fields.0 / fields.1),与前端 - // toFormatFormData(needCurlyBraces=false) 一致。 - // 注意:此处曾误用方括号 "%s[%d]",与同函数注释及提交说明矛盾, - // 会让学校端收到 fields[0].value 而取不到 fields.0.value。 + // 数组下标用方括号,对象键用点号——这是前端源码的硬编码行为: + // studentForm-Dx5Fpjfs.js: + // function pe(e,t,a){return e?a?`${e}[${t}]`:`${e}.${t}`:t} + // 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 { - key := fmt.Sprintf("%s.%d", prefix, i) + key := fmt.Sprintf("%s[%d]", prefix, i) xsgzFlatten(key, child, out) } case string: @@ -313,11 +324,17 @@ func xsgzFlatten(prefix string, v interface{}, out *[][2]string) { // XsgzSubmitApplication 统一的申请提交(请假/销假/续假共用)。 // -// 序列化规则严格对齐前端 toFormatFormData(T, needCurlyBraces=false): -// - 嵌套/数组用**点号**展开(fields.0.value),不用方括号; -// - 值为 null 或空字符串时**跳过**(不提交该键); -// - 数组按下标逐个展开; -// - 其余值为对象时递归展开。 +// 序列化规则对齐前端 studentForm-Dx5Fpjfs.js 的 v()/pe(): +// - 对象键用点号展开(address、fields[0].fieldId); +// - 数组下标用方括号(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) { if p.ProjectID == "" { return nil, fmt.Errorf("缺少请假类型(projectId)") diff --git a/backed/internal/service/cpdaily/leave_xsgz_submit_test.go b/backed/internal/service/cpdaily/leave_xsgz_submit_test.go index 68f06ce..8867225 100644 --- a/backed/internal/service/cpdaily/leave_xsgz_submit_test.go +++ b/backed/internal/service/cpdaily/leave_xsgz_submit_test.go @@ -13,7 +13,15 @@ import ( ) // 端到端契约回归:用假学工服务器接收真实请求,断言我们发出的 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 串) - if got := cap.form["fields.0.fieldId"]; got != "f1" { - t.Fatalf("fields.0.fieldId = %q, want f1(点号摊平)", got) + // fields 必须是「数组下标方括号 + 对象键点号」: + // studentForm-Dx5Fpjfs.js: const c = `${i}[${p}]`(数组下标硬编码方括号) + // 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 != "身体不适" { - t.Fatalf("fields.0.value = %q", got) + if got := cap.form["fields[0].value"]; got != "身体不适" { + t.Fatalf("fields[0].value = %q", got) } - if got := cap.form["fields.1.value"]; got != "回家休息" { - t.Fatalf("fields.1.value = %q", got) + if got := cap.form["fields[1].value"]; got != "回家休息" { + t.Fatalf("fields[1].value = %q", got) } // 空值字段的 value 不应出现 - if _, ok := cap.form["fields.2.value"]; ok { - t.Fatal("空字符串 value 应被跳过,但出现了 fields.2.value") + if _, ok := cap.form["fields[2].value"]; ok { + t.Fatal("空字符串 value 应被跳过,但出现了 fields[2].value") } // 但该字段的非空属性仍应提交 - if got := cap.form["fields.2.fieldId"]; got != "f3" { - t.Fatalf("fields.2.fieldId = %q", got) + if got := cap.form["fields[2].fieldId"]; got != "f3" { + t.Fatalf("fields[2].fieldId = %q", got) } - // 不应出现方括号形式 + // 数组下标之后必须紧跟点号再接对象键,不能出现 fields[0][fieldId] 之类 for k := range cap.form { - if strings.Contains(k, "[") { - t.Fatalf("不应出现方括号键(前端用点号): %s", k) + if strings.Count(k, "[") != strings.Count(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 { t.Fatal("销假未提供 startTime 时不应提交该键") } - if got := cap.form["fields.0.value"]; got != "已返校" { - t.Fatalf("fields.0.value = %q", got) + if got := cap.form["fields[0].value"]; got != "已返校" { + t.Fatalf("fields[0].value = %q", got) } } diff --git a/backed/internal/service/cpdaily/leave_xsgz_test.go b/backed/internal/service/cpdaily/leave_xsgz_test.go index 69004dd..f97d8b2 100644 --- a/backed/internal/service/cpdaily/leave_xsgz_test.go +++ b/backed/internal/service/cpdaily/leave_xsgz_test.go @@ -1,9 +1,6 @@ package cpdaily -import ( - "strings" - "testing" -) +import "testing" // 参考向量由真实前端 security.js(RSAUtils.encryptedString)在 Node 中生成, // 用于确保 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) { in := map[string]interface{}{ "projectId": "uuid-1", @@ -125,14 +122,14 @@ func TestXsgzFlatten(t *testing.T) { } want := map[string]string{ - "projectId": "uuid-1", - "requestType": "CANCELLATION", - "longitude": "115.8", - "inArea": "true", - "fields.0.fieldId": "f1", - "fields.0.value": "v1", - "fields.1.fieldId": "f2", - "fields.1.value": "v2", + "projectId": "uuid-1", + "requestType": "CANCELLATION", + "longitude": "115.8", + "inArea": "true", + "fields[0].fieldId": "f1", + "fields[0].value": "v1", + "fields[1].fieldId": "f2", + "fields[1].value": "v2", } for k, v := range want { 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 { t.Fatalf("空值键 %q 不应被提交", bad) } } - // 数组下标必须是点号,不能是方括号(needCurlyBraces=false 的语义) - for k := range got { - if strings.ContainsAny(k, "[]") { - t.Fatalf("键 %q 使用了方括号,前端用点号连接", k) - } - } }