568329890b
上一版 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] 这类二维下标形态。
301 lines
9.6 KiB
Go
301 lines
9.6 KiB
Go
package cpdaily
|
|
|
|
import (
|
|
"context"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/go-resty/resty/v2"
|
|
)
|
|
|
|
// 端到端契约回归:用假学工服务器接收真实请求,断言我们发出的 multipart 表单
|
|
// 与前端 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)
|
|
//
|
|
// 真实学校接口无法在未登录场景下验证,但请求体的**形状**是纯客户端产物,
|
|
// 可以用本地假服务器精确断言,覆盖「参数名、点号摊平、空值跳过、数组下标」。
|
|
|
|
// newXsgzTestSession 构造一个指向假服务器的会话。
|
|
// XsgzSession 的 baseURL/client 为非导出字段,测试同包可直接设置。
|
|
func newXsgzTestSession(t *testing.T, srv *httptest.Server) *XsgzSession {
|
|
t.Helper()
|
|
return &XsgzSession{
|
|
Token: "test-access-token",
|
|
RoleID: "role-123",
|
|
baseURL: srv.URL,
|
|
client: resty.New(),
|
|
}
|
|
}
|
|
|
|
// captureMultipart 记录一次提交请求的表单元数据与键值。
|
|
type captureMultipart struct {
|
|
contentType string
|
|
authHeader string
|
|
roleHeader string
|
|
path string
|
|
form map[string]string
|
|
// 出现顺序,用于确认 fields[] 按下标展开
|
|
keys []string
|
|
}
|
|
|
|
// readMultipart 把请求体解析为字符串键值(忽略文件部分)。
|
|
func (c *captureMultipart) readMultipart(t *testing.T, r *http.Request) {
|
|
t.Helper()
|
|
c.path = r.URL.Path
|
|
c.contentType = r.Header.Get("Content-Type")
|
|
c.authHeader = r.Header.Get("Authorization")
|
|
c.roleHeader = r.Header.Get("roleId")
|
|
c.form = map[string]string{}
|
|
|
|
mediaType, params, err := mime.ParseMediaType(c.contentType)
|
|
if err != nil {
|
|
t.Fatalf("解析 Content-Type 失败: %v", err)
|
|
}
|
|
if mediaType != "multipart/form-data" {
|
|
t.Fatalf("Content-Type 应为 multipart/form-data,实际 %q", mediaType)
|
|
}
|
|
boundary := params["boundary"]
|
|
if boundary == "" {
|
|
t.Fatal("缺少 multipart boundary")
|
|
}
|
|
mr := multipart.NewReader(r.Body, boundary)
|
|
for {
|
|
part, err := mr.NextPart()
|
|
if err != nil {
|
|
break
|
|
}
|
|
name := part.FormName()
|
|
if name == "" {
|
|
continue // 文件部分
|
|
}
|
|
var sb strings.Builder
|
|
buf := make([]byte, 512)
|
|
for {
|
|
n, rerr := part.Read(buf)
|
|
if n > 0 {
|
|
sb.Write(buf[:n])
|
|
}
|
|
if rerr != nil {
|
|
break
|
|
}
|
|
}
|
|
c.form[name] = sb.String()
|
|
c.keys = append(c.keys, name)
|
|
}
|
|
}
|
|
|
|
// TestXsgzSubmitLeaveMultipartContract 请假提交:断言表单键名与摊平规则。
|
|
func TestXsgzSubmitLeaveMultipartContract(t *testing.T) {
|
|
cap := &captureMultipart{}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
cap.readMultipart(t, r)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"code":0,"message":"ok"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
sess := newXsgzTestSession(t, srv)
|
|
fields := []map[string]interface{}{
|
|
{"fieldId": "f1", "value": "身体不适", "componentType": "TEXT"},
|
|
{"fieldId": "f2", "value": "回家休息", "componentType": "TEXTAREA"},
|
|
// 空值字段:前端 toFormatFormData 会跳过空字符串
|
|
{"fieldId": "f3", "value": "", "componentType": "TEXT"},
|
|
}
|
|
|
|
body, err := sess.XsgzSubmitLeave(context.Background(), XsgzSubmitPayload{
|
|
ProjectID: "proj-uuid-1",
|
|
StartTime: "2026-10-17 08:00:00",
|
|
EndTime: "2026-10-18 18:00:00",
|
|
Address: "山东农业大学",
|
|
Longitude: "117.12",
|
|
Latitude: "36.19",
|
|
Fields: fields,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("提交失败: %v", err)
|
|
}
|
|
if !strings.Contains(string(body), `"code":0`) {
|
|
t.Fatalf("响应解析异常: %s", body)
|
|
}
|
|
|
|
// 路径与鉴权头
|
|
if cap.path != "/api/leave/student_action/application_submit" {
|
|
t.Fatalf("路径错误: %s", cap.path)
|
|
}
|
|
if cap.authHeader != "test-access-token" {
|
|
t.Fatalf("Authorization 头错误: %q", cap.authHeader)
|
|
}
|
|
if cap.roleHeader != "role-123" {
|
|
t.Fatalf("roleId 头错误: %q", cap.roleHeader)
|
|
}
|
|
|
|
// 顶层字段名必须与前端一致
|
|
wantTop := map[string]string{
|
|
"projectId": "proj-uuid-1",
|
|
"requestType": "LEAVE",
|
|
"startTime": "2026-10-17 08:00:00",
|
|
"endTime": "2026-10-18 18:00:00",
|
|
"address": "山东农业大学",
|
|
"longitude": "117.12",
|
|
"latitude": "36.19",
|
|
}
|
|
for k, want := range wantTop {
|
|
if got := cap.form[k]; got != want {
|
|
t.Fatalf("表单键 %q = %q, want %q", k, got, want)
|
|
}
|
|
}
|
|
|
|
// 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[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 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.Count(k, "[") != strings.Count(k, "]") {
|
|
t.Fatalf("键 %q 括号不配对", k)
|
|
}
|
|
if strings.Contains(k, "][") && !strings.HasPrefix(k, "fields[") {
|
|
t.Fatalf("键 %q 形如二维数组下标,前端不会这样提交", k)
|
|
}
|
|
}
|
|
// 请假请求不应带销假专有字段
|
|
if _, ok := cap.form["originalId"]; ok {
|
|
t.Fatal("请假不应携带 originalId")
|
|
}
|
|
if _, ok := cap.form["inArea"]; ok {
|
|
t.Fatal("请假不应携带 inArea")
|
|
}
|
|
}
|
|
|
|
// TestXsgzSubmitCancelMultipartContract 销假提交:断言 originalId 与在范围标记。
|
|
func TestXsgzSubmitCancelMultipartContract(t *testing.T) {
|
|
cap := &captureMultipart{}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
cap.readMultipart(t, r)
|
|
_, _ = w.Write([]byte(`{"code":0}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
sess := newXsgzTestSession(t, srv)
|
|
_, err := sess.XsgzSubmitCancel(context.Background(), XsgzSubmitPayload{
|
|
ProjectID: "proj-uuid-2",
|
|
OriginalID: "orig-apply-99",
|
|
Address: "山东农业大学",
|
|
Longitude: "117.12",
|
|
Latitude: "36.19",
|
|
InArea: true,
|
|
CancellationApplyInArea: true,
|
|
Fields: []map[string]interface{}{
|
|
{"fieldId": "c1", "value": "已返校"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("销假提交失败: %v", err)
|
|
}
|
|
|
|
if got := cap.form["requestType"]; got != "CANCELLATION" {
|
|
t.Fatalf("requestType = %q, want CANCELLATION", got)
|
|
}
|
|
if got := cap.form["originalId"]; got != "orig-apply-99" {
|
|
t.Fatalf("originalId = %q, want orig-apply-99", got)
|
|
}
|
|
// bool 必须序列化为 true/false 字面量(前端 JS 行为)
|
|
if got := cap.form["inArea"]; got != "true" {
|
|
t.Fatalf("inArea = %q, want true", got)
|
|
}
|
|
if got := cap.form["cancellationApplyInArea"]; got != "true" {
|
|
t.Fatalf("cancellationApplyInArea = %q, want true", got)
|
|
}
|
|
// 销假不应带 startTime/endTime(未提供时)
|
|
if _, ok := cap.form["startTime"]; ok {
|
|
t.Fatal("销假未提供 startTime 时不应提交该键")
|
|
}
|
|
if got := cap.form["fields[0].value"]; got != "已返校" {
|
|
t.Fatalf("fields[0].value = %q", got)
|
|
}
|
|
}
|
|
|
|
// TestXsgzSubmitCancelRequiresOriginalID 销假缺少原单号时必须本地拦截。
|
|
func TestXsgzSubmitCancelRequiresOriginalID(t *testing.T) {
|
|
called := false
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
called = true
|
|
_, _ = w.Write([]byte(`{"code":0}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
sess := newXsgzTestSession(t, srv)
|
|
_, err := sess.XsgzSubmitCancel(context.Background(), XsgzSubmitPayload{ProjectID: "p"})
|
|
if err == nil {
|
|
t.Fatal("缺少 originalId 应报错")
|
|
}
|
|
if !strings.Contains(err.Error(), "originalId") {
|
|
t.Fatalf("错误信息应提到 originalId,实际: %v", err)
|
|
}
|
|
if called {
|
|
t.Fatal("参数校验失败时不应发起请求")
|
|
}
|
|
}
|
|
|
|
// TestXsgzSubmitRequiresProjectID 请假缺少 projectId 时必须本地拦截。
|
|
func TestXsgzSubmitRequiresProjectID(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatal("不应发起请求")
|
|
}))
|
|
defer srv.Close()
|
|
|
|
sess := newXsgzTestSession(t, srv)
|
|
if _, err := sess.XsgzSubmitLeave(context.Background(), XsgzSubmitPayload{Address: "x"}); err == nil {
|
|
t.Fatal("缺少 projectId 应报错")
|
|
}
|
|
}
|
|
|
|
// TestXsgzSubmitEmptyFieldsOmitted fields 为空时不应提交 fields 相关键。
|
|
func TestXsgzSubmitEmptyFieldsOmitted(t *testing.T) {
|
|
cap := &captureMultipart{}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
cap.readMultipart(t, r)
|
|
_, _ = w.Write([]byte(`{"code":0}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
sess := newXsgzTestSession(t, srv)
|
|
if _, err := sess.XsgzSubmitLeave(context.Background(), XsgzSubmitPayload{
|
|
ProjectID: "p1", Address: "a", Longitude: "1", Latitude: "2",
|
|
}); err != nil {
|
|
t.Fatalf("提交失败: %v", err)
|
|
}
|
|
for k := range cap.form {
|
|
if strings.HasPrefix(k, "fields") {
|
|
t.Fatalf("fields 为空时不应提交 %q", k)
|
|
}
|
|
}
|
|
}
|