校正 sdau 提交参数序列化,严格对齐前端 toFormatFormData
ci/woodpecker/tag/build Pipeline was successful

重新核对前端源码发现两处与实现不符,已修正:

1. FormData 序列化格式
   前端 toFormatFormData(T, needCurlyBraces=false) 的规则是:
   - 嵌套/数组用**点号**展开(fields.0.value),不是方括号;
   - 值为 null 或空字符串时**跳过**该键;
   - 数组按下标逐个展开。
   原实现把 fields 整个 JSON.stringify 成一个字符串,与前端不一致。
   新增 xsgzFlatten 按上述规则摊平,并加单测覆盖(点号/跳过空值/数组下标)。

2. fields 内容必须是「字段定义 + 用户填值」
   前端提交前会 forEach 把用户输入写回各字段的 value:
     f.value.forEach(l => { l.value = t[l.fieldId] ... })
   原实现只提交了空的字段定义。
   现在先拉 form_fields_for_add,再 fillXsgzFieldValues 把请假原因等
   按字段标签(含"原因/事由")回填到 value;销假同理(含"说明/原因")。

参数契约已逐项比对(源码原文 vs 实现):
  请假:{projectId, requestType:"LEAVE", startTime, endTime,
         fields[], address, longitude, latitude}
  销假:{originalId, projectId, requestType:"CANCELLATION", fields[],
         cancellationApplyInArea, inArea, address, longitude, latitude}
This commit is contained in:
becomer
2026-10-17 17:43:31 +08:00
parent 4a064b53cc
commit 07b0cfaa88
4 changed files with 160 additions and 8 deletions
+16
View File
@@ -500,12 +500,28 @@ func executeXsgzLeaveCancel(c *fiber.Ctx, user *model.SysUser, req *ExecuteLeave
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交销假申请..."})
// 与前端一致:先按 (projectId, requestType) 拉取该申请的表单字段定义,
// 作为 fields 提交(ActionPopup.vue: C.value = await form_fields_for_add({projectId, requestType}))
formFields, ffErr := sess.XsgzFetchFormFields(c.Context(), req.ProjectID, "CANCELLATION")
if ffErr != nil {
slog.Warn("xsgz 获取销假表单字段失败,按空字段提交", "userId", user.ID, "error", ffErr)
formFields = nil
}
// 销假表单通常含"销假说明/原因",用地址兜底填充可见字段
for _, f := range formFields {
label := strings.ToLower(mapStr(f, "fieldZh") + mapStr(f, "fieldName") + mapStr(f, "label"))
if (strings.Contains(label, "说明") || strings.Contains(label, "原因")) && req.Address != "" {
f["value"] = req.Address
}
}
body, err := sess.XsgzSubmitCancel(c.Context(), cpd.XsgzSubmitPayload{
ProjectID: req.ProjectID,
OriginalID: req.LeaveID,
Address: req.Address,
Longitude: req.Longitude,
Latitude: req.Latitude,
Fields: formFields,
})
if err != nil {
msg := "提交销假失败: " + err.Error()
+39
View File
@@ -583,6 +583,16 @@ func executeXsgzLeaveApply(c *fiber.Ctx, user *model.SysUser, req *ExecuteLeaveA
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交请假申请..."})
// 与前端一致:先按 (projectId, requestType) 拉取表单字段定义,再把用户填写的值
// 写回各字段的 value 后作为 fields 提交。
// (index-C4CfkeDm.js: f.value = form_fields_for_add(...),提交前 forEach 补 value)
formFields, ffErr := sess.XsgzFetchFormFields(c.Context(), req.XsgzProjectID, "LEAVE")
if ffErr != nil {
slog.Warn("xsgz 获取请假表单字段失败,按空字段提交", "userId", user.ID, "error", ffErr)
formFields = nil
}
fillXsgzFieldValues(formFields, req)
payload := cpd.XsgzSubmitPayload{
ProjectID: req.XsgzProjectID,
RequestType: "LEAVE",
@@ -591,6 +601,7 @@ func executeXsgzLeaveApply(c *fiber.Ctx, user *model.SysUser, req *ExecuteLeaveA
Address: req.LocationAddr,
Longitude: req.Longitude,
Latitude: req.Latitude,
Fields: formFields,
}
body, err := sess.XsgzSubmitLeave(c.Context(), payload)
if err != nil {
@@ -623,3 +634,31 @@ func executeXsgzLeaveApply(c *fiber.Ctx, user *model.SysUser, req *ExecuteLeaveA
})
return nil
}
// fillXsgzFieldValues 把用户填写的值写回 xsgz 表单字段定义的 value 字段。
//
// xsgz 提交的 fields 是「字段定义 + 当前值」的数组;本平台表单字段与 xsgz 字段
// 命名不同,这里按常见语义做映射:请假原因 → 含"原因/事由"的字段。
// 未能识别的字段保持原值(空),由学校端校验。
func fillXsgzFieldValues(fields []map[string]interface{}, req *ExecuteLeaveApplyRequest) {
if len(fields) == 0 {
return
}
for _, f := range fields {
label := strings.ToLower(mapStr(f, "fieldZh") + mapStr(f, "fieldName") + mapStr(f, "label"))
switch {
case strings.Contains(label, "原因") || strings.Contains(label, "事由"):
if req.LeaveReason != "" {
f["value"] = req.LeaveReason
}
}
}
}
// mapStr 安全取 map 中的字符串值
func mapStr(m map[string]interface{}, k string) string {
if v, ok := m[k]; ok && v != nil {
return fmt.Sprintf("%v", v)
}
return ""
}
+59 -8
View File
@@ -273,12 +273,52 @@ func (s *XsgzSession) XsgzSubmitCancel(ctx context.Context, p XsgzSubmitPayload)
return s.XsgzSubmitApplication(ctx, p)
}
// xsgzFlatten 按前端 toFormatFormData(needCurlyBraces=false) 的规则摊平嵌套结构:
// - 键路径用点号连接(fields.0.value);
// - nil 与空字符串跳过;
// - 数组按下标展开(fields.0 / fields.1);
// - map 递归展开;标量直接输出(bool 用 true/false)。
func xsgzFlatten(prefix string, v interface{}, out *[][2]string) {
switch val := v.(type) {
case nil:
return
case map[string]interface{}:
for k, child := range val {
key := k
if prefix != "" {
key = prefix + "." + k
}
xsgzFlatten(key, child, out)
}
case []interface{}:
for i, child := range val {
key := fmt.Sprintf("%s[%d]", prefix, i)
xsgzFlatten(key, child, out)
}
case string:
if val == "" {
return
}
*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)})
}
}
// XsgzSubmitApplication 统一的申请提交(请假/销假/续假共用)。
//
// 序列化规则严格对齐前端 toFormatFormData(T, needCurlyBraces=false):
// - 嵌套/数组用**点号**展开(fields.0.value),不用方括号;
// - 值为 null 或空字符串时**跳过**(不提交该键);
// - 数组按下标逐个展开;
// - 其余值为对象时递归展开。
func (s *XsgzSession) XsgzSubmitApplication(ctx context.Context, p XsgzSubmitPayload) ([]byte, error) {
if p.ProjectID == "" {
return nil, fmt.Errorf("缺少请假类型(projectId)")
}
form := map[string]string{
fields := map[string]interface{}{
"projectId": p.ProjectID,
"requestType": p.RequestType,
"address": p.Address,
@@ -286,22 +326,33 @@ func (s *XsgzSession) XsgzSubmitApplication(ctx context.Context, p XsgzSubmitPay
"latitude": p.Latitude,
}
if p.StartTime != "" {
form["startTime"] = p.StartTime
fields["startTime"] = p.StartTime
}
if p.EndTime != "" {
form["endTime"] = p.EndTime
fields["endTime"] = p.EndTime
}
if p.OriginalID != "" {
form["originalId"] = p.OriginalID
fields["originalId"] = p.OriginalID
}
if p.RequestType == "CANCELLATION" {
form["inArea"] = strconv.FormatBool(p.InArea)
form["cancellationApplyInArea"] = strconv.FormatBool(p.CancellationApplyInArea)
fields["inArea"] = p.InArea
fields["cancellationApplyInArea"] = p.CancellationApplyInArea
}
if len(p.Fields) > 0 {
if raw, err := json.Marshal(p.Fields); err == nil {
form["fields"] = string(raw)
arr := make([]interface{}, 0, len(p.Fields))
for _, f := range p.Fields {
arr = append(arr, f)
}
fields["fields"] = arr
}
// 按前端规则摊平为键值对
pairs := make([][2]string, 0, 16)
xsgzFlatten("", fields, &pairs)
form := map[string]string{}
for _, kv := range pairs {
form[kv[0]] = kv[1]
}
resp, err := s.client.R().SetContext(ctx).
SetHeader("Authorization", s.Token).
@@ -97,3 +97,49 @@ func TestIsXsgzSchool(t *testing.T) {
t.Fatalf("CAS service 地址错误: %s", XsgzCasServiceURL("sdau"))
}
}
// xsgzFlatten 必须与前端 toFormatFormData(needCurlyBraces=false) 规则一致:
// 点号展开、跳过空值、数组按下标。
func TestXsgzFlatten(t *testing.T) {
in := map[string]interface{}{
"projectId": "uuid-1",
"requestType": "CANCELLATION",
"address": "", // 空字符串应跳过
"longitude": "115.8",
"inArea": true,
"empty": nil, // nil 应跳过
"fields": []interface{}{
map[string]interface{}{"fieldId": "f1", "value": "v1", "blank": ""},
map[string]interface{}{"fieldId": "f2", "value": "v2"},
},
}
var pairs [][2]string
xsgzFlatten("", in, &pairs)
got := map[string]string{}
for _, kv := range pairs {
got[kv[0]] = kv[1]
}
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",
}
for k, v := range want {
if got[k] != v {
t.Fatalf("key %q = %q, want %q(实际: %v)", k, got[k], v, got)
}
}
// 空值不应出现
for _, bad := range []string{"address", "empty", "fields[0].blank"} {
if _, ok := got[bad]; ok {
t.Fatalf("空值键 %q 不应被提交", bad)
}
}
}