Files
cpdaily_xiaozhushou/backed/internal/service/cpdaily/leave_xsgz.go
T
becomer 96de022871 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 必须报错。
2026-10-17 18:21:52 +08:00

885 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package cpdaily
import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"math/big"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/go-resty/resty/v2"
)
// ========== 三易拓「新学工5」独立请假系统(xsgz) ==========
//
// 适用学校:山东农业大学(xsgz.sdau.edu.cn)等采用 sanyth-xuegong-5 系统的学校。
// 与辅导猫完全无关:辅导猫侧请假应用(wec-counselor-leave-*/)在该校为 HTTP 420「产品已到期」。
//
// 链路(全部已实测跑通):
//
// POST {host}/api/cas/loginUrl → 返回 CAS 登录地址(service={host}/oauth/callback)
// CAS 账密登录(wisedu 滑块) → 302 {host}/oauth/callback?ticket=ST-xxx
// GET {host}/api/sec_js → RSA 公钥 {_DK 盐, _M 模数, _E 指数}(DK 每次变,须现取)
// POST {host}/api/cas/callback → ★ form-urlencoded `ticket=<RSA密文>`(不是 JSON!)
// → {access_token, userInfo{realName, roleId, roleName}}
// 后续所有请求头:Authorization: <access_token> + roleId: <roleId>
//
// 注意:access_token 是 Bearer 风格的自定义头,不走 cookie 会话。
// XsgzSchoolIDs 使用三易拓「新学工5」系统的学校 ID 集合(按学校单独适配)
var XsgzSchoolIDs = map[string]bool{
"sdau": true, // 山东农业大学
}
// IsXsgzSchool 判断学校是否使用三易拓新学工5请假系统
func IsXsgzSchool(schoolID string) bool { return XsgzSchoolIDs[schoolID] }
// xsgzBaseURL 返回该校新学工系统根地址(不同学校域名不同,按 schoolID 映射)
func xsgzBaseURL(schoolID string) string {
switch schoolID {
case "sdau":
return "https://xsgz.sdau.edu.cn"
default:
return ""
}
}
// XsgzSession 新学工5 会话(access_token 驱动的鉴权上下文)
type XsgzSession struct {
Token string
RoleID string
RoleName string
RealName string
baseURL string
client *resty.Client
}
// XsgzLogin 用 CAS ticket 换取 access_token,建立新学工5会话。
// casTicket 由调用方通过 CAS 登录取得(service 必须指向 {host}/oauth/callback)。
func XsgzLogin(ctx context.Context, schoolID, casTicket string) (*XsgzSession, error) {
base := xsgzBaseURL(schoolID)
if base == "" {
return nil, fmt.Errorf("该校未配置新学工5地址(schoolId=%s)", schoolID)
}
client := resty.New().SetTimeout(30 * time.Second)
rsa, err := xsgzFetchRSA(client, base)
if err != nil {
return nil, fmt.Errorf("获取加密公钥失败: %w", err)
}
enc, err := xsgzEncryptTicket(casTicket, rsa)
if err != nil {
return nil, fmt.Errorf("ticket 加密失败: %w", err)
}
// ★ 必须是 form-urlencoded 的 `ticket=xxx`;用 JSON 会被服务端以 500 拒绝
resp, err := client.R().SetContext(ctx).
SetHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8").
SetBody("ticket=" + url.QueryEscape(enc)).
Post(base + "/api/cas/callback")
if err != nil {
return nil, fmt.Errorf("换取访问令牌失败: %w", err)
}
var result struct {
Code int `json:"code"`
Data struct {
AccessToken string `json:"access_token"`
UserInfo struct {
RealName string `json:"realName"`
RoleId string `json:"roleId"`
RoleName string `json:"roleName"`
} `json:"userInfo"`
} `json:"data"`
Message string `json:"message"`
}
body := resp.Body()
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析登录响应失败: %w", err)
}
if result.Code != 0 || result.Data.AccessToken == "" {
msg := result.Message
if msg == "" {
msg = "未知错误"
}
return nil, fmt.Errorf("登录失败: %s", msg)
}
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
}
// do 发起带鉴权的请求
func (s *XsgzSession) do(ctx context.Context, method, path string, query map[string]string, form url.Values, jsonBody ...[]byte) ([]byte, error) {
r := s.client.R().SetContext(ctx).
SetHeader("Authorization", s.Token).
SetHeader("roleId", s.RoleID)
if len(query) > 0 {
r = r.SetQueryParams(query)
}
if len(jsonBody) > 0 && jsonBody[0] != nil {
r = r.SetHeader("Content-Type", "application/json;charset=UTF-8").SetBody(jsonBody[0])
} else if form != nil {
r = r.SetHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8").SetBody(form.Encode())
}
var resp *resty.Response
var err error
switch method {
case "POST":
resp, err = r.Post(s.baseURL + path)
default:
resp, err = r.Get(s.baseURL + path)
}
if err != nil {
return nil, err
}
return resp.Body(), nil
}
// XsgzLeaveRecord 新学工5 请假记录
type XsgzLeaveRecord struct {
ID string `json:"id"`
ProjectID string `json:"projectId"` // 请假项目 UUID(销假提交需回传)
ProjectName string `json:"projectName"`
RequestType string `json:"requestTypeText"`
SubmitTime string `json:"submitTime"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
DurationDays int `json:"durationDays"`
Status string `json:"status"`
Approved bool `json:"approved"`
Cancelled bool `json:"cancelled"`
// CancellationApplyInArea 该校/该项目是否要求销假必须在范围内申请。
// 前端按 `=== true || === "是"` 归一化(见 LeaveDetailView),
// 因此这里用 interface{} 兼容 bool 与字符串两种返回。
CancellationApplyInArea interface{} `json:"cancellationApplyInArea"`
}
// RequiresInArea 归一化 cancellationApplyInArea,等价于前端:
//
// c.value?.cancellationApplyInArea === true || c.value?.cancellationApplyInArea === "是"
func (r XsgzLeaveRecord) RequiresInArea() bool {
switch v := r.CancellationApplyInArea.(type) {
case bool:
return v
case string:
return v == "是"
default:
return false
}
}
// XsgzFetchLeaveDetail 查询单条请假详情(只读)。
//
// 对应前端 GET /api/leave/student_action/leave_detail?leaveId=...
// (studentForm 的 he/Ft;LeaveDetailView 在 listType==="apply" 时用它填充详情对象,
// 并在其中读取 cancellationApplyInArea 传给销假弹窗)。
//
// 相比拉列表再线性查找,这里按 leaveId 精确查询,不受分页上限影响。
func (s *XsgzSession) XsgzFetchLeaveDetail(ctx context.Context, leaveID string) (*XsgzLeaveRecord, error) {
body, err := s.do(ctx, "GET", "/api/leave/student_action/leave_detail",
map[string]string{"leaveId": leaveID}, nil)
if err != nil {
return nil, err
}
var result struct {
Code int `json:"code"`
Data *XsgzLeaveRecord `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)
}
return result.Data, nil
}
// XsgzFetchLeavePage 查询请假记录列表(只读)。
func (s *XsgzSession) XsgzFetchLeavePage(ctx context.Context, page, limit int) ([]XsgzLeaveRecord, int, error) {
if page <= 0 {
page = 1
}
if limit <= 0 {
limit = 20
}
body, err := s.do(ctx, "GET", "/api/leave/record/leave_page",
map[string]string{"page": strconv.Itoa(page), "limit": strconv.Itoa(limit)}, nil)
if err != nil {
return nil, 0, err
}
var result struct {
Code int `json:"code"`
Data struct {
List []XsgzLeaveRecord `json:"list"`
Count int `json:"count"`
} `json:"data"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, 0, fmt.Errorf("解析请假列表失败: %w", err)
}
if result.Code != 0 {
return nil, 0, fmt.Errorf("查询请假列表失败: %s", result.Message)
}
return result.Data.List, result.Data.Count, nil
}
// XsgzFetchPendingCancel 查询「待销假」列表(未销假的请假记录)。
//
// 对应前端 GET /api/leave/record/batch-cancel-list,即销假入口展示的列表;
// 与 /api/leave/record/leave_page(全部请假记录)不同。
func (s *XsgzSession) XsgzFetchPendingCancel(ctx context.Context, page, limit int) ([]XsgzLeaveRecord, int, error) {
if page <= 0 {
page = 1
}
if limit <= 0 {
limit = 50
}
body, err := s.do(ctx, "GET", "/api/leave/record/batch-cancel-list",
map[string]string{"page": strconv.Itoa(page), "limit": strconv.Itoa(limit)}, nil)
if err != nil {
return nil, 0, err
}
var result struct {
Code int `json:"code"`
Data struct {
List []XsgzLeaveRecord `json:"list"`
Count int `json:"count"`
} `json:"data"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, 0, fmt.Errorf("解析待销假列表失败: %w", err)
}
if result.Code != 0 {
return nil, 0, fmt.Errorf("查询待销假列表失败: %s", result.Message)
}
return result.Data.List, result.Data.Count, nil
}
// XsgzSubmitPayload 请假申请提交参数(对应前端 application_submit 的 payload)
//
// 前端构造(index-C4CfkeDm.js):
//
// {projectId, requestType:"LEAVE", startTime, endTime,
// fields:[...], address, longitude, latitude}
//
// 提交方式为 multipart/form-data。
type XsgzSubmitPayload struct {
ProjectID string `json:"projectId"`
RequestType string `json:"requestType"` // "LEAVE" 请假 / "CANCELLATION" 销假
StartTime string `json:"startTime,omitempty"`
EndTime string `json:"endTime,omitempty"`
Address string `json:"address"`
Longitude string `json:"longitude"`
Latitude string `json:"latitude"`
Fields []map[string]interface{} `json:"fields,omitempty"`
// OriginalID 销假时必传:原假单 ID
OriginalID string `json:"originalId,omitempty"`
// InArea 销假时是否处于允许销假的地理范围内
InArea bool `json:"inArea,omitempty"`
// CancellationApplyInArea 该校是否要求销假必须在范围内申请
CancellationApplyInArea bool `json:"cancellationApplyInArea,omitempty"`
}
// XsgzSubmitLeave 提交请假申请(requestType=LEAVE)。
//
// 对应前端 POST /api/leave/student_action/application_submit,
// Content-Type 为 multipart/form-data(请假可带附件)。
func (s *XsgzSession) XsgzSubmitLeave(ctx context.Context, p XsgzSubmitPayload) ([]byte, error) {
if p.RequestType == "" {
p.RequestType = "LEAVE"
}
return s.XsgzSubmitApplication(ctx, p)
}
// XsgzSubmitCancel 提交销假申请(requestType=CANCELLATION)。
//
// 与请假共用同一个接口,靠 requestType 区分;销假必须带 originalId(原假单 ID),
// 并按学校要求带上 inArea(是否在销假地理范围内)。
func (s *XsgzSession) XsgzSubmitCancel(ctx context.Context, p XsgzSubmitPayload) ([]byte, error) {
if p.OriginalID == "" {
return nil, fmt.Errorf("缺少原假单 ID(originalId)")
}
p.RequestType = "CANCELLATION"
return s.XsgzSubmitApplication(ctx, p)
}
// xsgzFlatten 复刻前端 studentForm-Dx5Fpjfs.js 的 FormData 序列化规则:
//
// function pe(e,t,a){return e?a?`${e}[${t}]`:`${e}.${t}`:t}
// function v(e,t,a,o=false){...}
//
// 规则:
// - 对象键用点号连接(address / fields[0].fieldId);
// - 数组下标固定用方括号(fields[0]、fields[1]),与参数 o 无关;
// - undefined / null 与空字符串跳过;
// - map 递归展开;标量直接输出(bool 用 true/false,Date 转 ISO)。
//
// 注:早期注释写的 toFormatFormData(needCurlyBraces=false) 是臆造的符号,
// 该 bundle 中并不存在(已由线上源码核实)。
func xsgzFlatten(prefix string, v interface{}, out *[][2]string) {
switch val := v.(type) {
case nil:
return
case map[string]interface{}:
for k, child := range val {
key := k
if prefix != "" {
key = prefix + "." + k
}
xsgzFlatten(key, child, out)
}
case []interface{}:
// 数组下标用方括号,对象键用点号——这是前端源码的硬编码行为:
// studentForm-Dx5Fpjfs.js:
// function pe(e,t,a){return e?a?`${e}[${t}]`:`${e}.${t}`:t}
// v(): s.forEach((l,p)=>{const c=`${i}[${p}]`; ... })
// 调用方 LeaveApplyDrawer: le(T, D.value) → Ne(e,t={},a=false) → v(...,a=false)
// 即使 a=false(对象键走点号),数组下标依然固定为 [n]。
// 实际提交形如:fields[0].fieldId / fields[0].value / fields[1].value
for i, child := range val {
key := fmt.Sprintf("%s[%d]", prefix, i)
xsgzFlatten(key, child, out)
}
case string:
if val == "" {
return
}
*out = append(*out, [2]string{prefix, val})
case bool:
*out = append(*out, [2]string{prefix, strconv.FormatBool(val)})
default:
*out = append(*out, [2]string{prefix, fmt.Sprintf("%v", val)})
}
}
// XsgzSubmitApplication 统一的申请提交(请假/销假/续假共用)。
//
// 序列化规则对齐前端 studentForm-Dx5Fpjfs.js 的 v()/pe():
// - 对象键用点号展开(address、fields[0].fieldId);
// - 数组下标用方括号(fields[0].value),见 xsgzFlatten 注释;
// - 值为 null 或空字符串时跳过(不提交该键);
// - 其余对象递归展开。
//
// 前端对应链路:LeaveApplyDrawer 的 P()
//
// T = {projectId, requestType, startTime, endTime, fields, address, longitude, latitude}
// f = Ne(T, ext) → v(...) → FormData
// POST /leave/student_action/application_submit (multipart)
func (s *XsgzSession) XsgzSubmitApplication(ctx context.Context, p XsgzSubmitPayload) ([]byte, error) {
if p.ProjectID == "" {
return nil, fmt.Errorf("缺少请假类型(projectId)")
}
fields := map[string]interface{}{
"projectId": p.ProjectID,
"requestType": p.RequestType,
"address": p.Address,
"longitude": p.Longitude,
"latitude": p.Latitude,
}
if p.StartTime != "" {
fields["startTime"] = p.StartTime
}
if p.EndTime != "" {
fields["endTime"] = p.EndTime
}
if p.OriginalID != "" {
fields["originalId"] = p.OriginalID
}
if p.RequestType == "CANCELLATION" {
fields["inArea"] = p.InArea
fields["cancellationApplyInArea"] = p.CancellationApplyInArea
}
if len(p.Fields) > 0 {
arr := make([]interface{}, 0, len(p.Fields))
for _, f := range p.Fields {
arr = append(arr, f)
}
fields["fields"] = arr
}
// 按前端规则摊平为键值对
pairs := make([][2]string, 0, 16)
xsgzFlatten("", fields, &pairs)
form := map[string]string{}
for _, kv := range pairs {
form[kv[0]] = kv[1]
}
resp, err := s.client.R().SetContext(ctx).
SetHeader("Authorization", s.Token).
SetHeader("roleId", s.RoleID).
SetMultipartFormData(form).
Post(s.baseURL + "/api/leave/student_action/application_submit")
if err != nil {
return nil, fmt.Errorf("提交申请失败: %w", err)
}
return resp.Body(), nil
}
// XsgzLocationRange 销假地理范围(前端 location_range 的返回项)。
//
// 前端 LeaveCancelLocation 用它做客户端判断:Circle/poi 比半径,
// Polygon/Rectangle 用 GeometryUtil.isPointInPolygon。
type XsgzLocationRange struct {
CompatibleType string `json:"compatibleType"`
Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"`
Radius float64 `json:"radius"`
// Points 仅多边形/矩形有值
Points []struct {
Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"`
} `json:"points"`
}
// XsgzFetchLocationRange 查询指定请假项目的销假地理范围(只读)。
// 对应前端 GET /api/leave/student_action/location_range?projectId=...
func (s *XsgzSession) XsgzFetchLocationRange(ctx context.Context, projectID string) ([]XsgzLocationRange, error) {
body, err := s.do(ctx, "GET", "/api/leave/student_action/location_range",
map[string]string{"projectId": projectID}, nil)
if err != nil {
return nil, err
}
var result struct {
Code int `json:"code"`
Data []XsgzLocationRange `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)
}
return result.Data, nil
}
// InLocationRange 判断坐标是否落在任一销假范围内,等价于前端 canPosition。
//
// 前端实现(LeaveCancelLocation 的 c()):
// - Circle / poi:球面距离 <= radius;
// - Polygon / Rectangle:点是否在多边形内;
// - 范围列表为空时前端直接 return true(不限制)。
func InLocationRange(ranges []XsgzLocationRange, lng, lat float64) bool {
if len(ranges) == 0 {
return true // 前端:!r.value.length 时 return true
}
for _, r := range ranges {
switch r.CompatibleType {
case "Circle", "poi":
// 复用 task.go 的 haversineMeters(签名:lat1, lon1, lat2, lon2)
if haversineMeters(lat, lng, r.Latitude, r.Longitude) <= r.Radius {
return true
}
case "Polygon", "Rectangle":
if pointInPolygon(lng, lat, r.Points) {
return true
}
}
}
return false
}
// pointInPolygon 射线法判断点是否在多边形内(含顶点/边界的近似处理)。
func pointInPolygon(lng, lat float64, pts []struct {
Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"`
}) bool {
n := len(pts)
if n < 3 {
return false
}
inside := false
for i, j := 0, n-1; i < n; j, i = i, i+1 {
xi, yi := pts[i].Longitude, pts[i].Latitude
xj, yj := pts[j].Longitude, pts[j].Latitude
if (yi > lat) != (yj > lat) &&
lng < (xj-xi)*(lat-yi)/(yj-yi)+xi {
inside = !inside
}
}
return inside
}
// XsgzFetchIsRequireLocation 查询该校请假是否强制要求定位
func (s *XsgzSession) XsgzFetchIsRequireLocation(ctx context.Context) (bool, error) {
body, err := s.do(ctx, "GET", "/api/leave/student_action/is_require_location", nil, nil)
if err != nil {
return false, err
}
var v bool
if err := json.Unmarshal(body, &v); err == nil {
return v, nil
}
var wrap struct {
Data bool `json:"data"`
}
if err := json.Unmarshal(body, &wrap); err != nil {
return false, fmt.Errorf("解析定位要求失败: %w", err)
}
return wrap.Data, nil
}
// XsgzBatchCancel 批量销假。
// 对应前端 POST /api/leave/record/batch-cancel(body 为对象)。
// ⚠️ 该写接口的字段结构未能从静态资源完整反推,仅在明确传参时使用。
func (s *XsgzSession) XsgzBatchCancel(ctx context.Context, payload map[string]interface{}) ([]byte, error) {
raw, err := json.Marshal(payload)
if err != nil {
return nil, err
}
return s.do(ctx, "POST", "/api/leave/record/batch-cancel", nil, nil, raw)
}
// ToLeaveItems 把 xsgz 请假记录转换为平台通用的 LeaveItem 契约(前端零改动消费)。
//
// 销假相关字段说明:
// - CanReport:status 为「休假中」或「逾期未销假」时允许销假;
// - ReportPlaces 为空:该校请假无「规定销假位置」概念,由前端提示用户手动定位;
// - ReportApplyFlow=false:该校销假不需要选审批人(如需可从 record 扩展)。
func (s *XsgzSession) ToLeaveItems(recs []XsgzLeaveRecord) []LeaveItem {
out := make([]LeaveItem, 0, len(recs))
for _, r := range recs {
status := r.Status
canReport := status == "休假中" || status == "逾期未销假"
out = append(out, LeaveItem{
ID: r.ID,
LeaveType: r.ProjectName,
Reason: r.RequestType,
CreateTime: r.SubmitTime,
ActEndTime: r.EndTime,
Status: status,
StatusText: status,
CanReport: canReport,
CanExtend: false,
// TypeWid 承载 xsgz 的请假项目 UUID,供前端销假提交时回传 projectId
TypeWid: r.ProjectID,
IsLeaveOut: "",
})
}
return out
}
// CasServiceConfig 用已有 CASTGC 换取指定 service 的 CAS 票据所需参数
type CasServiceConfig struct {
CasBase string // CAS 根,如 https://auths.sdau.edu.cn/authserver
Service string // 目标系统回调地址
CASTGC string // 已有 CAS 会话票(辅导猫登录后获得)
Username string // 仅用于日志
}
// CasTicketForService 用已有 CASTGC 向 CAS 换取 service 对应的 ticket。
//
// 场景:用户已在辅导猫侧登录(持有 CASTGC),但学校另一个系统(如学工系统 xsgz)
// 属于不同 cookie 域,需要单独换取指向它的票据才能建立会话。
// 该函数不发起账号密码登录,只做一次 SSO 换票。
func CasTicketForService(ctx context.Context, cfg CasServiceConfig) (string, error) {
if cfg.CasBase == "" || cfg.Service == "" || cfg.CASTGC == "" {
return "", fmt.Errorf("CAS 换票参数不完整")
}
client := resty.New().SetTimeout(30*time.Second).
SetRedirectPolicy(resty.NoRedirectPolicy()).
SetHeader("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1").
SetHeader("Cookie", "CASTGC="+cfg.CASTGC)
loginURL := strings.TrimSuffix(cfg.CasBase, "/") + "/login?service=" + url.QueryEscape(cfg.Service)
// resty 在 NoRedirectPolicy 下遇 302 会返回 error,但响应本身有效
resp, err := client.R().SetContext(ctx).Get(loginURL)
if resp == nil {
return "", fmt.Errorf("CAS 换票失败: %w", err)
}
loc := resp.Header().Get("Location")
if loc == "" && resp.RawResponse != nil {
loc = resp.RawResponse.Header.Get("Location")
}
if loc == "" {
return "", fmt.Errorf("未取得 CAS 回调地址(CASTGC 可能已过期,status=%d)", resp.StatusCode())
}
u, perr := url.Parse(loc)
if perr != nil {
return "", fmt.Errorf("解析回调地址失败: %w", perr)
}
ticket := u.Query().Get("ticket")
if ticket == "" {
return "", fmt.Errorf("回调地址未携带 ticket")
}
return ticket, nil
}
// XsgzLeaveType 新学工5 请假类型(项目)
type XsgzLeaveType struct {
ID string `json:"id"`
Text string `json:"text"`
}
// XsgzFetchLeaveTypes 查询可用的请假项目/类型(只读)。
func (s *XsgzSession) XsgzFetchLeaveTypes(ctx context.Context) ([]XsgzLeaveType, error) {
body, err := s.do(ctx, "GET", "/api/leave/student_action/optional_project_all", nil, nil)
if err != nil {
return nil, err
}
var result struct {
Code int `json:"code"`
Data []XsgzLeaveType `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)
}
return result.Data, nil
}
// XsgzFetchFormFields 查询指定项目/类型下的表单字段(只读)。
// requestType 必传(LEAVE),否则返回空数组。
func (s *XsgzSession) XsgzFetchFormFields(ctx context.Context, projectID, requestType string) ([]map[string]interface{}, error) {
if requestType == "" {
requestType = "LEAVE"
}
body, err := s.do(ctx, "GET", "/api/leave/student_action/form_fields_for_add",
map[string]string{"projectId": projectID, "requestType": requestType}, nil)
if err != nil {
return nil, err
}
var result struct {
Code int `json:"code"`
Data []map[string]interface{} `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)
}
return result.Data, nil
}
// ---------- RSA 加密(与前端 security.js 的 RSAUtils 完全一致) ----------
type xsgzRSA struct {
DK string // 明文盐前缀(每次 /api/sec_js 都不同)
M string
E string
}
// xsgzDecodeJSHex 把 JS 字符串里的 \xNN 转义还原为原始字符
func xsgzDecodeJSHex(s string) string {
var sb strings.Builder
for i := 0; i < len(s); {
if s[i] == '\\' && i+3 < len(s) && s[i+1] == 'x' {
if b, err := hex.DecodeString(s[i+2 : i+4]); err == nil {
sb.Write(b)
i += 4
continue
}
}
sb.WriteByte(s[i])
i++
}
return sb.String()
}
var (
xsgzDKRe = regexp.MustCompile(`function _DK\(\)\{return "([^"]+)"`)
xsgzMRe = regexp.MustCompile(`function _M\(\)\{return "([^"]+)"`)
xsgzERe = regexp.MustCompile(`function _E\(\)\{return "([^"]+)"`)
)
// xsgzFetchRSA 拉取 RSA 公钥(模数固定、盐每次变化,必须与加密同批使用)
func xsgzFetchRSA(client *resty.Client, base string) (*xsgzRSA, error) {
resp, err := client.R().Get(base + "/api/sec_js")
if err != nil {
return nil, err
}
js := string(resp.Body())
r := &xsgzRSA{}
if m := xsgzDKRe.FindStringSubmatch(js); len(m) > 1 {
r.DK = xsgzDecodeJSHex(m[1])
}
if m := xsgzMRe.FindStringSubmatch(js); len(m) > 1 {
r.M = xsgzDecodeJSHex(m[1])
}
if m := xsgzERe.FindStringSubmatch(js); len(m) > 1 {
r.E = xsgzDecodeJSHex(m[1])
}
if r.DK == "" || r.M == "" || r.E == "" {
return nil, fmt.Errorf("解析公钥参数失败")
}
return r, nil
}
// xsgzEncodeURIComponent 严格等价于 JS encodeURIComponent:
// 保留 A-Za-z0-9 与 -_.!~*'(),其余按 UTF-8 逐字节 %XX。
// 注意不能用 url.QueryEscape(它把空格编成 '+',服务端解不出原文)。
func xsgzEncodeURIComponent(s string) string {
const keep = "-_.!~*'()"
var sb strings.Builder
for _, b := range []byte(s) {
if (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') ||
strings.IndexByte(keep, b) >= 0 {
sb.WriteByte(b)
continue
}
fmt.Fprintf(&sb, "%%%02X", b)
}
return sb.String()
}
// xsgzEncryptTicket 按前端 RSAUtils.encryptedString 语义加密 ticket。
//
// 与标准 PKCS#1 不同(Dave Shapiro RSAUtils 的自定义格式):
// 1. 明文 = encodeURIComponent(DK + ticket),按 charCode 双字节**小端**装入 BigInt;
// 2. chunkSize = 模数字节数 - 2(1024 位密钥 → 126);
// 3. 末尾补 0x00 到 chunkSize 整数倍;
// 4. 每块 m^e mod n,输出小写 hex,块间用单个空格连接。
//
// 已用真实前端 security.js 在 Node 中生成的向量交叉验证一致。
func xsgzEncryptTicket(ticket string, r *xsgzRSA) (string, error) {
plain := xsgzEncodeURIComponent(r.DK + ticket)
n := new(big.Int)
if _, ok := n.SetString(r.M, 16); !ok {
return "", fmt.Errorf("模数格式错误")
}
e := new(big.Int)
if _, ok := e.SetString(r.E, 16); !ok {
return "", fmt.Errorf("指数格式错误")
}
k := (n.BitLen() + 7) / 8
chunk := k - 2
if chunk <= 0 {
return "", fmt.Errorf("密钥长度异常")
}
// JS charCode 序列(UTF-16 code unit),非 BMP 字符拆代理对
codes := make([]int, 0, len(plain))
for _, ch := range plain {
if ch > 0xFFFF {
v := int(ch) - 0x10000
codes = append(codes, 0xD800+(v>>10), 0xDC00+(v&0x3FF))
continue
}
codes = append(codes, int(ch))
}
for len(codes)%chunk != 0 {
codes = append(codes, 0)
}
blocks := make([]string, 0, len(codes)/chunk)
for i := 0; i < len(codes); i += chunk {
var m big.Int
// digits[j] = codes[k] + codes[k+1]<<8,低位 digit 在数组前 → 反向组装
for j := chunk/2 - 1; j >= 0; j-- {
v := codes[i+2*j]
if i+2*j+1 < len(codes) {
v += codes[i+2*j+1] << 8
}
m.Lsh(&m, 16)
m.Or(&m, big.NewInt(int64(v)))
}
c := new(big.Int).Exp(&m, e, n)
blocks = append(blocks, c.Text(16))
}
return strings.Join(blocks, " "), nil
}
// XsgzCasServiceURL 返回该校新学工5的 CAS 回调 service 地址(供 CAS 登录使用)
func XsgzCasServiceURL(schoolID string) string {
base := xsgzBaseURL(schoolID)
if base == "" {
return ""
}
return base + "/oauth/callback"
}