Files
cpdaily_xiaozhushou/backed/internal/service/cpdaily/service.go
T
becomer 2e2c161770 feat: init cpdaily-xiaozhushou with blue-green deploy
- 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
2026-07-03 15:37:55 +08:00

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