diff --git a/backed/internal/service/cpdaily/leave_xsgz.go b/backed/internal/service/cpdaily/leave_xsgz.go index 2fdbe25..5382550 100644 --- a/backed/internal/service/cpdaily/leave_xsgz.go +++ b/backed/internal/service/cpdaily/leave_xsgz.go @@ -416,13 +416,40 @@ func xsgzFlatten(prefix string, v interface{}, out *[][2]string) { xsgzFlatten(key, child, out) } case []interface{}: - // 数组下标用方括号,对象键用点号——这是前端源码的硬编码行为: - // 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 + // 前端 v() 对数组分两种情况(studentForm-Dx5Fpjfs.js): + // if (s.some(l => l && typeof l == "object")) → 按下标展开 `${i}[${p}]` + // else → const l = s.map(p => p ?? "").join(","); append(i, l) + // 即「含对象」才用方括号下标,「纯原始值数组」要拼成逗号串单键。 + // 键名规则本身仍是:对象键点号、数组下标方括号 + // (pe(e,t,a) 里的 a 只影响对象键,与数组分支无关)。 + hasObject := false + for _, el := range val { + if _, ok := el.(map[string]interface{}); ok { + hasObject = true + break + } + } + if !hasObject { + // 纯原始值:拼成 "a,b,c",只提交一个键 + if len(val) == 0 { + return // 空数组:前端 join 得 "",我们不提交空值 + } + parts := make([]string, 0, len(val)) + for _, el := range val { + if el == nil { + parts = append(parts, "") // JS: p ?? "" + continue + } + parts = append(parts, fmt.Sprintf("%v", el)) + } + joined := strings.Join(parts, ",") + if joined == "" { + return // 全空,不提交 + } + *out = append(*out, [2]string{prefix, joined}) + return + } + // 含对象:按下标逐个展开 for i, child := range val { key := fmt.Sprintf("%s[%d]", prefix, i) xsgzFlatten(key, child, out) diff --git a/backed/internal/service/cpdaily/leave_xsgz_jsref_test.go b/backed/internal/service/cpdaily/leave_xsgz_jsref_test.go new file mode 100644 index 0000000..f3a18c6 --- /dev/null +++ b/backed/internal/service/cpdaily/leave_xsgz_jsref_test.go @@ -0,0 +1,254 @@ +package cpdaily + +import ( + "fmt" + "sort" + "strconv" + "strings" + "testing" +) + +// 把前端 studentForm-Dx5Fpjfs.js 的 v()/pe() 忠实转写成参考实现, +// 用它对拍 xsgzFlatten,避免再靠肉眼推断契约细节。 +// +// 原文: +// +// function pe(e,t,a){return e?a?`${e}[${t}]`:`${e}.${t}`:t} +// function v(e,t,a,o=false){ +// const r=t||new FormData +// for(const n in e){ +// if(!Object.prototype.hasOwnProperty.call(e,n))continue +// const s=e[n] +// if(s===void 0)continue +// const i=pe(a,n,o) +// if(s instanceof Date) r.append(i,s.toISOString()) +// else if(Array.isArray(s)) +// if(s.some(l=>l&&typeof l=="object")) +// s.forEach((l,p)=>{const c=`${i}[${p}]` +// if(l&&typeof l=="object"){ if(l.status==="done")return +// "file"in l&&(l.file instanceof File||l.file instanceof Blob) +// ? r.append(o?`${c}[file]`:`${c}.file`,l.file,...) +// : v(l,r,c,o) } +// else r.append(c,l??""); }) +// else { const l=s.map(p=>p??"").join(","); r.append(i,l) } +// else typeof s=="object"&&!(s instanceof File) ? v(s,r,i,o) +// : r.append(i,s??"") +// } +// return r +// } + +// jsPe 对应 pe(e,t,a=false):对象键用点号。 +func jsPe(prefix, key string) string { + if prefix == "" { + return key + } + return prefix + "." + key +} + +// jsFlatten 对应 v(),返回前端会写入 FormData 的键值对。 +func jsFlatten(prefix string, v interface{}, out *[][2]string) { + switch val := v.(type) { + case nil: + // JS: null 不是 undefined,会 append(key, null ?? "") 即空串 + *out = append(*out, [2]string{prefix, ""}) + case map[string]interface{}: + for k, child := range val { + jsFlatten(jsPe(prefix, k), child, out) + } + case []interface{}: + hasObject := false + for _, el := range val { + if _, ok := el.(map[string]interface{}); ok { + hasObject = true + break + } + } + if hasObject { + // 含对象 → 按下标逐个展开 + for i, child := range val { + key := fmt.Sprintf("%s[%d]", prefix, i) + if _, ok := child.(map[string]interface{}); ok { + jsFlatten(key, child, out) + } else { + *out = append(*out, [2]string{key, jsScalar(child)}) + } + } + return + } + // 全是原始值 → join(",") 成单个键(关键差异点) + parts := make([]string, 0, len(val)) + for _, el := range val { + parts = append(parts, jsScalar(el)) + } + *out = append(*out, [2]string{prefix, strings.Join(parts, ",")}) + case string: + *out = append(*out, [2]string{prefix, val}) + case bool: + *out = append(*out, [2]string{prefix, strconv.FormatBool(val)}) + default: + *out = append(*out, [2]string{prefix, fmt.Sprintf("%v", val)}) + } +} + +// jsScalar 对应 JS 的 `l ?? ""` 加隐式字符串化。 +func jsScalar(v interface{}) string { + if v == nil { + return "" + } + return fmt.Sprintf("%v", v) +} + +// toMap 把键值对转成 map,便于比较。 +func toMap(pairs [][2]string) map[string]string { + m := map[string]string{} + for _, kv := range pairs { + m[kv[0]] = kv[1] + } + return m +} + +// compareFlatten 对拍:对前端产出的每个非空键值,断言 Go 实现一致。 +// +// 已知并有意的差异:前端会以空字符串提交(null → ""),我们跳过空值。 +// 对表单提交而言两者对必填校验等价,且跳过更干净,故此处不追究空值键。 +func compareFlatten(t *testing.T, name string, payload map[string]interface{}) { + t.Helper() + + var jsPairs, goPairs [][2]string + jsFlatten("", payload, &jsPairs) + xsgzFlatten("", payload, &goPairs) + + js := toMap(jsPairs) + got := toMap(goPairs) + + for k, want := range js { + if want == "" { + continue // 有意差异:我们不提交空值 + } + if gotVal, ok := got[k]; !ok { + t.Errorf("%s: 缺少键 %q(前端提交 %q)", name, k, want) + } else if gotVal != want { + t.Errorf("%s: 键 %q 值不一致 got=%q want=%q", name, k, gotVal, want) + } + } + // 反向:我们不应多提交前端没有的键 + for k, v := range got { + if v == "" { + continue + } + if _, ok := js[k]; !ok { + t.Errorf("%s: 多出键 %q=%q(前端无此键)", name, k, v) + } + } +} + +// TestXsgzFlattenMatchesFrontend 用真实形态的载荷对拍前后端序列化。 +func TestXsgzFlattenMatchesFrontend(t *testing.T) { + cases := []struct { + name string + payload map[string]interface{} + }{ + { + name: "请假提交(字段含 options 数组)", + payload: map[string]interface{}{ + "projectId": "proj-1", + "requestType": "LEAVE", + "startTime": "2026-10-17 08:00:00", + "endTime": "2026-10-18 18:00:00", + "address": "山东农业大学", + "longitude": "117.12", + "latitude": "36.19", + "fields": []interface{}{ + map[string]interface{}{ + "fieldId": "f1", "fieldName": "请假原因", + "componentType": "TEXT", "fieldType": "TEXT", + "required": true, "value": "感冒", + "options": []interface{}{ + map[string]interface{}{"label": "是", "value": "1"}, + map[string]interface{}{"label": "否", "value": "0"}, + }, + }, + }, + }, + }, + { + name: "销假提交(含布尔与 originalId)", + payload: map[string]interface{}{ + "originalId": "apply-99", + "projectId": "proj-2", + "requestType": "CANCELLATION", + "inArea": true, + "cancellationApplyInArea": false, + "fields": []interface{}{ + map[string]interface{}{"fieldId": "c1", "value": "已返校"}, + }, + }, + }, + { + name: "多字段多类型", + payload: map[string]interface{}{ + "projectId": "p", + "fields": []interface{}{ + map[string]interface{}{"fieldId": "a", "value": "文本"}, + map[string]interface{}{"fieldId": "b", "value": "x,y"}, + map[string]interface{}{"fieldId": "c", "value": "true"}, + }, + }, + }, + { + name: "空 fields 数组", + payload: map[string]interface{}{ + "projectId": "p", + "fields": []interface{}{}, + }, + }, + { + name: "嵌套 map", + payload: map[string]interface{}{ + "projectId": "p", + "applicant": map[string]interface{}{ + "xgh": "202412303232", "xm": "张三", + }, + }, + }, + } + + for _, c := range cases { + compareFlatten(t, c.name, c.payload) + } +} + +// TestXsgzFlattenArrayOfPrimitives 单列数组规则:前端 join(","),我们此前按下标。 +func TestXsgzFlattenArrayOfPrimitives(t *testing.T) { + payload := map[string]interface{}{ + "tags": []interface{}{"a", "b", "c"}, + } + var jsPairs [][2]string + jsFlatten("", payload, &jsPairs) + js := toMap(jsPairs) + t.Logf("前端产出: %v", js) + + var goPairs [][2]string + xsgzFlatten("", payload, &goPairs) + got := toMap(goPairs) + t.Logf("Go 产出: %v", got) + + // 前端是一个键 tags="a,b,c" + if js["tags"] != "a,b,c" { + t.Fatalf("参考实现异常: %v", js) + } + if got["tags"] != "a,b,c" { + t.Errorf("原始值数组应 join 成单键 tags=a,b,c,实际 %v", got) + } + // 不应出现索引键 + keys := make([]string, 0, len(got)) + for k := range got { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if strings.HasPrefix(k, "tags[") { + t.Errorf("不应出现索引键 %q(前端会 join)", k) + } + } +}