修销假两个线上报错: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:
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user