Files
cpdaily_xiaozhushou/backed/internal/service/cpdaily/service.go
T

296 lines
9.0 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"
"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*["']([^"']+)["']`)