296 lines
9.0 KiB
Go
296 lines
9.0 KiB
Go
package cpdaily
|
||
|
||
import (
|
||
"context"
|
||
"crypto/tls"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"net/url"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/go-resty/resty/v2"
|
||
)
|
||
|
||
// ========== HTTP 客户端 ==========
|
||
|
||
var httpClient *resty.Client
|
||
|
||
func init() {
|
||
httpClient = resty.New().
|
||
SetTimeout(30*time.Second).
|
||
SetDoNotParseResponse(true).
|
||
SetTLSClientConfig(&tls.Config{
|
||
InsecureSkipVerify: true,
|
||
MinVersion: tls.VersionTLS10,
|
||
}).
|
||
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("CpdailyClientType", "CPDAILY").
|
||
SetHeader("deviceType", "2")
|
||
}
|
||
|
||
// ========== 公开 API ==========
|
||
|
||
// SearchSchools 搜索学校
|
||
func SearchSchools(name string) ([]SchoolBriefInfo, error) {
|
||
client := httpClient
|
||
resp, err := client.R().Get("https://static.campushoy.com/apicache/tenantListSort")
|
||
if err != nil {
|
||
return nil, fmt.Errorf("请求学校列表失败: %w", err)
|
||
}
|
||
defer resp.RawBody().Close()
|
||
|
||
body, _ := io.ReadAll(resp.RawBody())
|
||
|
||
var result struct {
|
||
ErrMsg string `json:"errMsg"`
|
||
ErrCode int `json:"errCode"`
|
||
Data json.RawMessage `json:"data"`
|
||
}
|
||
if err := json.Unmarshal(body, &result); err != nil {
|
||
return nil, fmt.Errorf("解析学校列表失败: %w", err)
|
||
}
|
||
|
||
// data 是一个数组,每项包含 datas 字段
|
||
var datasets []struct {
|
||
Datas []SchoolBriefInfo `json:"datas"`
|
||
}
|
||
if err := json.Unmarshal(result.Data, &datasets); err != nil {
|
||
return nil, fmt.Errorf("解析学校数据失败: %w", err)
|
||
}
|
||
|
||
var schools []SchoolBriefInfo
|
||
for _, ds := range datasets {
|
||
for _, s := range ds.Datas {
|
||
if name == "" || strings.Contains(s.Name, name) || strings.Contains(s.NamePinYin, name) {
|
||
schools = append(schools, s)
|
||
}
|
||
}
|
||
}
|
||
return schools, nil
|
||
}
|
||
|
||
// GetSchoolDetail 获取学校详情
|
||
func GetSchoolDetail(tenantID string) (*SchoolDetailInfo, error) {
|
||
// AES 加密 tenantID
|
||
encrypted := AesEncrypt([]byte(tenantID))
|
||
aParam := base64.StdEncoding.EncodeToString(encrypted)
|
||
|
||
resp, err := httpClient.R().
|
||
SetQueryParam("a", aParam).
|
||
SetQueryParam("b", "first_v4").
|
||
Get("https://mobile.campushoy.com/v6/config/guest/tenant/info/v-8222")
|
||
if err != nil {
|
||
return nil, fmt.Errorf("请求学校详情失败: %w", err)
|
||
}
|
||
defer resp.RawBody().Close()
|
||
|
||
body, _ := io.ReadAll(resp.RawBody())
|
||
|
||
var result struct {
|
||
ErrMsg string `json:"errMsg"`
|
||
ErrCode int `json:"errCode"`
|
||
Data string `json:"data"`
|
||
}
|
||
if err := json.Unmarshal(body, &result); err != nil {
|
||
return nil, fmt.Errorf("解析学校详情失败: %w", err)
|
||
}
|
||
|
||
if result.ErrCode != 0 {
|
||
return nil, fmt.Errorf("学校详情接口错误: %s", result.ErrMsg)
|
||
}
|
||
|
||
// 数据是 base64(AES_Encrypted(JSON)),需要解码+解密
|
||
encData, err := base64.StdEncoding.DecodeString(result.Data)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("base64解码失败: %w", err)
|
||
}
|
||
decData := AesDecrypt(encData)
|
||
|
||
// 有时候需要解密两次
|
||
if !json.Valid(decData) {
|
||
// 再解密一次
|
||
encData2, _ := base64.StdEncoding.DecodeString(string(decData))
|
||
if encData2 != nil {
|
||
decData = AesDecrypt(encData2)
|
||
}
|
||
}
|
||
|
||
// 注意: API 返回的可能是一个数组(部分学校返回单对象,部分返回数组)
|
||
var detail SchoolDetailInfo
|
||
if decData[0] == '[' {
|
||
var details []SchoolDetailInfo
|
||
if err := json.Unmarshal(decData, &details); err != nil {
|
||
return nil, fmt.Errorf("解析学校详情数组JSON失败: %w, data: %s", err, string(decData))
|
||
}
|
||
if len(details) == 0 {
|
||
return nil, fmt.Errorf("学校详情数组为空")
|
||
}
|
||
detail = details[0]
|
||
} else {
|
||
if err := json.Unmarshal(decData, &detail); err != nil {
|
||
return nil, fmt.Errorf("解析学校详情JSON失败: %w, data: %s", err, string(decData))
|
||
}
|
||
}
|
||
|
||
// 填充额外的字段
|
||
detail.NamePinYin = getPinYinFromID(tenantID)
|
||
detail.NameAbbr = getAbbrFromName(detail.Name)
|
||
|
||
return &detail, nil
|
||
}
|
||
|
||
// 硬编码走 IAP 登录的学校 ID 列表(尽管 joinType 不是 cloud,但实际支持 IAP)
|
||
var iapSchoolIds = map[string]bool{
|
||
"01e270d1-00e2-4ce9-b0af-5d5fbd462b8a": true, // 河北石油职业技术大学
|
||
}
|
||
|
||
// hasUsableLoginUrl 判断学校是否具备可用的登录地址(IAP/CAS 都需要)
|
||
func hasUsableLoginUrl(school *SchoolDetailInfo) bool {
|
||
return school != nil && (school.IdsUrl != "" || school.WecloudUrl != "" ||
|
||
school.AmpUrl != "" || school.AmpUrl2 != "" || school.AmpUrl3 != "")
|
||
}
|
||
|
||
// CpdailyLogin 执行 cpdaily 登录
|
||
// 返回登录成功后的所有 cookies;若触发多因子认证(MFA),返回 (nil, mfaCtx, ErrMFARequired),
|
||
// 调用方应暂存 mfaCtx 并引导用户完成验证码校验(CompleteMFALogin)。
|
||
func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
|
||
// 南昌理工学院:自研 CAS→IAP 混合认证,与通用 IAP/CAS 流程均不兼容,单独适配
|
||
if school.ID == NutSchoolId {
|
||
cookies, err := nutCasIapLogin(ctx, school, username, password)
|
||
return cookies, nil, err
|
||
}
|
||
// 合肥师范学院:学校统一身份认证 SSO 会拒绝单点登录跳转("系统拒绝单点登录跳转"),
|
||
// 但其 wecloud 网关(hfnu.campusphere.net)支持标准 IAP 账密登录,单独走 IAP
|
||
if school.ID == HfnuSchoolId {
|
||
cookies, err := iapLogin(ctx, school, username, password)
|
||
return cookies, nil, err
|
||
}
|
||
// 许昌学院:平台配置 joinType=CLOUD(IsIap=true),但 IAP 账密登录实际不兼容;
|
||
// 学校真实链路为 CAS 账密 + MFA + /iap/loginSuccess 回调,单独强制走 CAS(详见 login_xcu.go)
|
||
if school.ID == XcuSchoolId {
|
||
return xcuCasLogin(ctx, school, username, password)
|
||
}
|
||
if school.IsIap() || iapSchoolIds[school.ID] {
|
||
if !hasUsableLoginUrl(school) {
|
||
slog.Warn("IAP学校缺少登录地址", "school", school.Name, "schoolId", school.ID)
|
||
return nil, nil, fmt.Errorf("该校登录配置不完整(缺少登录地址),暂不支持自动登录,请稍后重试或提交待适配反馈")
|
||
}
|
||
cookies, err := iapLogin(ctx, school, username, password)
|
||
return cookies, nil, err
|
||
}
|
||
if !hasUsableLoginUrl(school) {
|
||
slog.Warn("CAS学校缺少登录地址", "school", school.Name, "schoolId", school.ID)
|
||
return nil, nil, fmt.Errorf("该校暂未开放自动登录(待适配),请稍后重试或提交待适配反馈")
|
||
}
|
||
return casLogin(ctx, school, username, password)
|
||
}
|
||
|
||
// SerializeCookies 将 cookies 序列化为字符串
|
||
func SerializeCookies(cookies []*http.Cookie) string {
|
||
// 转为 map[string]string
|
||
cookieMap := make(map[string]string)
|
||
for _, c := range cookies {
|
||
cookieMap[c.Name] = c.Value
|
||
}
|
||
jsonBytes, _ := json.Marshal(cookieMap)
|
||
return string(jsonBytes)
|
||
}
|
||
|
||
// ========== 学校辅助函数 ==========
|
||
|
||
// 从学校名取简称 (拼音首字母或截取)
|
||
func getAbbrFromName(name string) string {
|
||
// 简化: 取前几个字符
|
||
name = strings.TrimSpace(name)
|
||
if len(name) <= 4 {
|
||
return name
|
||
}
|
||
// 取前4个字符作为简称
|
||
runes := []rune(name)
|
||
if len(runes) > 4 {
|
||
return string(runes[:4])
|
||
}
|
||
return name
|
||
}
|
||
|
||
func getPinYinFromID(id string) string {
|
||
// 这是个简化函数,实际应该从学校列表获取
|
||
return ""
|
||
}
|
||
|
||
// SchoolDomainCode 从学校详情解析域名短码(用于用户名等标识)
|
||
// 如 wecloudUrl=https://xacxxy.campusphere.net → xacxxy
|
||
// 依次尝试 wecloudUrl / ampUrl / idsUrl,均无时返回空串
|
||
func SchoolDomainCode(school *SchoolDetailInfo) string {
|
||
if school == nil {
|
||
return ""
|
||
}
|
||
for _, raw := range []string{school.WecloudUrl, school.AmpUrl, school.IdsUrl} {
|
||
if code := domainCodeOf(raw); code != "" {
|
||
return code
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// domainCodeOf 从 URL 中提取域名短码:
|
||
// 1. host 以 .campusphere.net 结尾时去掉该后缀(xacxxy.campusphere.net → xacxxy)
|
||
// 2. 其他域名取第一段子域(sub.example.com → sub)
|
||
// 3. 无子域则返回整个 host
|
||
func domainCodeOf(raw string) string {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return ""
|
||
}
|
||
if !strings.HasPrefix(raw, "http") {
|
||
raw = "https://" + raw
|
||
}
|
||
u, err := url.Parse(raw)
|
||
if err != nil || u.Host == "" {
|
||
return ""
|
||
}
|
||
host := u.Host
|
||
if strings.HasSuffix(host, ".campusphere.net") {
|
||
return strings.TrimSuffix(host, ".campusphere.net")
|
||
}
|
||
if i := strings.Index(host, "."); i > 0 {
|
||
return host[:i]
|
||
}
|
||
return host
|
||
}
|
||
|
||
// ========== 工具函数 ==========
|
||
|
||
// ParseCookieString 解析 Set-Cookie 头
|
||
func parseCookies(resp *resty.Response) []*http.Cookie {
|
||
return resp.RawResponse.Cookies()
|
||
}
|
||
|
||
// CombineCookies 从多个响应中收集 cookies
|
||
func combineCookies(responses ...*resty.Response) []*http.Cookie {
|
||
seen := make(map[string]*http.Cookie)
|
||
for _, resp := range responses {
|
||
if resp == nil || resp.RawResponse == nil {
|
||
continue
|
||
}
|
||
for _, c := range resp.RawResponse.Cookies() {
|
||
// 同名 cookie 用最新的
|
||
seen[c.Name] = c
|
||
}
|
||
}
|
||
var result []*http.Cookie
|
||
for _, c := range seen {
|
||
result = append(result, c)
|
||
}
|
||
return result
|
||
}
|
||
|
||
// 从 URL 中提取值
|
||
var saltRegexp = regexp.MustCompile(`var\s+pwdDefaultEncryptSalt\s*=\s*["']([^"']+)["']`)
|