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:
becomer
2026-10-17 18:25:23 +08:00
parent 6bc7ab359c
commit b90a1dcd8f
2 changed files with 288 additions and 7 deletions
+34 -7
View File
@@ -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)
}
}
}