2e2c161770
- backed/: Go Fiber backend with cpdaily integration - frondend/: Vue 3 + Vite frontend - Dockerfile: multi-stage build (Node → Go → Alpine) - deploy/docker-compose.yml: production compose file - .woodpecker/build.yml: CI/CD with blue-green zero-downtime deploy - /health endpoint for deployment health checks - Port 8884/8885 blue-green swap via 1Panel OpenResty
216 lines
5.7 KiB
Go
216 lines
5.7 KiB
Go
package cpdaily
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"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
|
|
}
|
|
|
|
// CpdailyLogin 执行 cpdaily 登录
|
|
// 返回登录成功后的所有 cookies
|
|
func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
|
|
if school.IsIap() {
|
|
return iapLogin(ctx, school, username, password)
|
|
}
|
|
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 ""
|
|
}
|
|
|
|
// ========== 工具函数 ==========
|
|
|
|
// 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*["']([^"']+)["']`)
|