diff --git a/backed/internal/service/cpdaily/leave_xsgz.go b/backed/internal/service/cpdaily/leave_xsgz.go index 775539e..eaa2e13 100644 --- a/backed/internal/service/cpdaily/leave_xsgz.go +++ b/backed/internal/service/cpdaily/leave_xsgz.go @@ -77,6 +77,15 @@ func XsgzLogin(ctx context.Context, schoolID, casTicket string) (*XsgzSession, e if err != nil { return nil, fmt.Errorf("ticket 加密失败: %w", err) } + // 诊断日志:便于线上区分「ticket 本身异常」与「加密产物异常」。 + // blockCount>1 说明产生了多块(用空格连接),此时 body 若被 URL 编码 + // 会引入 '+' 差异;encLen 与 dkLen 用于核对密钥是否取对。 + // 不打印密文与 DK 本体。 + slog.Info("CAS 登录加密完成", + "ticketLen", len(casTicket), + "dkLen", len(rsa.DK), + "encLen", len(enc), + "blockCount", strings.Count(enc, " ")+1) // body 与前端保持一致:前端 OAuthCallback 组件是 // const u = window.encrypt(ticket); await getOAuthToken(u); @@ -116,6 +125,13 @@ func XsgzLogin(ctx context.Context, schoolID, casTicket string) (*XsgzSession, e if msg == "" { msg = "未知错误" } + // 记录 HTTP 状态与响应体摘要:服务端对「解密失败」与「ticket 无效」 + // 返回的文案不同(前者「参数解码错误」,后者「用户名或密码错误」), + // 这是区分两类问题最直接的证据。限制长度避免刷日志。 + slog.Error("CAS 登录被拒绝", + "httpStatus", resp.StatusCode(), + "message", msg, + "bodySnippet", truncateForLog(string(body), 300)) return nil, fmt.Errorf("登录失败: %s", msg) } @@ -754,6 +770,14 @@ func CasTicketForService(ctx context.Context, cfg CasServiceConfig) (string, err if ticket == "" { return "", fmt.Errorf("回调地址未携带 ticket") } + // 诊断日志:CAS 换票结果只在这里可见,出问题时若没有这些信息, + // 只能猜测「参数解码错误」是 ticket 异常还是加密异常。 + // 只记录长度与形态特征,不打印 ticket 本体,避免凭证泄露。 + slog.Info("CAS 换票成功", + "ticketLen", len(ticket), + "hasSTPrefix", strings.HasPrefix(ticket, "ST-"), + "hasSpace", strings.Contains(ticket, " "), + "service", cfg.Service) return ticket, nil } @@ -946,3 +970,16 @@ func XsgzCasServiceURL(schoolID string) string { } return base + "/oauth/callback" } + +// truncateForLog 限制日志字符串长度,避免把整个响应体刷进日志。 +// 按 rune 截断,避免切断多字节字符产生乱码。 +func truncateForLog(s string, maxRunes int) string { + if maxRunes <= 0 { + return "" + } + r := []rune(s) + if len(r) <= maxRunes { + return s + } + return string(r[:maxRunes]) + "...(truncated)" +} diff --git a/backed/internal/service/cpdaily/leave_xsgz_submit_test.go b/backed/internal/service/cpdaily/leave_xsgz_submit_test.go index b75288f..48ae850 100644 --- a/backed/internal/service/cpdaily/leave_xsgz_submit_test.go +++ b/backed/internal/service/cpdaily/leave_xsgz_submit_test.go @@ -548,3 +548,28 @@ func TestCasTicketNameVariants(t *testing.T) { }) } } + +// truncateForLog 截断保护:不刷日志、不切断多字节字符。 +func TestTruncateForLog(t *testing.T) { + if got := truncateForLog("abc", 10); got != "abc" { + t.Fatalf("短串不应截断,实际 %q", got) + } + long := strings.Repeat("中", 400) + got := truncateForLog(long, 300) + if !strings.HasSuffix(got, "...(truncated)") { + t.Fatal("超长应加截断标记") + } + // 中文按 rune 截断,不应出现乱码(每个中文字仍是完整字符) + body := strings.TrimSuffix(got, "...(truncated)") + if len([]rune(body)) != 300 { + t.Fatalf("应保留 300 个 rune,实际 %d", len([]rune(body))) + } + for _, r := range body { + if r != '中' { + t.Fatalf("出现乱码字符 %q", r) + } + } + if got := truncateForLog("x", 0); got != "" { + t.Fatalf("maxRunes<=0 应返回空串,实际 %q", got) + } +}