xsgz 纯原始值数组改为 join(","),并引入对拍测试防止再次误读前端规则
写了一个忠实转写前端 v()/pe() 的参考实现做对拍,立刻发现一处真实偏差:
前端: tags = "a,b,c" (单个键)
Go: tags[0]=a tags[1]=b tags[2]=c (三个索引键)
前端 v() 对数组是两条分支:
if (s.some(l => l && typeof l == "object")) → 按下标展开 `${i}[${p}]`
else → const l = s.map(p => p ?? "").join(","); append(i, l)
我们只有前一条分支。学校端按 tags 读字符串时拿不到值。
此前几轮反复在「方括号 vs 点号」上判断失误,根因是仅凭注释与内部一致性
推断契约。这次改为用可执行的参考实现做对拍,让差异由测试暴露而不是靠人眼。
改动:
- xsgzFlatten 数组分支按是否有对象分流:纯原始值 join(",")(空数组
与全空值不提交),含对象才按下标展开;键名规则本身不变
(对象键点号、数组下标方括号);
- 新增 leave_xsgz_jsref_test.go:把前端 v()/pe() 转写成 jsFlatten,
用真实形态载荷(请假、销假、多字段、嵌套 map、空 fields)对拍,
双向断言「不缺键、不多键、值一致」;
- 记录一处有意差异并说明理由:前端会把 null 提交为空串,我们跳过空值,
对必填校验等价且更干净。
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user