修销假两个线上报错:projectId 为空、CAS 参数解码错误

线上(162,v1.2.160,userId=1490)实测两类报错:
  提交销假失败: 缺少请假类型(projectId)
  学工系统登录失败: 登录失败: 参数解码错误,刷新页面后尝试
且伴随 WARN「获取销假表单字段失败: 未找到数据」——因为 projectId 为空,
form_fields_for_add 查不到任何东西。

1) projectId 为空
   projectId 是销假提交必传项,但前端销假请求体未必带(TypeWid 在列表里,
   不一定回传到执行接口)。原实现直接拿 req.ProjectID 用,为空即报错。
   修复:
   - 为空时按 leaveId 调 leave_detail 回查补齐,补到后记 INFO;
   - 回查仍拿不到则明确报「无法确定该请假记录所属的请假类型,
     请返回列表刷新后重试」,而不是发出注定失败的提交。
   - 同时修 XsgzFetchLeaveDetail 的解析:前端取的是
     `c.value?.project?.id || c.value?.projectId`,项目 id 可能嵌在
     project 对象里;原先只读顶层 projectId,现按「顶层优先、
     回落 project.id」处理,项目名同样回落到 project.text。

2) CAS 参数解码错误
   前端 getOAuthToken 的 body 是 `ticket=${s}` 直接拼接,**不做 URL 编码**。
   我们原先用 url.QueryEscape,而密文由多块小写 hex 以**空格**连接
   (实测长 ticket 分 2 块),QueryEscape 会把空格变成 '+';
   服务端按原始 body 解析时还原不回空格,逐块解密失败,
   正好对应「参数解码错误,刷新页面后尝试」。
   修复:改为与前端一致的直接拼接。

测试:
- 回查补齐 projectId 的两条路径(嵌套 project.id、顶层优先);
- CAS body 保留原始空格、不得出现 '+';
- 反例固化 QueryEscape 会破坏 ticket,说明为何不能用它。
This commit is contained in:
becomer
2026-10-17 23:05:30 +08:00
parent 9bf2d46f84
commit e38fd89425
3 changed files with 155 additions and 6 deletions
+22
View File
@@ -521,6 +521,28 @@ func executeXsgzLeaveCancel(c *fiber.Ctx, user *model.SysUser, req *ExecuteLeave
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交销假申请..."}) sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交销假申请..."})
// projectId 是销假提交的必传项。前端列表里它放在 TypeWid,
// 但销假请求体里未必带上(线上实测 userId=1490 就是空值,
// 直接导致「缺少请假类型(projectId)」)。
// 这里不再依赖前端传值:为空时按 leaveId 回查学校详情补齐。
if req.ProjectID == "" {
if d, dErr := sess.XsgzFetchLeaveDetail(ctx, req.LeaveID); dErr != nil {
slog.Warn("xsgz 回查销假详情补 projectId 失败",
"userId", user.ID, "leaveId", req.LeaveID, "error", dErr)
} else if d != nil && d.ProjectID != "" {
req.ProjectID = d.ProjectID
slog.Info("xsgz 已回查补齐 projectId",
"userId", user.ID, "leaveId", req.LeaveID, "projectId", req.ProjectID)
}
}
if req.ProjectID == "" {
msg := "无法确定该请假记录所属的请假类型,请返回列表刷新后重试"
slog.Error("xsgz 销假缺少 projectId 且回查失败", "userId", user.ID, "leaveId", req.LeaveID)
saveLog(false, msg)
sendEvent("task_error", map[string]interface{}{"status": "error", "message": msg})
return
}
// 与前端一致:先按 (projectId, requestType) 拉取该申请的表单字段定义, // 与前端一致:先按 (projectId, requestType) 拉取该申请的表单字段定义,
// 作为 fields 提交(ActionPopup.vue: C.value = await form_fields_for_add({projectId, requestType})) // 作为 fields 提交(ActionPopup.vue: C.value = await form_fields_for_add({projectId, requestType}))
formFields, ffErr := sess.XsgzFetchFormFields(ctx, req.ProjectID, "CANCELLATION") formFields, ffErr := sess.XsgzFetchFormFields(ctx, req.ProjectID, "CANCELLATION")
+34 -6
View File
@@ -78,10 +78,18 @@ func XsgzLogin(ctx context.Context, schoolID, casTicket string) (*XsgzSession, e
return nil, fmt.Errorf("ticket 加密失败: %w", err) return nil, fmt.Errorf("ticket 加密失败: %w", err)
} }
// ★ 必须是 form-urlencoded 的 `ticket=xxx`;用 JSON 会被服务端以 500 拒绝 // 必须与前端完全一致:前端 getOAuthToken 是
// service.post("/cas/callback", `ticket=${s}`,
// {headers:{"Content-Type":"application/x-www-form-urlencoded"}})
// 即直接把密文拼进去,不做 URL 编码。
//
// 密文由多块小写 hex 用**空格**连接,若用 url.QueryEscape 会把空格变成 '+'。
// 服务端若按原始 body 解析(不做 form 解码),'+' 不会被还原成空格,
// 逐块解密就会失败,返回「参数解码错误,刷新页面后尝试」——
// 这正是线上 userId=1490 遇到的报错。
resp, err := client.R().SetContext(ctx). resp, err := client.R().SetContext(ctx).
SetHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8"). SetHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8").
SetBody("ticket=" + url.QueryEscape(enc)). SetBody("ticket=" + enc).
Post(base + "/api/cas/callback") Post(base + "/api/cas/callback")
if err != nil { if err != nil {
return nil, fmt.Errorf("换取访问令牌失败: %w", err) return nil, fmt.Errorf("换取访问令牌失败: %w", err)
@@ -259,6 +267,11 @@ func (r XsgzLeaveRecord) RequiresInArea() bool {
// 并在其中读取 cancellationApplyInArea 传给销假弹窗)。 // 并在其中读取 cancellationApplyInArea 传给销假弹窗)。
// //
// 相比拉列表再线性查找,这里按 leaveId 精确查询,不受分页上限影响。 // 相比拉列表再线性查找,这里按 leaveId 精确查询,不受分页上限影响。
//
// projectId 的处理特别注意:前端取的是 `c.value?.project?.id || c.value?.projectId`
// (见 LeaveDetailView 传给 StudentActionDialog 的 props),即项目 id 可能
// 嵌在 project 对象里而不是顶层 projectId。销假提交缺它就会报
// 「缺少请假类型(projectId)」,因此两条路径都要认。
func (s *XsgzSession) XsgzFetchLeaveDetail(ctx context.Context, leaveID string) (*XsgzLeaveRecord, error) { func (s *XsgzSession) XsgzFetchLeaveDetail(ctx context.Context, leaveID string) (*XsgzLeaveRecord, error) {
body, err := s.do(ctx, "GET", "/api/leave/student_action/leave_detail", body, err := s.do(ctx, "GET", "/api/leave/student_action/leave_detail",
map[string]string{"leaveId": leaveID}, nil) map[string]string{"leaveId": leaveID}, nil)
@@ -266,9 +279,16 @@ func (s *XsgzSession) XsgzFetchLeaveDetail(ctx context.Context, leaveID string)
return nil, err return nil, err
} }
var result struct { var result struct {
Code int `json:"code"` Code int `json:"code"`
Data *XsgzLeaveRecord `json:"data"` Data struct {
Message string `json:"message"` XsgzLeaveRecord
// 详情里项目常以对象形式返回
Project *struct {
ID string `json:"id"`
Text string `json:"text"`
} `json:"project"`
} `json:"data"`
Message string `json:"message"`
} }
if err := json.Unmarshal(body, &result); err != nil { if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析请假详情失败: %w", err) return nil, fmt.Errorf("解析请假详情失败: %w", err)
@@ -276,7 +296,15 @@ func (s *XsgzSession) XsgzFetchLeaveDetail(ctx context.Context, leaveID string)
if result.Code != 0 { if result.Code != 0 {
return nil, fmt.Errorf("查询请假详情失败: %s", result.Message) return nil, fmt.Errorf("查询请假详情失败: %s", result.Message)
} }
return result.Data, nil rec := result.Data.XsgzLeaveRecord
// 顶层 projectId 缺失时回落到 project.id
if rec.ProjectID == "" && result.Data.Project != nil {
rec.ProjectID = result.Data.Project.ID
}
if rec.ProjectName == "" && result.Data.Project != nil {
rec.ProjectName = result.Data.Project.Text
}
return &rec, nil
} }
// XsgzFetchLeavePage 查询请假记录列表(只读)。 // XsgzFetchLeavePage 查询请假记录列表(只读)。
@@ -6,6 +6,7 @@ import (
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"strings" "strings"
"testing" "testing"
@@ -421,3 +422,101 @@ func TestXsgzFetchUserRoleError(t *testing.T) {
t.Fatal("code!=0 应报错") t.Fatal("code!=0 应报错")
} }
} }
// casCallbackBody 复现 CasTicketForService → XsgzLogin 的 body 构造。
//
// 前端 getOAuthToken 是 `ticket=${s}` 直接拼接,不做 URL 编码:
//
// service.post("/cas/callback", `ticket=${s}`,
// {headers:{"Content-Type":"application/x-www-form-urlencoded"}})
//
// 密文多块之间是**空格**;若用 url.QueryEscape 会变成 '+',
// 服务端按原始 body 解析时解不回来,报「参数解码错误,刷新页面后尝试」。
func casCallbackBody(enc string) string {
return "ticket=" + enc
}
// TestCasCallbackBodyKeepsSpaces 密文空格必须原样保留,不能变成 '+'。
func TestCasCallbackBodyKeepsSpaces(t *testing.T) {
// 用真实加密产物验证:多块时含空格
r := &xsgzRSA{DK: vecXsgzDK, M: vecXsgzM, E: vecXsgzE}
long := vecXsgzTicket
for i := 0; i < 10; i++ {
long += "0123456789"
}
enc, err := xsgzEncryptTicket(long, r)
if err != nil {
t.Fatalf("加密失败: %v", err)
}
if !strings.Contains(enc, " ") {
t.Skip("该密钥长度下未产生多块,无法验证空格")
}
body := casCallbackBody(enc)
if strings.Contains(body, "+") {
t.Fatal("body 不应含 '+':服务端会当成空格以外的字符导致解密失败")
}
if !strings.Contains(body, "ticket="+enc) {
t.Fatal("body 应为 ticket=<原始密文>")
}
// 关键:空格未被编码
if got := strings.Count(body, " "); got != strings.Count(enc, " ") {
t.Fatalf("空格数量被改变:enc=%d body=%d", strings.Count(enc, " "), got)
}
}
// 用 url.QueryEscape 会改变空格,作为反例固化「为什么不能用它」。
func TestQueryEscapeWouldBreakTicket(t *testing.T) {
plain := "ticket=aaa bbb"
escaped := "ticket=" + url.QueryEscape("aaa bbb")
if escaped == plain {
t.Fatal("前提失效:QueryEscape 未改变空格")
}
if !strings.Contains(escaped, "+") {
t.Fatalf("QueryEscape 应把空格编成 '+',实际 %q", escaped)
}
}
// TestXsgzFetchLeaveDetailNestedProject 项目 id 嵌在 project 对象时也要取到。
//
// 前端取 projectId 是 `c.value?.project?.id || c.value?.projectId`。
// 销假缺 projectId 会直接报「缺少请假类型(projectId)」(线上 userId=1490)。
func TestXsgzFetchLeaveDetailNestedProject(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":0,"data":{
"id":"apply-1",
"project":{"id":"proj-nested","text":"病假"},
"status":"休假中"
}}`))
}))
defer srv.Close()
d, err := newXsgzTestSession(t, srv).XsgzFetchLeaveDetail(context.Background(), "apply-1")
if err != nil {
t.Fatalf("查询详情失败: %v", err)
}
if d.ProjectID != "proj-nested" {
t.Fatalf("应从 project.id 取 projectId,实际 %q", d.ProjectID)
}
if d.ProjectName != "病假" {
t.Fatalf("应从 project.text 取项目名,实际 %q", d.ProjectName)
}
}
// 顶层 projectId 优先于 project.id。
func TestXsgzFetchLeaveDetailTopLevelWins(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":0,"data":{
"id":"a","projectId":"proj-top","project":{"id":"proj-nested"}
}}`))
}))
defer srv.Close()
d, err := newXsgzTestSession(t, srv).XsgzFetchLeaveDetail(context.Background(), "a")
if err != nil {
t.Fatalf("失败: %v", err)
}
if d.ProjectID != "proj-top" {
t.Fatalf("顶层 projectId 应优先,实际 %q", d.ProjectID)
}
}