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
266 lines
6.5 KiB
Go
266 lines
6.5 KiB
Go
package cpdaily
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
|
||
"cpdaily-new/internal/config"
|
||
|
||
"github.com/go-resty/resty/v2"
|
||
)
|
||
|
||
// ========== IAP 登录 ==========
|
||
|
||
func iapLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
|
||
sc := newSessionClient()
|
||
loginUrl := getIapValidLoginUrl(school)
|
||
|
||
// 1. 获取 LT (Login Ticket)
|
||
lt, err := getIapLT(ctx, loginUrl, sc)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取LT失败: %w", err)
|
||
}
|
||
|
||
// 2. 获取真实 _lt
|
||
realLT, err := getIapRealLT(ctx, loginUrl, lt, sc.Client)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取真实LT失败: %w", err)
|
||
}
|
||
|
||
// 3. RSA 加密密码
|
||
rsa, err := NewRSAKeyPairFromPublicKey(IapRsaPubKey)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("RSA密钥解析失败: %w", err)
|
||
}
|
||
encPwd, err := rsa.Encrypt(password)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("RSA加密失败: %w", err)
|
||
}
|
||
|
||
// 4. 检测验证码
|
||
captchaCode := ""
|
||
if needIapCaptcha(ctx, loginUrl, realLT, sc.Client) {
|
||
captchaCode = resolveIapCaptcha(ctx, loginUrl, realLT, sc.Client)
|
||
}
|
||
|
||
// 5. 提交登录
|
||
submitData := map[string]string{
|
||
"lt": realLT,
|
||
"rememberMe": "true",
|
||
"dllt": "",
|
||
"mobile": "",
|
||
"captcha": captchaCode,
|
||
"username": username,
|
||
"password": "{rsa}" + encPwd,
|
||
}
|
||
|
||
submitUrls := []string{
|
||
loginUrl + "/iap/doLogin",
|
||
loginUrl + "/doLogin",
|
||
}
|
||
|
||
var loginResp *resty.Response
|
||
var lastErr error
|
||
for _, submitUrl := range submitUrls {
|
||
loginResp, lastErr = sc.R().
|
||
SetFormData(submitData).
|
||
SetContext(ctx).
|
||
Post(submitUrl)
|
||
if lastErr == nil {
|
||
break
|
||
}
|
||
}
|
||
if lastErr != nil {
|
||
return nil, fmt.Errorf("提交IAP登录失败: %w", lastErr)
|
||
}
|
||
defer loginResp.RawBody().Close()
|
||
|
||
// 6. 处理响应
|
||
switch loginResp.StatusCode() {
|
||
case 302:
|
||
return handleIapLoginSuccess(ctx, loginResp, school, sc)
|
||
case 200:
|
||
body, _ := io.ReadAll(loginResp.RawBody())
|
||
var result struct {
|
||
ResultCode int `json:"resultCode"`
|
||
ResultMsg string `json:"resultMsg"`
|
||
}
|
||
if err := json.Unmarshal(body, &result); err == nil {
|
||
switch result.ResultCode {
|
||
case 1:
|
||
return sc.jar.AllCookies(), nil
|
||
case -4:
|
||
return nil, fmt.Errorf("验证码错误")
|
||
case -3:
|
||
return nil, fmt.Errorf("账号或密码错误")
|
||
}
|
||
if result.ResultMsg != "" {
|
||
return nil, fmt.Errorf("%s", result.ResultMsg)
|
||
}
|
||
}
|
||
return nil, fmt.Errorf("IAP登录失败,未知错误")
|
||
default:
|
||
return nil, fmt.Errorf("IAP登录返回异常状态码: %d", loginResp.StatusCode())
|
||
}
|
||
}
|
||
|
||
func getIapValidLoginUrl(school *SchoolDetailInfo) string {
|
||
if school.WecloudUrl != "" {
|
||
return school.WecloudUrl
|
||
}
|
||
return school.AmpUrl
|
||
}
|
||
|
||
func getIapLT(ctx context.Context, baseUrl string, sc *sessionClient) (string, error) {
|
||
sc.SetRedirectPolicy(resty.NoRedirectPolicy())
|
||
resp, err := sc.R().SetContext(ctx).Get(baseUrl + "/portal/login")
|
||
if err != nil {
|
||
resp, err = sc.R().SetContext(ctx).Get(baseUrl + "/iap/login/mobile.html")
|
||
}
|
||
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
||
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer resp.RawBody().Close()
|
||
|
||
if resp.StatusCode() == 302 {
|
||
loc, _ := resp.RawResponse.Location()
|
||
if loc != nil {
|
||
lt := loc.Query().Get("lt")
|
||
if lt != "" {
|
||
return lt, nil
|
||
}
|
||
}
|
||
}
|
||
|
||
body, _ := io.ReadAll(resp.RawBody())
|
||
return extractLTFromPage(string(body)), nil
|
||
}
|
||
|
||
func getIapRealLT(ctx context.Context, baseUrl, lt string, client *resty.Client) (string, error) {
|
||
resp, err := client.R().
|
||
SetFormData(map[string]string{"lt": lt}).
|
||
SetContext(ctx).
|
||
Post(baseUrl + "/iap/security/lt")
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer resp.RawBody().Close()
|
||
|
||
body, _ := io.ReadAll(resp.RawBody())
|
||
var result struct {
|
||
Result struct {
|
||
LT string `json:"_lt"`
|
||
} `json:"result"`
|
||
}
|
||
if err := json.Unmarshal(body, &result); err != nil {
|
||
return "", fmt.Errorf("解析LT响应失败: %w", err)
|
||
}
|
||
if result.Result.LT == "" {
|
||
return "", fmt.Errorf("获取真实LT为空")
|
||
}
|
||
return result.Result.LT, nil
|
||
}
|
||
|
||
func needIapCaptcha(ctx context.Context, baseUrl, lt string, client *resty.Client) bool {
|
||
resp, err := client.R().
|
||
SetFormData(map[string]string{"ltId": lt}).
|
||
SetContext(ctx).
|
||
Post(baseUrl + "/iap/checkNeedCaptcha")
|
||
if err != nil {
|
||
return false
|
||
}
|
||
defer resp.RawBody().Close()
|
||
body, _ := io.ReadAll(resp.RawBody())
|
||
return strings.Contains(string(body), `"needCaptcha":true`)
|
||
}
|
||
|
||
func resolveIapCaptcha(ctx context.Context, baseUrl, lt string, client *resty.Client) string {
|
||
captchaUrl := fmt.Sprintf("%s/iap/generateCaptcha?ltId=%s", baseUrl, lt)
|
||
imgResp, err := client.R().SetContext(ctx).Get(captchaUrl)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
defer imgResp.RawBody().Close()
|
||
imgData, _ := io.ReadAll(imgResp.RawBody())
|
||
return captchaOCR(imgData)
|
||
}
|
||
|
||
func handleIapLoginSuccess(ctx context.Context, resp *resty.Response, school *SchoolDetailInfo, sc *sessionClient) ([]*http.Cookie, error) {
|
||
jumpUrl, err := resp.RawResponse.Location()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取IAP重定向地址失败: %w", err)
|
||
}
|
||
|
||
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
||
verifyResp, err := sc.R().SetContext(ctx).Get(jumpUrl.String())
|
||
if err != nil {
|
||
return nil, fmt.Errorf("IAP跟随重定向失败: %w", err)
|
||
}
|
||
defer verifyResp.RawBody().Close()
|
||
|
||
activateSession(ctx, school.WecloudUrl, sc.Client)
|
||
return sc.jar.AllCookies(), nil
|
||
}
|
||
|
||
func extractLTFromPage(page string) string {
|
||
idx := strings.Index(page, `"lt":"`)
|
||
if idx > 0 {
|
||
end := strings.Index(page[idx+5:], `"`)
|
||
if end > 0 {
|
||
return page[idx+5 : idx+5+end]
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func getHost(rawUrl string) string {
|
||
u, err := url.Parse(rawUrl)
|
||
if err != nil {
|
||
return rawUrl
|
||
}
|
||
return u.Host
|
||
}
|
||
|
||
// ========== OCR 验证码识别 ==========
|
||
|
||
func captchaOCR(imgData []byte) string {
|
||
// 创建专用客户端(不继承 DoNotParseResponse)
|
||
cfg := config.Global.Cpdaily
|
||
resp, err := resty.New().
|
||
SetTimeout(15 * time.Second).
|
||
R().
|
||
SetHeader("X-Api-Key", cfg.CaptchaApiKey).
|
||
SetFileReader("file", "captcha.jpg", bytes.NewReader(imgData)).
|
||
Post(cfg.CaptchaBaseUrl + "/ocr")
|
||
if err != nil {
|
||
slog.Warn("OCR请求失败", "err", err)
|
||
return ""
|
||
}
|
||
|
||
var result struct {
|
||
Code int `json:"code"`
|
||
Data string `json:"data"`
|
||
}
|
||
bodyStr := resp.String()
|
||
if err := json.Unmarshal([]byte(bodyStr), &result); err != nil {
|
||
slog.Warn("OCR响应解析失败", "err", err, "body", bodyStr)
|
||
return ""
|
||
}
|
||
if result.Code != 200 {
|
||
slog.Warn("OCR识别失败", "code", result.Code, "data", result.Data)
|
||
return ""
|
||
}
|
||
slog.Info("OCR识别结果", "data", result.Data)
|
||
return result.Data
|
||
}
|