Compare commits

...

11 Commits

Author SHA1 Message Date
sugarBecomer ac6409f3e2 🐛 opencv 2025-11-12 15:39:20 +08:00
sugarBecomer 36a6932056 🐛 opencv 2025-11-12 15:19:15 +08:00
sugarBecomer 72dab0e968 🐛 opencv 2025-11-12 15:01:33 +08:00
sugarBecomer b716434f62 🐛 opencv 2025-11-12 14:45:25 +08:00
sugarBecomer 8ed7dd77e4 🐛 opencv 2025-11-12 14:30:03 +08:00
sugarBecomer ae1c9c695e 🐛 签到问题 & opencv 2025-11-12 14:12:47 +08:00
sugarBecomer 66995c0c78 🐛 cas滑块验证码 2025-11-12 13:35:17 +08:00
sugarBecomer 120e690795 🐛 cas滑块验证码 2025-11-12 13:31:54 +08:00
sugarBecomer bc2bb25578 🐛 签到bug 2025-11-11 22:34:06 +08:00
RecordingTheSmile b37fc5cb24 fix: 签到额外信息填写问题 2025-11-11 22:29:01 +08:00
sugarBecomer 614dd9917f 🐛 签到bug 2025-11-11 22:07:57 +08:00
11 changed files with 392 additions and 93 deletions
+22
View File
@@ -1,6 +1,16 @@
FROM golang:latest AS backend-builder
RUN apt update
RUN apt install -y build-essential
# 安装 OpenCV 开发库和依赖
RUN apt install -y \
libopencv-dev \
libopencv-core-dev \
libopencv-imgproc-dev \
libopencv-imgcodecs-dev \
libopencv-videoio-dev \
pkg-config
WORKDIR /build
COPY . .
ENV CGO_ENABLED=1
@@ -10,7 +20,19 @@ RUN apt install -y upx-ucl
RUN upx --best --lzma bin/server
# 自动收集二进制文件依赖的所有 .so 文件
RUN mkdir -p /deps/libs && \
ldd bin/server | grep "=> /" | awk '{print $3}' | while read lib; do \
if [ -f "$lib" ]; then \
cp -L "$lib" /deps/libs/ 2>/dev/null || true; \
fi \
done
FROM golang:latest
# 复制所有依赖的 .so 文件
COPY --from=backend-builder /deps/libs/* /usr/lib/x86_64-linux-gnu/
COPY --from=backend-builder /build/bin/server /app/server
#COPY static /app/static
#COPY static/*.so /app/
+1
View File
@@ -33,6 +33,7 @@ require (
github.com/yalue/onnxruntime_go v1.12.1
go.uber.org/automaxprocs v1.6.0
go.uber.org/zap v1.27.0
gocv.io/x/gocv v0.42.0
golang.org/x/crypto v0.43.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
+2
View File
@@ -244,6 +244,8 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
gocv.io/x/gocv v0.42.0 h1:AAsrFJH2aIsQHukkCovWqj0MCGZleQpVyf5gNVRXjQI=
gocv.io/x/gocv v0.42.0/go.mod h1:zYdWMj29WAEznM3Y8NsU3A0TRq/wR/cy75jeUypThqU=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+35 -9
View File
@@ -16,10 +16,12 @@ import (
"net/http"
"net/url"
"regexp"
"slices"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/avast/retry-go/v4"
"github.com/deatil/go-cryptobin/cryptobin/crypto"
"github.com/samber/oops"
"github.com/tidwall/gjson"
@@ -188,20 +190,35 @@ func (c *CpdailyCasLogin) Login(ctx context.Context, schoolDetail *school.School
} else {
allInputs["password"] = encryptCasPassword(password, salt)
}
var SliderCaptchaIds = []string{"1018764144934965"}
// 判断是否需要验证码
if isCasNeedCaptcha(ctx, loginResponseUrl, loginType, username, c.client) {
// 解决验证码
captchaUrl := getCasCaptchaUrl(loginResponseUrl, loginType)
// 如果是滑块验证码
if slices.Contains(SliderCaptchaIds, schoolDetail.Id) {
e := retry.Do(func() error {
return utils.Catch(func() {
captchaUrl := getCasSliderCaptchaUrl(loginResponseUrl)
verifyUrl := getCasSliderVerifyUrl(loginResponseUrl)
ResolveCasSliderCaptcha(ctx, captchaUrl, verifyUrl, c.client)
})
}, retry.MaxDelay(15*time.Second), retry.Attempts(5), retry.Context(ctx))
if e != nil {
utils.OkOrPanic(errors.NewBusinessError(503, "滑块验证码解析失败").WithInnerError(e), oops.WithContext(ctx).Wrap)
}
} else {
// 解决常规图片验证码
captchaUrl := getCasCaptchaUrl(loginResponseUrl, loginType)
captchaResult := ResolveOcrCaptcha(ctx, captchaUrl, c.client)
captchaResult := ResolveOcrCaptcha(ctx, captchaUrl, c.client)
switch loginType {
case 0:
allInputs["captchaResponse"] = captchaResult
default:
allInputs["captcha"] = captchaResult
switch loginType {
case 0:
allInputs["captchaResponse"] = captchaResult
default:
allInputs["captcha"] = captchaResult
}
}
}
loginPostResponse, err := c.client.R().
@@ -529,3 +546,12 @@ func getCasCaptchaUrl(url *url.URL, loginType int) string {
return fmt.Sprintf("%s://%s/authserver/getCaptcha.htl", url.Scheme, url.Host)
}
}
func getCasSliderCaptchaUrl(url *url.URL) string {
// /authserver/common/openSliderCaptcha.htl?_=1762873725346
return fmt.Sprintf("%s://%s/authserver/common/openSliderCaptcha.htl", url.Scheme, url.Host)
}
func getCasSliderVerifyUrl(url *url.URL) string {
return fmt.Sprintf("%s://%s/authserver/common/verifySliderCaptcha.htl", url.Scheme, url.Host)
}
+40 -58
View File
@@ -2,71 +2,53 @@ package login
import (
"context"
"fmt"
"io"
"likedream-goframe/internal/application/httpclient"
"likedream-goframe/internal/common/cst"
"likedream-goframe/internal/application/recognizer/ddddocr"
"likedream-goframe/internal/framework/logger"
"likedream-goframe/internal/utils"
"os"
"github.com/gogf/gf/v2/frame/g"
"github.com/samber/oops"
"github.com/tidwall/gjson"
"go.uber.org/zap"
"resty.dev/v3"
)
func ResolveOcrCaptcha(ctx context.Context, url string, client *resty.Client) string {
captchaResponse, err := client.R().
WithContext(ctx).
Get(url)
utils.OkOrPanic(err, func(err error) error {
return oops.
With("captchaUrl", url).
WithContext(ctx).
Wrap(err)
})
defer func() {
_ = captchaResponse.Body.Close()
}()
ocrUrl := g.Cfg().MustGetWithEnv(ctx, cst.ConfigOcrServerUrl).String()
ocrResponse, err := httpclient.Common.R().
SetMultipartFields(&resty.MultipartField{
Name: "file",
FileName: "captcha.jpg",
ContentType: captchaResponse.Header().Get("Content-Type"),
Reader: captchaResponse.Body,
}).
WithContext(ctx).
Post(ocrUrl + "/ocr")
utils.OkOrPanic(err, func(err error) error {
return oops.
With("captchaUrl", url).
WithContext(ctx).
Wrap(err)
})
defer func() {
_ = ocrResponse.Body.Close()
}()
responseBytes, err := io.ReadAll(ocrResponse.Body)
utils.OkOrPanic(err)
responseJSON := gjson.ParseBytes(responseBytes)
data := responseJSON.Get("data").String()
if data == "" {
utils.OkOrPanic(oops.
In("ResolveOcrCaptcha").
With("captchaUrl", url).
With("message", responseJSON.Get("message").String()).
WithContext(ctx).
New("OCR服务返回数据为空"))
}
codePath := DownCode2File(ctx, url, client)
img, _ := utils.ReadImg(codePath) //读取验证码图片
data := ddddocr.AutoOCRVerification(img)
return data
}
func DownCode2File(ctx context.Context, url string, client *resty.Client) string {
res, err := client.R().
WithContext(ctx).
Get(url)
utils.OkOrPanic(err, func(err error) error {
return oops.
With("captchaUrl", url).
WithContext(ctx).
Wrap(err)
})
defer res.Body.Close()
codeFileName := "code" + utils.GenerateRandomString(16) //生成验证码文件名称
codeFileName += ".png"
utils.PathExistForCreate("./assets/code/") //检测是否存在路径,如果不存在则创建
filepath := fmt.Sprintf("./assets/code/%s", codeFileName)
file, err := os.Create(filepath)
if err != nil {
logger.Logger.Error("生成验证码失败", zap.Error(err))
return ""
}
_, err = io.Copy(file, res.Body)
if err != nil {
logger.Logger.Error("生成验证码失败", zap.Error(err))
return ""
}
file.Close()
return filepath
}
@@ -0,0 +1,67 @@
package login
import (
"context"
"fmt"
"io"
"likedream-goframe/internal/application/recognizer/slider"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/utils"
"github.com/bytedance/sonic"
"github.com/samber/oops"
"resty.dev/v3"
)
type CasSliderCaptchaReponse struct {
BigImage string `json:"bigImage"`
SmallImage string `json:"smallImage"`
TagWidth int `json:"tagWidth"`
YHeight int `json:"yHeight"`
}
type CasSliderResult struct {
ErrorCode int `json:"errorCode"`
ErrorMsg string `json:"errorMsg"`
}
func ResolveCasSliderCaptcha(ctx context.Context, captchaUrl string, verifyUrl string, client *resty.Client) {
res, err := client.R().
WithContext(ctx).
Get(captchaUrl)
utils.OkOrPanic(err, func(err error) error {
return oops.
With("captchaUrl", captchaUrl).
WithContext(ctx).
Wrap(err)
})
defer res.Body.Close()
bytes, _ := io.ReadAll(res.Body)
var captcha CasSliderCaptchaReponse
sonic.Unmarshal(bytes, &captcha)
width := slider.VerifySliderWidthByBase64(captcha.BigImage, captcha.SmallImage)
res2, err2 := client.R().
WithContext(ctx).
SetFormData(map[string]string{
"canvasLength": "280",
"moveLength": fmt.Sprintf("%d", width),
}).
Post(verifyUrl)
utils.OkOrPanic(err2, func(err error) error {
return oops.
With("verifyUrl", verifyUrl).
WithContext(ctx).
Wrap(err)
})
defer res2.Body.Close()
bytes, _ = io.ReadAll(res2.Body)
str := string(bytes)
if len(str) > 0 {
var result CasSliderResult
sonic.Unmarshal(bytes, &result)
if result.ErrorCode != 1 {
utils.OkOrPanic(oops.WithContext(ctx).Wrap(errors.NewBusinessError(500, "滑块解析错误")))
}
}
}
+14 -17
View File
@@ -384,18 +384,14 @@ func (c *CpdailyAutoSignTask) ParseFormJson(ctx context.Context, rawJson map[str
}
list = append(list, dto)
var current uint = 1
if isPhoto := rows["isPhoto"]; isPhoto != nil && isPhoto.Int() == 1 {
dto = &formtype.CpTaskFormItem{
Title: "签到照片",
ColName: "signImg",
Type: formtype.ItemTypeImage,
Required: true,
Number: current,
}
list = append(list, dto)
current++
}
// 是否范围外签到
@@ -404,7 +400,6 @@ func (c *CpdailyAutoSignTask) ParseFormJson(ctx context.Context, rawJson map[str
ColName: "isMalposition",
Type: formtype.ItemTypeSingle,
Required: true,
Number: current,
Options: []formtype.CpTaskFormItemOption{
{
Content: "是",
@@ -418,7 +413,6 @@ func (c *CpdailyAutoSignTask) ParseFormJson(ctx context.Context, rawJson map[str
},
}
list = append(list, dto)
current++
// 范围外签到原因
dto = &formtype.CpTaskFormItem{
@@ -427,16 +421,15 @@ func (c *CpdailyAutoSignTask) ParseFormJson(ctx context.Context, rawJson map[str
Type: formtype.ItemTypeInput,
Required: true,
LogicWid: "abnormalReason",
Number: current,
}
list = append(list, dto)
current++
current := uint(1)
// 处理额外字段
if isNeedExtra := rows["isNeedExtra"]; isNeedExtra != nil && isNeedExtra.Int() == 1 {
extraField := rows["extraField"].Array()
for i, field := range extraField {
fieldObj := field.(map[string]*gvar.Var)
for _, field := range extraField {
fieldObj := gvar.New(field).MapStrVar()
title := fieldObj["title"].String()
isRequired := fieldObj["isRequired"].Bool()
hasOtherItems := fieldObj["hasOtherItems"].Bool()
@@ -445,16 +438,17 @@ func (c *CpdailyAutoSignTask) ParseFormJson(ctx context.Context, rawJson map[str
Title: title,
ColName: title,
Type: formtype.ItemTypeSingle,
Number: uint(i),
Number: current,
Required: isRequired,
IsOther: hasOtherItems,
}
current++
// 处理选项
extraFieldItems := fieldObj["extraFieldItems"].Array()
var options []formtype.CpTaskFormItemOption
for _, item := range extraFieldItems {
itemObj := item.(map[string]*gvar.Var)
itemObj := gvar.New(item).MapStrVar()
option := formtype.CpTaskFormItemOption{
Content: itemObj["content"].String(),
IsOtherItems: itemObj["isOtherItems"].Bool(),
@@ -554,7 +548,9 @@ func (c *CpdailyAutoSignTask) FillCustomForm(ctx context.Context, form map[strin
ticket = submitCaptchaResolver.GetCaptchaTicket(ctx, captchaInfo)
})
}, retry.MaxDelay(15*time.Second), retry.Attempts(5), retry.Context(ctx))
utils.OkOrPanic(errors.NewBusinessError(503, "提交验证码识别失败").WithInnerError(e), oops.WithContext(ctx).Wrap)
if e != nil {
utils.OkOrPanic(errors.NewBusinessError(503, "提交验证码识别失败").WithInnerError(e), oops.WithContext(ctx).Wrap)
}
resultForm["ticket"] = gvar.New(ticket)
} else {
@@ -577,8 +573,9 @@ func (c *CpdailyAutoSignTask) FillCustomForm(ctx context.Context, form map[strin
var extraFieldItemValues []map[string]*gvar.Var
for i, field := range extraField {
current := uint(i + 1)
// 检查数字是否在范围内
found := slices.Contains(numbers, uint(i))
found := slices.Contains(numbers, current)
if !found {
utils.OkOrPanic(errors.NewBusinessError(500, "非法的额外信息数据"), oops.WithContext(ctx).Wrap)
}
@@ -586,7 +583,7 @@ func (c *CpdailyAutoSignTask) FillCustomForm(ctx context.Context, form map[strin
// 找到对应的参数
var targetParam *formtype.CpTaskFormItem
for _, v := range paramList {
if v.Number == uint(i) {
if v.Number == current {
targetParam = v
break
}
@@ -596,7 +593,7 @@ func (c *CpdailyAutoSignTask) FillCustomForm(ctx context.Context, form map[strin
continue
}
fieldObj := field.(map[string]*gvar.Var)
fieldObj := gvar.New(field).MapStrVar()
fieldTitle := fieldObj["title"].String()
if targetParam.Title != fieldTitle {
@@ -607,7 +604,7 @@ func (c *CpdailyAutoSignTask) FillCustomForm(ctx context.Context, form map[strin
var extraFieldItemValue map[string]*gvar.Var
for _, item := range extraFieldItems {
itemObj := item.(map[string]*gvar.Var)
itemObj := gvar.New(item).MapStrVar()
content := itemObj["content"].String()
if content == targetParam.Content {
@@ -463,18 +463,15 @@ func (c *CpdailySleepCheckTask) ParseFormJson(ctx context.Context, rawJson map[s
}
list = append(list, dto)
var current uint = 1
if isPhoto := rows["isPhoto"]; isPhoto != nil && isPhoto.Int() == 1 {
dto = &formtype.CpTaskFormItem{
Title: "查寝照片",
ColName: "signImg",
Type: formtype.ItemTypeImage,
Required: true,
Number: current,
}
list = append(list, dto)
current++
}
// 是否范围外签到
@@ -483,7 +480,6 @@ func (c *CpdailySleepCheckTask) ParseFormJson(ctx context.Context, rawJson map[s
ColName: "isMalposition",
Type: formtype.ItemTypeSingle,
Required: true,
Number: current,
Options: []formtype.CpTaskFormItemOption{
{
Content: "是",
@@ -497,7 +493,6 @@ func (c *CpdailySleepCheckTask) ParseFormJson(ctx context.Context, rawJson map[s
},
}
list = append(list, dto)
current++
for key, item := range list {
list[key].Show = item.LogicWid == ""
File diff suppressed because one or more lines are too long
@@ -0,0 +1,192 @@
package slider
import (
"encoding/base64"
"os"
"gocv.io/x/gocv"
)
func VerifySliderWidth(bgImgPath, sliderImgPath string) int {
// 检查背景图片文件是否存在
if _, err := os.Stat(bgImgPath); os.IsNotExist(err) {
return -1
}
// 检查滑块图片文件是否存在
if _, err := os.Stat(sliderImgPath); os.IsNotExist(err) {
return -1
}
// 读取背景图片
bgImg := gocv.IMRead(bgImgPath, gocv.IMReadColor)
if bgImg.Empty() {
return -1
}
defer bgImg.Close()
// 读取滑块图片(包含 alpha 通道)
sliderImgWithAlpha := gocv.IMRead(sliderImgPath, gocv.IMReadUnchanged)
if sliderImgWithAlpha.Empty() {
return -1
}
defer sliderImgWithAlpha.Close()
// 分离滑块图片的通道
channels := gocv.Split(sliderImgWithAlpha)
defer func() {
for i := range channels {
channels[i].Close()
}
}()
var sliderImg gocv.Mat
var mask gocv.Mat
// 如果有 alpha 通道(4通道),提取 mask
if len(channels) == 4 {
// 前3个通道是 BGR
sliderImg = gocv.NewMat()
defer sliderImg.Close()
gocv.Merge(channels[:3], &sliderImg)
// 使用 alpha 通道作为 mask
mask = channels[3].Clone()
defer mask.Close()
} else if len(channels) == 3 {
// 没有透明通道,直接使用原图
sliderImg = sliderImgWithAlpha.Clone()
defer sliderImg.Close()
mask = gocv.NewMat() // 空 mask
defer mask.Close()
} else {
return -1
}
// 创建结果矩阵用于存储匹配结果
result := gocv.NewMat()
defer result.Close()
// 直接在原图上使用模板匹配找到滑块位置
if mask.Empty() {
gocv.MatchTemplate(bgImg, sliderImg, &result, gocv.TmCcoeffNormed, gocv.NewMat())
} else {
// 带 mask 的模板匹配
gocv.MatchTemplate(bgImg, sliderImg, &result, gocv.TmCcoeffNormed, mask)
}
// 找到最佳匹配位置
_, maxVal, _, maxLoc := gocv.MinMaxLoc(result)
// 如果匹配度太低,返回 -1 表示失败
if maxVal < 0.6 {
return -1
}
// 获取原图上的 x 坐标,然后按比例缩放到目标尺寸
originalWidth := bgImg.Cols()
targetWidth := 280
scale := float64(targetWidth) / float64(originalWidth)
scaledX := int(float64(maxLoc.X) * scale)
// 返回缩放后的 x 坐标(滑块需要移动的距离)
return scaledX
}
// VerifySliderWidthByBase64 通过 base64 编码的图片计算滑块验证码需要移动的距离
// bgImgBase64: 背景图片的 base64 编码
// sliderImgBase64: 滑块图片的 base64 编码
// 返回: 滑块需要移动的 x 距离(已缩放到 280 宽度),失败返回 -1
func VerifySliderWidthByBase64(bgImgBase64, sliderImgBase64 string) int {
// 解码背景图片的 base64
bgImgData, err := base64.StdEncoding.DecodeString(bgImgBase64)
if err != nil {
return -1
}
// 解码滑块图片的 base64
sliderImgData, err := base64.StdEncoding.DecodeString(sliderImgBase64)
if err != nil {
return -1
}
// 从内存中读取背景图片
bgImg, err := gocv.IMDecode(bgImgData, gocv.IMReadColor)
if err != nil {
return -1
}
if bgImg.Empty() {
return -1
}
defer bgImg.Close()
// 从内存中读取滑块图片(包含 alpha 通道)
sliderImgWithAlpha, err := gocv.IMDecode(sliderImgData, gocv.IMReadUnchanged)
if err != nil {
return -1
}
if sliderImgWithAlpha.Empty() {
return -1
}
defer sliderImgWithAlpha.Close()
// 分离滑块图片的通道
channels := gocv.Split(sliderImgWithAlpha)
defer func() {
for i := range channels {
channels[i].Close()
}
}()
var sliderImg gocv.Mat
var mask gocv.Mat
// 如果有 alpha 通道(4通道),提取 mask
if len(channels) == 4 {
// 前3个通道是 BGR
sliderImg = gocv.NewMat()
defer sliderImg.Close()
gocv.Merge(channels[:3], &sliderImg)
// 使用 alpha 通道作为 mask
mask = channels[3].Clone()
defer mask.Close()
} else if len(channels) == 3 {
// 没有透明通道,直接使用原图
sliderImg = sliderImgWithAlpha.Clone()
defer sliderImg.Close()
mask = gocv.NewMat() // 空 mask
defer mask.Close()
} else {
return -1
}
// 创建结果矩阵用于存储匹配结果
result := gocv.NewMat()
defer result.Close()
// 直接在原图上使用模板匹配找到滑块位置
if mask.Empty() {
gocv.MatchTemplate(bgImg, sliderImg, &result, gocv.TmCcoeffNormed, gocv.NewMat())
} else {
// 带 mask 的模板匹配
gocv.MatchTemplate(bgImg, sliderImg, &result, gocv.TmCcoeffNormed, mask)
}
// 找到最佳匹配位置
_, maxVal, _, maxLoc := gocv.MinMaxLoc(result)
// 如果匹配度太低,返回 -1 表示失败
if maxVal < 0.6 {
return -1
}
// 获取原图上的 x 坐标,然后按比例缩放到目标尺寸
originalWidth := bgImg.Cols()
targetWidth := 280
scale := float64(targetWidth) / float64(originalWidth)
scaledX := int(float64(maxLoc.X) * scale)
// 返回缩放后的 x 坐标(滑块需要移动的距离)
return scaledX
}
+6 -3
View File
@@ -2,9 +2,9 @@ package schedule
import (
"context"
"likedream-goframe/internal/utils"
"likedream-goframe/internal/framework/logger"
"github.com/samber/oops"
"go.uber.org/zap"
)
// StopTask 让任务队列停止执行某任务
@@ -17,5 +17,8 @@ func StopTask(ctx context.Context, queue, taskID string) {
queue = "default"
}
e := globalInspector.DeleteTask(queue, taskID)
utils.OkOrPanic(e, oops.WithContext(ctx).With("queue", queue, "taskID", taskID).Wrap)
if e != nil {
logger.Logger.Error("删除定时任务失败", zap.Error(e))
}
//utils.OkOrPanic(e, oops.WithContext(ctx).With("queue", queue, "taskID", taskID).Wrap)
}