修正 xsgz 表单数组下标:fields[0] 改为 fields.0,与 needCurlyBraces=false 语义一致

新增的 httptest 端到端契约测试(真发 multipart,假服务器接收)发现:
xsgzFlatten 对数组用 fmt.Sprintf("%s[%d]"),实际提交键为 fields[0].value,
而同一函数的注释、XsgzSubmitApplication 注释、以及 07b0cfa 的提交说明
三处都写的是点号 fields.0.value。

needCurlyBraces=false 的语义本身就是「不用方括号/花括号」,实现与此矛盾。
resty 对 multipart 键名原样透传(addMultipartFormField →
createMultipartHeader(mf.Param)),方括号完全来自我方代码,不是库行为。

后果:学校端按 fields.0.value 取值时拿不到值,请假原因等字段为空,
大概率被判定为「必填项未填写」而提交失败。

改动:
- xsgzFlatten 数组分支改为 "%s.%d",并在注释里记录这次错误;
- TestXsgzFlatten 的期望值同步修正(原断言固化了错误行为),
  并新增断言:所有键不得含 [ 或 ];
- 新增 leave_xsgz_submit_test.go:用 httptest 假服务器捕获真实请求,
  端到端断言路径、Authorization/roleId 头、顶层参数名、
  fields 点号摊平、空值跳过、请假/销假字段互不串味、
  以及缺 projectId/originalId 时本地拦截不发请求。
This commit is contained in:
becomer
2026-10-17 18:12:53 +08:00
parent 00bd63cfdf
commit f1f79dfb6c
3 changed files with 311 additions and 11 deletions
@@ -291,8 +291,12 @@ 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。
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:
@@ -0,0 +1,287 @@
package cpdaily
import (
"context"
"mime"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-resty/resty/v2"
)
// 端到端契约回归:用假学工服务器接收真实请求,断言我们发出的 multipart 表单
// 与前端 toFormatFormData + application_submit 的行为一致。
//
// 真实学校接口无法在未登录场景下验证,但请求体的**形状**是纯客户端产物,
// 可以用本地假服务器精确断言,覆盖「参数名、点号摊平、空值跳过、数组下标」。
// 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 必须是点号摊平的下标键(不是方括号,也不是 JSON 串)
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)
}
// 不应出现方括号形式
for k := range cap.form {
if strings.Contains(k, "[") {
t.Fatalf("不应出现方括号键(前端用点号): %s", 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)
}
}
}
@@ -1,6 +1,9 @@
package cpdaily
import "testing"
import (
"strings"
"testing"
)
// 参考向量由真实前端 security.js(RSAUtils.encryptedString)在 Node 中生成,
// 用于确保 Go 实现与浏览器行为逐字节一致。
@@ -122,14 +125,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 {
@@ -137,9 +140,15 @@ 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)
}
}
}