Compare commits

...

8 Commits

Author SHA1 Message Date
RecordingTheSmile 09e7a82882 fix: iap login failed message 2025-11-16 19:51:35 +08:00
sugarBecomer 1fff983d37 🐛 尝试干掉submitData的userId看看是否解决 2025-11-16 19:14:32 +08:00
RecordingTheSmile 836e61b8b0 fix: duplicate asynq.Unique option. 2025-11-14 22:36:01 +08:00
sugarBecomer 96044b8a88 Merge remote-tracking branch 'origin/main' 2025-11-14 22:35:32 +08:00
sugarBecomer acb865596e 🐛 取消定时任务cookie检测,强制登录 2025-11-14 22:35:15 +08:00
RecordingTheSmile 59559f4201 fix: task id unique 2025-11-14 22:26:10 +08:00
sugarBecomer 4d516adea8 ✨ 新增适配安徽建筑大学登录 2025-11-14 21:48:56 +08:00
sugarBecomer a37368ddcf ✨ 新增适配山东华宇工学院滑块 2025-11-14 20:38:58 +08:00
12 changed files with 312 additions and 27 deletions
+61 -2
View File
@@ -3,17 +3,47 @@ package cookiejar
import (
"bytes"
"encoding/gob"
"fmt"
"github.com/bytedance/sonic"
"io"
"likedream-goframe/internal/utils"
"net/http"
"net/url"
"strings"
"sync"
)
type hostMap map[string]cookieMap
type cookieMap map[string]*http.Cookie
// cookieKey 生成 cookie 的唯一标识 key,使用 name 和 path 的组合
func cookieKey(name, path string) string {
return fmt.Sprintf("%s|%s", name, path)
}
// pathMatch 检查请求路径是否匹配 cookie 的 path
// 根据 RFC 6265 Section 5.1.4
func pathMatch(requestPath, cookiePath string) bool {
// 如果路径完全相同
if requestPath == cookiePath {
return true
}
// 如果 cookie path 是请求 path 的前缀
if strings.HasPrefix(requestPath, cookiePath) {
// cookie path 以 / 结尾,或者请求 path 在 cookie path 后紧跟 /
if strings.HasSuffix(cookiePath, "/") {
return true
}
if len(requestPath) > len(cookiePath) && requestPath[len(cookiePath)] == '/' {
return true
}
}
return false
}
type PersistCookieJar struct {
hostMap hostMap
*sync.RWMutex
@@ -40,7 +70,23 @@ func (p *PersistCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
if p.hostMap[host] == nil {
p.hostMap[host] = make(cookieMap)
}
p.hostMap[host][cookie.Name] = cookie
// 如果 cookie 没有设置 Path,使用默认值 "/"
cookiePath := cookie.Path
if cookiePath == "" {
cookiePath = "/"
}
// 使用 name 和 path 的组合作为 key
key := cookieKey(cookie.Name, cookiePath)
// 如果 MaxAge=0 或 MaxAge<0,表示删除 cookie
// 只删除完全匹配的 cookie(name 和 path 都相同)
if cookie.MaxAge < 0 || (cookie.MaxAge == 0 && cookie.Value == "") {
delete(p.hostMap[host], key)
} else {
p.hostMap[host][key] = cookie
}
}
}
@@ -50,10 +96,23 @@ func (p *PersistCookieJar) Cookies(u *url.URL) []*http.Cookie {
defer p.RUnlock()
host := u.Host
requestPath := u.Path
if requestPath == "" {
requestPath = "/"
}
if cookies, exists := p.hostMap[host]; exists {
result := make([]*http.Cookie, 0, len(cookies))
for _, cookie := range cookies {
result = append(result, cookie)
// 检查 cookie 的 path 是否匹配请求的 path
cookiePath := cookie.Path
if cookiePath == "" {
cookiePath = "/"
}
if pathMatch(requestPath, cookiePath) {
result = append(result, cookie)
}
}
return result
}
@@ -0,0 +1,12 @@
package cryptoutil
import (
"crypto/md5"
"encoding/hex"
)
// MD5 对输入字符串进行 MD5 加密,返回 32 位小写十六进制字符串
func MD5(input string) string {
hash := md5.Sum([]byte(input))
return hex.EncodeToString(hash[:])
}
+209
View File
@@ -0,0 +1,209 @@
package login
import (
"bytes"
"context"
"fmt"
"io"
"likedream-goframe/internal/application/cookiejar"
"likedream-goframe/internal/application/cpdaily/cryptoutil"
"likedream-goframe/internal/application/cpdaily/school"
"likedream-goframe/internal/application/httpclient"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/utils"
"net/http"
"net/url"
"github.com/PuerkitoBio/goquery"
"github.com/samber/oops"
"resty.dev/v3"
)
// 南阳理工学院登录实现 RSA加密 & 验证码必出
type CpdailyAhjzuLogin struct {
client *resty.Client
}
func (c CpdailyAhjzuLogin) Login(ctx context.Context, schoolDetail *school.SchoolDetailInfo, username, password string) {
jar := cookiejar.NewPersistCookieJar()
c.client.SetCookieJar(jar).SetRedirectPolicy(resty.NoRedirectPolicy())
// 获取合法的登录URL
loginUrl := getCasValidLoginUrl(schoolDetail)
// 请求登录页面并获取真正的登录URL
loginPageResponse := getAhjzuLoginPageResponse(ctx, c.client, loginUrl)
defer loginPageResponse.Body.Close()
loginPageBytes, err := io.ReadAll(loginPageResponse.Body)
utils.OkOrPanic(err)
loginResponseUrl := loginPageResponse.RawResponse.Request.URL
// 解析登录页面
loginPage, err := goquery.NewDocumentFromReader(bytes.NewReader(loginPageBytes))
utils.OkOrPanic(err)
// 判断登录类型
var (
allInputs = map[string]string{}
fm1Node = loginPage.Find("#credentials").First()
)
inputs := fm1Node.Find("input")
for _, input := range inputs.EachIter() {
name, exists := input.Attr("name")
if !exists {
continue
}
value, _ := input.Attr("value")
allInputs[name] = value
}
allInputs["username"] = username
// 处理密码
allInputs["password"] = cryptoutil.MD5(password)
// 解决常规图片验证码
captchaUrl := getAhjzuCaptchaUrl(loginResponseUrl)
captchaResult := ResolveOcrCaptcha(ctx, captchaUrl, c.client)
allInputs["validateCode"] = captchaResult
// 登录提交
loginPostResponse, err := c.client.R().
SetFormData(allInputs).
WithContext(ctx).
Post(loginResponseUrl.String())
utils.OkOrPanic(err, func(err error) error {
return oops.
WithContext(ctx).
With("loginResponseUrl", loginResponseUrl).
With("allInputs", allInputs).
Wrap(err)
})
defer loginPostResponse.Body.Close()
switch loginPostResponse.StatusCode() {
case 302:
handleCasLoginSuccess(ctx, loginPostResponse, schoolDetail, username, password, c.client)
case 200:
fallthrough
case 401:
doc, err := goquery.NewDocumentFromReader(loginPostResponse.Body)
utils.OkOrPanic(err, func(err error) error {
return oops.
WithContext(ctx).
With("loginResponseUrl", loginResponseUrl).
With("allInputs", allInputs).
Wrap(err)
})
if isCasNeedKickOut(doc) {
kickoutResponse := submitCasKickOut(ctx, loginResponseUrl.String(), doc, c.client)
handleCasLoginSuccess(ctx, kickoutResponse, schoolDetail, username, password, c.client)
return
}
message := getCasErrorMessage(doc)
utils.OkOrPanic(errors.NewBusinessError(503, fmt.Sprintf("今日校园登录失败: %s", message)), func(err error) error {
return oops.
WithContext(ctx).
With("loginResponseUrl", loginResponseUrl).
With("allInputs", allInputs).
With("errorMessage", message).
Wrap(errors.NewBusinessError(503, message))
})
}
}
func getAhjzuLoginPageResponse(ctx context.Context, client *resty.Client, loginUrl string) *resty.Response {
res1, err := client.R().Get(loginUrl)
utils.OkOrPanic(err, func(err error) error {
return oops.
With("loginUrl", loginUrl).
WithContext(ctx).
Wrap(err)
})
if res1.StatusCode() != http.StatusFound && res1.StatusCode() != http.StatusMovedPermanently {
utils.OkOrPanic(errors.NewBusinessError(503, "今日校园登录失败: 登录页面获取失败"), func(err error) error {
return oops.
With("loginUrl", loginUrl).
With("statusCode", res1.StatusCode()).
WithContext(ctx).
Wrap(err)
})
}
location := res1.RawResponse.Header.Get("Location")
res2, err := client.R().Get(location)
if res2.StatusCode() != http.StatusFound && res2.StatusCode() != http.StatusMovedPermanently {
utils.OkOrPanic(errors.NewBusinessError(503, "今日校园登录失败: 登录页面获取失败"), func(err error) error {
return oops.
With("loginUrl", loginUrl).
With("statusCode", res2.StatusCode()).
WithContext(ctx).
Wrap(err)
})
}
location = res2.RawResponse.Header.Get("Location")
res3, err := client.R().Get(location)
if res3.StatusCode() != http.StatusFound && res3.StatusCode() != http.StatusMovedPermanently {
utils.OkOrPanic(errors.NewBusinessError(503, "今日校园登录失败: 登录页面获取失败"), func(err error) error {
return oops.
With("loginUrl", loginUrl).
With("statusCode", res3.StatusCode()).
WithContext(ctx).
Wrap(err)
})
}
location = res3.RawResponse.Header.Get("Location")
res4, err := client.R().Get(location)
if res4.StatusCode() != http.StatusFound && res3.StatusCode() != http.StatusMovedPermanently {
utils.OkOrPanic(errors.NewBusinessError(503, "今日校园登录失败: 登录页面获取失败"), func(err error) error {
return oops.
With("loginUrl", loginUrl).
With("statusCode", res4.StatusCode()).
WithContext(ctx).
Wrap(err)
})
}
location = res4.RawResponse.Header.Get("Location")
res5, err := client.R().Get(location)
if err != nil {
utils.OkOrPanic(errors.NewBusinessError(503, "今日校园登录失败: 登录页面获取失败"), func(err error) error {
return oops.
With("loginUrl", loginUrl).
With("statusCode", res4.StatusCode()).
WithContext(ctx).
Wrap(err)
})
}
return res5
}
func getAhjzuCaptchaUrl(url *url.URL) string {
return fmt.Sprintf("%s://%s/sso/code/code.jsp", url.Scheme, url.Host)
}
func (c CpdailyAhjzuLogin) GetCookieJar() http.CookieJar {
return c.client.CookieJar()
}
func (c CpdailyAhjzuLogin) GetCookieString() string {
cookieJar := c.GetCookieJar()
serializableJar := cookieJar.(*cookiejar.PersistCookieJar)
return serializableJar.Serialize()
}
func (c CpdailyAhjzuLogin) GetClient() *resty.Client {
return c.client
}
func NewAhjzu(ctx context.Context) *CpdailyAhjzuLogin {
return &CpdailyAhjzuLogin{
client: httpclient.Cpdaily.Clone(ctx),
}
}
@@ -10,6 +10,9 @@ func GetCpdailyLoginBySchoolInfo(ctx context.Context, info *school.SchoolDetailI
// 南昌理工学院
case "d6e8f2ca-10b2-4599-bdb8-4b33d419658d":
return NewNut(ctx)
// 安徽建筑大学
case "ahjzu":
return NewAhjzu(ctx)
default:
if info.IsIap() {
return NewIap(ctx)
+3 -1
View File
@@ -190,7 +190,9 @@ func (c *CpdailyCasLogin) Login(ctx context.Context, schoolDetail *school.School
} else {
allInputs["password"] = encryptCasPassword(password, salt)
}
var SliderCaptchaIds = []string{"1018764144934965"}
// 声明需要滑块验证码的学校id
var SliderCaptchaIds = []string{"1018764144934965", "sdhyxy"}
// 判断是否需要验证码
if isCasNeedCaptcha(ctx, loginResponseUrl, loginType, username, c.client) {
// 如果是滑块验证码
+3 -4
View File
@@ -172,12 +172,11 @@ func (c *CpdailyIapLogin) Login(ctx context.Context, schoolDetail *school.School
In("Cpdaily.Login").New("Iap登录失败, 用户名或密码错误")
})
default:
utils.OkOrPanic(errors.NewBusinessError(503, fmt.Sprintf("Iap登录失败, 状态识别码: %s", resultCode)), func(err error) error {
return oops.With("username", username).
utils.OkOrPanic(errors.NewBusinessError(503, fmt.Sprintf("Iap登录失败, 状态识别码: %s", resultCode)),
oops.With("username", username).
With("password", password).
With("userId", utils.GetUserIdNotPanic(ctx)).
In("Cpdaily.Login").New("Iap登录失败, 状态识别码: %s" + resultCode)
})
In("Cpdaily.Login").Wrap)
}
}
}
@@ -640,11 +640,11 @@ func (c *CpdailySleepCheckTask) Submit(ctx context.Context, form map[string]*gva
// 构造提交数据
submitData := map[string]*gvar.Var{
"lon": form["longitude"],
"version": gvar.New(cryptoutil.EncryptVersion),
"calVersion": gvar.New(cpdailyInfo.CalVersion),
"deviceId": extensionForm["deviceId"],
"userId": gvar.New(username),
"lon": form["longitude"],
"version": gvar.New(cryptoutil.EncryptVersion),
"calVersion": gvar.New(cpdailyInfo.CalVersion),
"deviceId": extensionForm["deviceId"],
//"userId": gvar.New(username),
"systemName": gvar.New("android"),
"bodyString": gvar.New(bodyString),
"lat": form["latitude"],
+3 -2
View File
@@ -10,11 +10,12 @@ import (
var Cpdaily = resty.New().
SetCookieJar(nil).
SetTimeout(30 * time.Second).
SetTimeout(30*time.Second).
SetDoNotParseResponse(true).
SetTLSClientConfig(&tls.Config{
InsecureSkipVerify: true,
})
}).
SetHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36")
func init() {
if !utils.IsDebug() {
@@ -96,13 +96,14 @@ func (c *CpdailyTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task)
schoolDetail := school.GetSchoolDetail(ctx, targetTask.Edges.CpUser.SchoolID)
cookie := targetTask.Edges.CpUser.Cookie
restyClient := login.GetClient(ctx, cookie)
if !login.CookieEnabled(ctx, schoolDetail.WecloudUrl, restyClient) {
loginExecutor := login.GetCpdailyLoginBySchoolInfo(ctx, &schoolDetail)
loginExecutor.Login(ctx, &schoolDetail, targetTask.Edges.CpUser.Username, targetTask.Edges.CpUser.Password)
cookie = loginExecutor.GetCookieString()
db.Global.CpUser.UpdateOneID(targetTask.Edges.CpUser.ID).SetCookie(cookie).ExecX(ctx)
restyClient = login.GetClient(ctx, cookie)
}
//if !login.CookieEnabled(ctx, schoolDetail.WecloudUrl, restyClient) {
// 定时任务强制登录并更新cookie
loginExecutor := login.GetCpdailyLoginBySchoolInfo(ctx, &schoolDetail)
loginExecutor.Login(ctx, &schoolDetail, targetTask.Edges.CpUser.Username, targetTask.Edges.CpUser.Password)
cookie = loginExecutor.GetCookieString()
db.Global.CpUser.UpdateOneID(targetTask.Edges.CpUser.ID).SetCookie(cookie).ExecX(ctx)
restyClient = login.GetClient(ctx, cookie)
//}
// 获取任务执行handler
taskExecutor := task2.GetCpdailyTaskService(ctx, targetTask.TaskType, restyClient, schoolDetail)
+2 -3
View File
@@ -5,7 +5,6 @@ import (
"context"
"encoding/gob"
"errors"
"fmt"
cst2 "likedream-goframe/internal/common/cst"
"likedream-goframe/internal/common/db"
"likedream-goframe/internal/ent"
@@ -124,10 +123,10 @@ func (ts *TaskScanner) scanCpdailyTask() {
e = utils.Catch(func() {
queue.AddTask(ts.ctx, asynq.NewTask(cst2.TaskCpdaily, payloadWriter.Bytes()),
asynq.TaskID(fmt.Sprintf(cst2.TaskCpdailyId, task.ID)),
asynq.Unique(20*time.Minute),
asynq.MaxRetry(4),
asynq.ProcessAt(execTime),
asynq.Deadline(execTime.Add(10*time.Minute)),
asynq.Timeout(10*time.Minute),
asynq.Queue(cst2.TaskQueueCpdaily),
)
// 更新未开始的任务状态为队列中
+2 -3
View File
@@ -260,11 +260,10 @@ func (v CpTaskServiceV1) ExecuteNow(ctx context.Context, req *v1.ExecuteNowReq)
// 使用时间戳生成唯一的 TaskID,避免与自动扫描的任务冲突,也避免 Unique 限制
// 由于已经检查了任务状态,不需要使用 Unique 来防重复
taskID := fmt.Sprintf("cpdaily:task:manual:%d:%d", req.Id, time.Now().UnixNano())
//taskID := fmt.Sprintf("cpdaily:task:manual:%d:%d", req.Id, time.Now().UnixNano())
e = utils.Catch(func() {
queue.AddTask(ctx, asynq.NewTask(cst2.TaskCpdaily, payloadWriter.Bytes()),
asynq.TaskID(taskID),
asynq.Unique(5*time.Second),
asynq.Unique(20*time.Minute),
asynq.MaxRetry(0),
asynq.Timeout(10*time.Minute),
asynq.Deadline(time.Now().Add(10*time.Minute)),
+1
View File
@@ -22,6 +22,7 @@ func main() {
if err != nil {
log.Panic(err)
}
time.Local = cst.TimeZoneShangHai
args := ""
if len(os.Args) > 1 {
args = os.Args[1]