e38fd89425
线上(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,说明为何不能用它。
940 lines
31 KiB
Go
940 lines
31 KiB
Go
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)
|
||
}
|
||
|
||
// 必须与前端完全一致:前端 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).
|
||
SetHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8").
|
||
SetBody("ticket=" + 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 精确查询,不受分页上限影响。
|
||
//
|
||
// 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) {
|
||
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 struct {
|
||
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 {
|
||
return nil, fmt.Errorf("解析请假详情失败: %w", err)
|
||
}
|
||
if result.Code != 0 {
|
||
return nil, fmt.Errorf("查询请假详情失败: %s", result.Message)
|
||
}
|
||
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 查询请假记录列表(只读)。
|
||
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{}:
|
||
// 前端 v() 对数组分两种情况(studentForm-Dx5Fpjfs.js):
|
||
// if (s.some(l => l && typeof l == "object")) → 按下标展开 `${i}[${p}]`
|
||
// else → const l = s.map(p => p ?? "").join(","); append(i, l)
|
||
// 即「含对象」才用方括号下标,「纯原始值数组」要拼成逗号串单键。
|
||
// 键名规则本身仍是:对象键点号、数组下标方括号
|
||
// (pe(e,t,a) 里的 a 只影响对象键,与数组分支无关)。
|
||
hasObject := false
|
||
for _, el := range val {
|
||
if _, ok := el.(map[string]interface{}); ok {
|
||
hasObject = true
|
||
break
|
||
}
|
||
}
|
||
if !hasObject {
|
||
// 纯原始值:拼成 "a,b,c",只提交一个键
|
||
if len(val) == 0 {
|
||
return // 空数组:前端 join 得 "",我们不提交空值
|
||
}
|
||
parts := make([]string, 0, len(val))
|
||
for _, el := range val {
|
||
if el == nil {
|
||
parts = append(parts, "") // JS: p ?? ""
|
||
continue
|
||
}
|
||
parts = append(parts, fmt.Sprintf("%v", el))
|
||
}
|
||
joined := strings.Join(parts, ",")
|
||
if joined == "" {
|
||
return // 全空,不提交
|
||
}
|
||
*out = append(*out, [2]string{prefix, joined})
|
||
return
|
||
}
|
||
// 含对象:按下标逐个展开
|
||
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"
|
||
}
|