Files
cpdaily_xiaozhushou/backed/internal/service/cpdaily/leave_xsgz.go
T
becomer 568329890b 回退 f1f79df:抓取线上前端源码证实数组下标确为方括号 fields[0]
上一版 f1f79df 把 xsgzFlatten 的数组下标从 fields[0] 改成 fields.0,
依据是函数注释/提交说明/「needCurlyBraces 语义」三处都写点号。
本次从 sdau 线上静态资源(无需登录)拿到真实前端源码,证明该判断错误。

证据(xsgz.sdau.edu.cn/assets/,免登录可取):
  index-B6fhXjS6.js      10.7MB 主包,含 1354 个 chunk 清单
  studentForm-Dx5Fpjfs.js       序列化与提交实现
  LeaveApplyDrawer-BeaVSGRQ.js  请假提交调用点

前端真实链路(LeaveApplyDrawer 的 P()):
  c = ee(表单值, 字段定义)
  L = ae(c, 字段定义)                      // fields 数组
  T = {projectId, requestType:"LEAVE", startTime, endTime,
       fields:L, address, longitude, latitude}
  f = le(T, ext)                          // le → Ne → v → pe,转 FormData
  await te(f)                             // POST application_submit (multipart)

键名规则(studentForm-Dx5Fpjfs.js 原文):
  function pe(e,t,a){return e? a? `${e}[${t}]` : `${e}.${t}` : t}
  v(): if(Array.isArray(s)) s.forEach((l,p)=>{
         const c=`${i}[${p}]`;            // 数组下标永远方括号,与 a 无关
         ... v(l,r,c,o)
       })
  Ne(e, t={}, a=false) → v(..., a=false)

即:对象键用点号,数组下标硬编码方括号。实际提交形如
fields[0].fieldId / fields[0].value。原实现正确,f1f79df 改错了。

另外查明:注释里长期引用的 toFormatFormData / needCurlyBraces
在整个 bundle 中并不存在(grep 计数为 0),是早期臆造的符号,
现已在注释中改为真实函数名 v()/pe()/Ne() 并附调用链。

改动:
- xsgzFlatten 数组分支还原为 "%s[%d]",注释给出源码依据;
- XsgzSubmitApplication 注释改为真实前端链路与规则;
- TestXsgzFlatten 期望还原为 fields[0].*;
- 契约测试期望同步为 fields[0].*,并加断言:括号需配对、
  不得出现 fields[0][fieldId] 这类二维下标形态。
2026-10-17 18:15:35 +08:00

683 lines
23 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"
"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 复刻前端 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
}
// 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"
}