CAS 链路加诊断日志,便于一次请求定位「参数解码错误」
ci/woodpecker/tag/build Pipeline was successful

上一轮用公开接口做对照实验,确认了服务端响应语义:
  - 正确加密但 ticket 无效 → 「用户名或密码错误」(解密成功)
  - 裸 ticket / 乱码密文  → 「参数解码错误」(解密失败)
  - 空 ticket            → 500
同时已排除代码侧原因(Go 与站点 security.js 产物逐字节一致、
chunkSize=126 一致、sec_js 禁缓存、算法自 v1.2.157 未变)。

但「参数解码错误」仍缺一处可见性:CAS 换票拿到的 ticket 本身是什么形态、
加密后几块、服务端原始响应是什么,线上日志都没有。没有这些信息,
下一次复现只能继续猜。

新增(均不含凭证本体):
- CAS 换票成功:ticketLen / hasSTPrefix / hasSpace / service
- 加密完成:ticketLen / dkLen / encLen / blockCount
  (blockCount>1 才可能出现多块空格,届时 body 是否被 URL 编码才有区别;
   这条日志能一次性回答该问题)
- 登录被拒:httpStatus / message / bodySnippet(截断 300 rune)

配套 truncateForLog:按 rune 截断,不切断多字节字符。
测试覆盖短串不截断、超长加标记、中文不产生乱码、边界值。
This commit is contained in:
becomer
2026-10-17 23:11:37 +08:00
parent 392c90ec11
commit 8b647fc31a
2 changed files with 62 additions and 0 deletions
@@ -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)"
}
@@ -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)
}
}