xsgz 登录后补拉 /auth/user 拿 roleId,不再只依赖 cas/callback 响应

核对前端源码发现两者的获取路径是分开的:

  getOAuthToken:  setToken(m.data.data?.access_token)   // 只管 token
  getUserInfo:    POST /auth/user → data.role.roleId     // roleId 在这里
  axios 拦截器:    s.headers.roleId = JSON.parse(getCurrentRole()).roleId
                  s.headers.Authorization = getToken()   // 原文,无 Bearer 前缀

我们原先在同一处只读 cas/callback 的 data.userInfo.roleId。
若该校响应不含该嵌套结构,RoleID 会为空,导致:
  - 所有后续请求缺少 roleId 头;
  - 学校端可能直接拒绝,且报错信息不会指向真正原因。

改动:
- 登录成功后主动 POST /api/auth/user 取 data.role.roleId(roleName 同步);
- 该调用失败不阻断(slog.Warn 后沿用 cas 响应的值),保持向后兼容;
- 两者都拿不到 roleId 时才明确报错「未能获取角色信息」,
  避免带着空 roleId 一路请求到被学校端拒绝;
- realName 缺失时回退 xm(国内学工系统常见字段名)。

顺带核实两处未被验证过的契约,结论是现有实现正确、无需改动:
- Authorization 头是 token 原文,不加 Bearer 前缀;
- RoleID 与前端 getCurrentRole().roleId 语义一致。

测试:/auth/user 的路径与 POST 方法、role/roleName/realName 解析、
realName 缺失回退 xm、非 0 code 必须报错。
This commit is contained in:
becomer
2026-10-17 18:21:52 +08:00
parent 7c2b9aa3a7
commit 96de022871
2 changed files with 134 additions and 1 deletions
+73 -1
View File
@@ -5,6 +5,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"math/big"
"net/url"
"regexp"
@@ -110,13 +111,84 @@ func XsgzLogin(ctx context.Context, schoolID, casTicket string) (*XsgzSession, e
return nil, fmt.Errorf("登录失败: %s", msg)
}
return &XsgzSession{
sess := &XsgzSession{
Token: result.Data.AccessToken,
RoleID: result.Data.UserInfo.RoleId,
RoleName: result.Data.UserInfo.RoleName,
RealName: result.Data.UserInfo.RealName,
baseURL: base,
client: client,
}
// roleId 不能只依赖 cas/callback 的响应。前端把两者分开处理:
// getOAuthToken: 只取 data.data.access_token(不管 roleId)
// getUserInfo: POST /auth/user → data.role.roleId → setCurrentRole
// 再由 axios 拦截器把 roleId 塞进每个请求头。
// 若此处继续只读 cas 响应的 userInfo,一旦该校不返回该嵌套结构,
// RoleID 就会为空,所有后续请求都缺 roleId 头。
// 因此这里主动补一次 /auth/user,拿不到也不阻断(保持向后兼容)。
if info, iErr := sess.fetchUserRole(ctx); iErr != nil {
slog.Warn("xsgz 拉取用户角色失败,沿用 cas 响应中的 roleId",
"roleId", sess.RoleID, "error", iErr)
} else if info != nil {
if info.RoleID != "" {
sess.RoleID = info.RoleID
}
if info.RoleName != "" {
sess.RoleName = info.RoleName
}
if info.RealName != "" {
sess.RealName = info.RealName
}
}
if sess.RoleID == "" {
return nil, fmt.Errorf("未能获取角色信息(roleId 为空),请刷新页面后重试")
}
return sess, nil
}
// xsgzUserInfo /auth/user 返回的角色信息(前端 getUserInfo)。
type xsgzUserInfo struct {
RoleID string
RoleName string
RealName string
}
// fetchUserRole 对应前端 GET/POST /api/auth/user,取 data.role.roleId。
//
// 前端因此把 roleId 存在 sessionStorage 并注入每个请求头。
func (s *XsgzSession) fetchUserRole(ctx context.Context) (*xsgzUserInfo, error) {
body, err := s.do(ctx, "POST", "/api/auth/user", nil, nil)
if err != nil {
return nil, err
}
var result struct {
Code int `json:"code"`
Data struct {
Role struct {
RoleID string `json:"roleId"`
RoleName string `json:"roleName"`
} `json:"role"`
RealName string `json:"realName"`
Xm string `json:"xm"`
} `json:"data"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析用户信息失败: %w", err)
}
if result.Code != 0 {
return nil, fmt.Errorf("获取用户信息失败: %s", result.Message)
}
name := result.Data.RealName
if name == "" {
name = result.Data.Xm
}
return &xsgzUserInfo{
RoleID: result.Data.Role.RoleID,
RoleName: result.Data.Role.RoleName,
RealName: name,
}, nil
}
@@ -360,3 +360,64 @@ func TestXsgzFetchLeaveDetailError(t *testing.T) {
t.Fatal("code!=0 应报错")
}
}
// TestXsgzFetchUserRoleContract /auth/user 取 data.role.roleId。
//
// 前端不依赖 cas/callback 返回 roleId:
//
// getOAuthToken 只存 access_token
// getUserInfo → POST /auth/user → data.role.roleId → 注入请求头
func TestXsgzFetchUserRoleContract(t *testing.T) {
var gotPath, gotMethod string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotMethod = r.URL.Path, r.Method
_, _ = w.Write([]byte(`{"code":0,"data":{
"realName":"张三",
"role":{"roleId":"role-777","roleName":"学生"}
}}`))
}))
defer srv.Close()
sess := newXsgzTestSession(t, srv)
info, err := sess.fetchUserRole(context.Background())
if err != nil {
t.Fatalf("获取用户角色失败: %v", err)
}
if gotPath != "/api/auth/user" {
t.Fatalf("路径错误: %s", gotPath)
}
if gotMethod != http.MethodPost {
t.Fatalf("方法应为 POST,实际 %s", gotMethod)
}
if info.RoleID != "role-777" || info.RoleName != "学生" || info.RealName != "张三" {
t.Fatalf("解析异常: %+v", info)
}
}
// TestXsgzFetchUserRoleFallbackXm realName 缺失时回退 xm。
func TestXsgzFetchUserRoleFallbackXm(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":0,"data":{"xm":"李四","role":{"roleId":"r1"}}}`))
}))
defer srv.Close()
info, err := newXsgzTestSession(t, srv).fetchUserRole(context.Background())
if err != nil {
t.Fatalf("失败: %v", err)
}
if info.RealName != "李四" {
t.Fatalf("应回退 xm,实际 %q", info.RealName)
}
}
// TestXsgzFetchUserRoleError 非 0 code 必须报错。
func TestXsgzFetchUserRoleError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"code":401,"message":"未登录"}`))
}))
defer srv.Close()
if _, err := newXsgzTestSession(t, srv).fetchUserRole(context.Background()); err == nil {
t.Fatal("code!=0 应报错")
}
}