f1f79dfb6c
新增的 httptest 端到端契约测试(真发 multipart,假服务器接收)发现:
xsgzFlatten 对数组用 fmt.Sprintf("%s[%d]"),实际提交键为 fields[0].value,
而同一函数的注释、XsgzSubmitApplication 注释、以及 07b0cfa 的提交说明
三处都写的是点号 fields.0.value。
needCurlyBraces=false 的语义本身就是「不用方括号/花括号」,实现与此矛盾。
resty 对 multipart 键名原样透传(addMultipartFormField →
createMultipartHeader(mf.Param)),方括号完全来自我方代码,不是库行为。
后果:学校端按 fields.0.value 取值时拿不到值,请假原因等字段为空,
大概率被判定为「必填项未填写」而提交失败。
改动:
- xsgzFlatten 数组分支改为 "%s.%d",并在注释里记录这次错误;
- TestXsgzFlatten 的期望值同步修正(原断言固化了错误行为),
并新增断言:所有键不得含 [ 或 ];
- 新增 leave_xsgz_submit_test.go:用 httptest 假服务器捕获真实请求,
端到端断言路径、Authorization/roleId 头、顶层参数名、
fields 点号摊平、空值跳过、请假/销假字段互不串味、
以及缺 projectId/originalId 时本地拦截不发请求。
666 lines
22 KiB
Go
666 lines
22 KiB
Go
package cpdaily
|
||
|
||
import (
|
||
"context"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"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)
|
||
}
|
||
|
||
return &XsgzSession{
|
||
Token: result.Data.AccessToken,
|
||
RoleID: result.Data.UserInfo.RoleId,
|
||
RoleName: result.Data.UserInfo.RoleName,
|
||
RealName: result.Data.UserInfo.RealName,
|
||
baseURL: base,
|
||
client: client,
|
||
}, 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"`
|
||
}
|
||
|
||
// 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 按前端 toFormatFormData(needCurlyBraces=false) 的规则摊平嵌套结构:
|
||
// - 键路径用点号连接(fields.0.value);
|
||
// - nil 与空字符串跳过;
|
||
// - 数组按下标展开(fields.0 / fields.1);
|
||
// - map 递归展开;标量直接输出(bool 用 true/false)。
|
||
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{}:
|
||
// 下标也用点号连接(fields.0 / fields.1),与前端
|
||
// toFormatFormData(needCurlyBraces=false) 一致。
|
||
// 注意:此处曾误用方括号 "%s[%d]",与同函数注释及提交说明矛盾,
|
||
// 会让学校端收到 fields[0].value 而取不到 fields.0.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 统一的申请提交(请假/销假/续假共用)。
|
||
//
|
||
// 序列化规则严格对齐前端 toFormatFormData(T, needCurlyBraces=false):
|
||
// - 嵌套/数组用**点号**展开(fields.0.value),不用方括号;
|
||
// - 值为 null 或空字符串时**跳过**(不提交该键);
|
||
// - 数组按下标逐个展开;
|
||
// - 其余值为对象时递归展开。
|
||
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
|
||
}
|
||
|
||
// 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"
|
||
}
|