b90a1dcd8f
写了一个忠实转写前端 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 提交为空串,我们跳过空值,
对必填校验等价且更干净。
255 lines
7.0 KiB
Go
255 lines
7.0 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|