Compare commits

...

142 Commits

Author SHA1 Message Date
sugarBecomer 16692177d4 🐛 学习通列表 2025-12-10 15:10:39 +08:00
sugarBecomer 5f81a7ba5e 🐛 学习通刷课修复 2025-12-09 23:36:13 +08:00
sugarBecomer 58fb282fe8 🐛 学习通优化 2025-12-09 22:06:07 +08:00
sugarBecomer f64b4b5498 ✨ 配置单价 2025-12-09 16:19:45 +08:00
sugarBecomer 84620989ad ✨ 四川教师刷课 2025-12-09 13:59:12 +08:00
sugarBecomer af7fcc4cb4 🐛 销假 2025-12-08 17:54:05 +08:00
sugarBecomer c206a0863e ✨ 学习通优化 & 注册开关 2025-12-08 14:11:49 +08:00
sugarBecomer b068e178da 🐛 邮件只重试一次 2025-12-08 10:47:57 +08:00
sugarBecomer 02737ef743 🐛 销假 2025-12-08 10:44:29 +08:00
sugarBecomer 74a1134cf1 🐛 邮件调整 2025-12-08 10:03:21 +08:00
sugarBecomer b6f777f997 🐛 调整任务执行 2025-12-08 09:55:50 +08:00
sugarBecomer 2dc233631e 🐛 调整任务执行 2025-12-08 09:55:27 +08:00
sugarBecomer 0c60eb5ae4 🐛 销假 2025-12-07 22:37:28 +08:00
sugarBecomer 450c026a6f 🐛 销假 2025-12-06 21:58:51 +08:00
sugarBecomer d9d6acee5f 🐛 销假 2025-12-06 15:12:43 +08:00
sugarBecomer 164d8956de 🐛 销假 & 学习通 2025-12-06 15:10:47 +08:00
sugarBecomer 3f1a08374e 🐛 日志格式 & 邮箱错误处理 2025-12-06 12:53:52 +08:00
sugarBecomer 334d597f9a 🐛 邮箱发送失败 2025-12-06 12:34:12 +08:00
sugarBecomer 98eb15f4e4 ✨ 手机号登录 2025-12-06 02:16:20 +08:00
sugarBecomer 3a2e679b85 ✨ 学习通 & 销假 2025-12-05 22:09:47 +08:00
sugarBecomer 921192a914 ✅ 查寝获取近期任务信息 2025-12-05 10:08:43 +08:00
sugarBecomer 107b472cf7 🐛 限制注册 2025-12-04 22:53:47 +08:00
sugarBecomer 14108f6ba3 🥚 关闭注册 2025-12-03 23:09:30 +08:00
sugarBecomer a22f559889 ✨ 消费日志 2025-12-02 16:42:08 +08:00
sugarBecomer ececfa344d ✨ 公告管理 2025-12-02 15:11:06 +08:00
sugarBecomer 5ba9e38e1a 🐛 删除不用的代码 & 修改时间 2025-12-02 10:53:16 +08:00
RecordingTheSmile 6eb4bc6d8a feat: 完成短信验证码登录 2025-12-02 10:17:47 +08:00
sugarBecomer 17e6c0a789 🐛 创建时间更新时间 2025-12-01 12:58:58 +08:00
sugarBecomer f067ed2ad0 🐛 系统邮件 2025-11-29 21:39:40 +08:00
sugarBecomer 7bfc7a58ab 🐛 系统邮件通知 2025-11-29 17:45:37 +08:00
sugarBecomer 1ad0c285f2 🐛 学习通完善 2025-11-29 11:34:48 +08:00
sugarBecomer 498c51c585 🐛 学校信息 2025-11-29 10:48:25 +08:00
sugarBecomer 6a6716655b ✨ 销假相关 2025-11-29 08:54:44 +08:00
sugarBecomer ddc0949e56 ✨ 销假相关 2025-11-28 21:13:45 +08:00
sugarBecomer a723733562 ✨ 销假相关 2025-11-28 21:13:21 +08:00
sugarBecomer e7307182dd ✨ 学习通 临时提交 2025-11-28 21:12:56 +08:00
sugarBecomer 92ccebfa4e ✨ 学习通 临时提交 2025-11-28 21:12:18 +08:00
sugarBecomer 30dd8a8dfe 🐛 长春大学 登录 2025-11-27 23:26:49 +08:00
sugarBecomer 788f1f4de7 🐛 福建江夏学院 登录 2025-11-27 21:58:43 +08:00
sugarBecomer 54d81b5ba8 🐛 福建江夏学院 登录 2025-11-27 21:55:46 +08:00
sugarBecomer 4912a27348 ✨ 蘑菇钉初始化 2025-11-27 10:36:54 +08:00
sugarBecomer 085820892f 🐛 西安体育学院 登录 2025-11-26 22:52:33 +08:00
sugarBecomer 391a4c2ccb 🐛 西安体育学院 登录 2025-11-26 20:44:28 +08:00
sugarBecomer 92363c8185 ✨ 学习通 2025-11-26 12:34:43 +08:00
sugarBecomer 73b6c01cf9 ✨ 刷课表 2025-11-26 10:58:22 +08:00
sugarBecomer f703344485 ✨ 学习通刷课功能 2025-11-26 10:39:01 +08:00
sugarBecomer d9049095e3 ✨ 首页统计 2025-11-26 09:58:32 +08:00
sugarBecomer b2f0f91712 🐛 rsa非标准加密 2025-11-25 22:45:45 +08:00
sugarBecomer ac93a5385e 🐛 遗漏的代码 2025-11-25 19:01:43 +08:00
sugarBecomer 644ceed436 ✨ 学习通相关 2025-11-25 18:59:45 +08:00
sugarBecomer 77ea55eda2 🎨 调整扣分规则 2025-11-25 18:59:26 +08:00
sugarBecomer 0f83cfc9b9 ✨ 适配华侨大学 2025-11-23 20:55:54 +08:00
sugarBecomer 83749fc647 🐛 提交验证码处理 2025-11-22 22:49:32 +08:00
sugarBecomer bd5f8a9b15 🐛 经纬度修复 2025-11-22 20:01:21 +08:00
sugarBecomer 8889be8ca2 🐛 经纬度修复 2025-11-22 19:57:28 +08:00
sugarBecomer 6d58222143 ✨ 修复cookie问题 2025-11-22 15:50:31 +08:00
sugarBecomer c3019b741c 🐛 甘肃农业大学适配 2025-11-22 15:28:13 +08:00
sugarBecomer b5b2103372 🐛 学校登录模式更改 2025-11-22 14:11:10 +08:00
sugarBecomer b234c87048 ✨ 经纬度更新 2025-11-22 13:33:25 +08:00
sugarBecomer f982aa5e0c 🚀 删除冗余import 2025-11-21 20:32:23 +08:00
sugarBecomer 22823328c0 🚀 Dockerfile 2025-11-21 16:18:13 +08:00
sugarBecomer 323e49e375 🚀 captcha分离 2025-11-21 16:14:19 +08:00
sugarBecomer 526e4ac198 🐛 没有任务id了,因此没法删除asynq里的任务了 2025-11-18 16:05:03 +08:00
sugarBecomer 7469813e37 🐛 浅浅的加个锁 2025-11-18 15:53:57 +08:00
sugarBecomer 8eb4e40b57 🐛 缓存高频访问的学校信息 2025-11-18 15:37:08 +08:00
sugarBecomer 311cf18708 🐛 每次直接创建全新client试试效果 2025-11-18 14:57:37 +08:00
sugarBecomer 1bde0af01d 🐛 小问题修复 2025-11-18 14:34:43 +08:00
sugarBecomer 765ec6fb32 🐛 修复cookie问题 2025-11-17 17:14:49 +08:00
sugarBecomer 5cd8329519 🐛 修改定时任务信息 2025-11-17 12:39:05 +08:00
sugarBecomer 1ea9170d89 Merge remote-tracking branch 'origin/main' 2025-11-17 12:05:37 +08:00
sugarBecomer 29fc69d022 🐛 尝试解决cookie jar的问题 2025-11-17 12:03:47 +08:00
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
sugarBecomer 26dcfaf82d 🐛 增加日志信息 2025-11-14 13:09:00 +08:00
sugarBecomer e55a8866b1 🐛 schedule不要任务id唯一 2025-11-13 23:28:32 +08:00
sugarBecomer 0702135db2 🐛 iap新版本登录 2025-11-13 22:39:01 +08:00
sugarBecomer c668a76a99 🐛 关联关系bug 2025-11-13 21:42:26 +08:00
sugarBecomer 2a1cb90766 🐛 紧急修复漏洞 2025-11-13 21:20:07 +08:00
sugarBecomer ce536eb5bc 🐛 cookie问题 2025-11-13 16:53:59 +08:00
sugarBecomer 0244e330fd 🐛 cookie问题 2025-11-13 16:49:36 +08:00
sugarBecomer 035bac10cc 🐛 尝试调整登录cookie校验 2025-11-13 11:46:44 +08:00
sugarBecomer cb808f9c68 ✨ 适配南昌理工学院 2025-11-13 00:17:49 +08:00
sugarBecomer 22735aeaae ✨ 随机图片改为顺序图片 2025-11-12 19:59:02 +08:00
sugarBecomer dd6eeb5dfe 🐛 opencv 2025-11-12 15:54:52 +08:00
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
sugarBecomer 00bc3432bd 🐛 修复onnx环境 2025-11-11 21:48:08 +08:00
sugarBecomer ffec10d1cc ✨ 迁移积分 2025-11-11 21:31:49 +08:00
sugarBecomer 3993fa9b32 ✨ 忘记密码 & 重发邮件 2025-11-11 14:08:07 +08:00
sugarBecomer 20b92b7d1c 🐛 注册验证码 2025-11-11 12:55:53 +08:00
sugarBecomer 05b9a94d41 🐛 ai初始化 & 错误处理 2025-11-11 11:57:50 +08:00
sugarBecomer 49f4475991 🐛 干掉global id 2025-11-10 22:53:05 +08:00
sugarBecomer 76bfdd03dd 🐛 cookie更新 2025-11-10 22:20:34 +08:00
sugarBecomer c18f2fdcf7 🐛 细节问题 2025-11-10 21:51:08 +08:00
sugarBecomer 74c6154c59 🐛 验证码处理 & 查询条件 2025-11-10 21:36:59 +08:00
sugarBecomer 3dc9a5e2a4 🐛 错误的判定 2025-11-10 15:40:40 +08:00
sugarBecomer ca30fd89cb 🐛 错误的判定 2025-11-10 15:40:24 +08:00
sugarBecomer b56e18b8ce ✨ 实现ai识别提交验证码问题 2025-11-10 15:39:38 +08:00
sugarBecomer b031157f98 ✨ 调整文件结构 2025-11-10 14:46:01 +08:00
sugarBecomer 7262023382 ✨ 学习通获取课程列表 2025-11-10 14:40:38 +08:00
sugarBecomer dde5825d30 ✨ 集成ddddocr服务 2025-11-10 13:57:17 +08:00
sugarBecomer da4bc164e2 🚚 优化ai模型初始化方式 2025-11-10 13:38:41 +08:00
sugarBecomer ae95a871c8 ✨ 学习通 2025-11-09 22:18:03 +08:00
sugarBecomer 4a29772de0 🐛 下载地址返回 2025-11-09 00:58:59 +08:00
sugarBecomer 194065af8d ✨ 音乐下载 2025-11-08 23:39:02 +08:00
sugarBecomer a118b75379 🐛 立即执行参数 2025-11-08 22:24:45 +08:00
sugarBecomer 09d59162d0 🐛 遗漏的文件 2025-11-08 21:50:22 +08:00
sugarBecomer 1323bc7aa8 🐛 签到修复 & 定时重置状态 2025-11-08 21:47:08 +08:00
sugarBecomer 604245add2 🐛 修改余额 2025-11-08 00:21:53 +08:00
sugarBecomer 90bbc11712 🐛 任务立即执行但没有次数 2025-11-07 23:44:05 +08:00
sugarBecomer 948d118a4f 🐛 查寝各种问题修复 2025-11-07 23:36:02 +08:00
sugarBecomer 618d6384d9 🐛 代理信息修改 2025-11-07 20:00:05 +08:00
sugarBecomer face5ca364 🐛 代理充值 2025-11-07 19:46:25 +08:00
sugarBecomer d3869f68f8 🐛 代理设置 2025-11-07 19:45:57 +08:00
sugarBecomer 97ab3a7a1d 🐛 代理设置 2025-11-07 19:23:01 +08:00
sugarBecomer 6cc4582c87 🐛 代理设置 2025-11-07 19:13:06 +08:00
sugarBecomer 4941d4eb8a 🐛 代理设置 2025-11-07 16:55:22 +08:00
sugarBecomer 11a16a65a0 🐛 级联删除用户数据 2025-11-07 16:03:09 +08:00
sugarBecomer 71f992544e 🐛 登录问题 2025-11-07 15:56:23 +08:00
sugarBecomer 6320d6eb17 🐛 登录问题 2025-11-07 15:55:06 +08:00
sugarBecomer 6840b69f1a 🐛 登录问题 2025-11-07 15:28:54 +08:00
sugarBecomer 1ae2601209 🐛 salt寻找规则 2025-11-07 15:11:06 +08:00
sugarBecomer 07444f70df 🐛 ssl证书忽略 2025-11-07 14:59:40 +08:00
sugarBecomer 1bc04c74f3 🐛 菜单 keep alive 2025-11-07 14:30:12 +08:00
sugarBecomer dcb5cd8a4e 🐛 菜单修复 2025-11-07 14:00:45 +08:00
sugarBecomer 0d7d4c11a0 🐛 Login need kick 2025-11-07 12:11:50 +08:00
sugarBecomer e4d07874eb 🐛 任务执行bug和日志 2025-11-07 01:14:10 +08:00
274 changed files with 38267 additions and 1666 deletions
+5 -8
View File
@@ -1,19 +1,16 @@
FROM golang:latest AS backend-builder
RUN apt update
RUN apt install -y build-essential
FROM golang:alpine AS backend-builder
WORKDIR /build
COPY . .
ENV CGO_ENABLED=1
RUN go build -ldflags="-s -w" -o bin/server
RUN apt install -y upx-ucl
RUN apk add --no-cache upx
RUN upx --best --lzma bin/server
FROM golang:latest
FROM alpine:latest
COPY --from=backend-builder /build/bin/server /app/server
COPY static /app/static
COPY static/*.so /app/
WORKDIR /app
EXPOSE 8080
RUN ls -a
+1 -1
View File
@@ -2,4 +2,4 @@ ent-new:
cd internal;go run -mod=mod entgo.io/ent/cmd/ent new $(Name)
ent-gen:
go run . g
go run main.go g
+14 -1
View File
@@ -14,7 +14,20 @@ func Run() {
IDType: &field.TypeInfo{
Type: field.TypeInt64,
},
Features: gen.AllFeatures,
Features: []gen.Feature{
gen.FeaturePrivacy,
gen.FeatureIntercept,
gen.FeatureEntQL,
gen.FeatureNamedEdges,
gen.FeatureBidiEdgeRefs,
gen.FeatureSnapshot,
gen.FeatureSchemaConfig,
gen.FeatureLock,
gen.FeatureModifier,
gen.FeatureExecQuery,
gen.FeatureUpsert,
gen.FeatureVersionedMigration,
},
})
utils.OkOrPanic(err)
+4 -4
View File
@@ -4,7 +4,7 @@ import (
"context"
"likedream-goframe/internal/api"
"likedream-goframe/internal/application/filestorage"
"likedream-goframe/internal/db"
db2 "likedream-goframe/internal/common/db"
_ "likedream-goframe/internal/ent/runtime"
"likedream-goframe/internal/framework/redis"
"likedream-goframe/internal/framework/schedule"
@@ -17,10 +17,10 @@ import (
)
func Run() {
db.InitDB()
db2.InitDB()
// 如果没有数据,进行数据初始化
if !db.CheckHasData() {
db.GenerateData()
if !db2.CheckHasData() {
db2.GenerateData()
}
ctx, cancel := context.WithCancel(gctx.GetInitCtx())
+21
View File
@@ -0,0 +1,21 @@
package main
import (
"fmt"
"likedream-goframe/internal/application/cpdaily/login"
"github.com/gogf/gf/v2/os/gctx"
)
func main() {
const testSmsPhoneNumber = ""
handler := login.SmsLoginHandler{}
ctx := gctx.GetInitCtx()
sendSmsData := handler.SendSms(ctx, testSmsPhoneNumber)
// 读取验证码手动输入
var code string
println("请输入收到的验证码:")
_, _ = fmt.Scanln(&code)
loginData := handler.LoginByCode(ctx, testSmsPhoneNumber, code, sendSmsData.CookieJar)
handler.SetLoginCookie(ctx, sendSmsData.CookieJar, loginData)
}
+28 -10
View File
@@ -3,21 +3,24 @@ url = "https://devocr.likedream.xyz"
[database]
type = "mysql"
dsn = "test_likedream_go:EpXckXmxawDEQGxJ@tcp(database.out:3306)/test_likedream_go?parseTime=true"
dsn = "test_likedream_go:EpXckXmxawDEQGxJ@tcp(database.out:3306)/test_likedream_go?parseTime=true&loc=Asia%2FShanghai"
dsn2 = "host=database.out user=dev password=JPmKjmpcaG3EYAJZ dbname=dev port=5432 sslmode=disable TimeZone=Asia/Shanghai"
# host=localhost user=gorm password=gorm dbname=gorm port=9920 sslmode=disable TimeZone=Asia/Shanghai
[server]
port = 8090
url = "https://devocr.likedream.xyz"
openapiPath= "/api.json"
swaggerPath= "/swagger"
emailConfirmBaseUrl = "https://dev.likedream.xyz/confirm/"
emailConfirmBaseUrl = "http://localhost:3200/confirm/"
emailResetPasswordUrl = "http://localhost:3200/reset-password/"
balanceMigrateUrl = "http://localhost:3200/balance-migrate/"
[env]
# 可选值: dev, prod
mode = "dev"
[ruoliCaptcha]
baseUrl = ""
[captcha]
baseUrl = "http://captcha-resolver.out"
#baseUrl = "http://localhost:8890"
[redis]
url = "redis://:jhkdjhkjdhsIUTYURTU_pEGmFP@database.out:56379"
@@ -26,15 +29,30 @@ url = "redis://:jhkdjhkjdhsIUTYURTU_pEGmFP@database.out:56379"
smtpHost = "smtp-mail.outlook.com"
smtpPort = 587
# 发件人邮箱和密码(需要使用授权码,不是登录密码)
smtpUsername = "demo@likedream.xyz"
smtpUsername = "reply@likedream.xyz"
smtpPassword = "baca122..."
# 发件人邮箱和显示名称
fromEmail = "demo@likedream.xyz"
fromName = "今日校园系统"
fromEmail = "reply@likedream.xyz"
fromName = "若梦系统"
[s3]
accessKey = "dzckoE1UlZe8XFItWQDI"
secretKey = "f8UZdnDohtwZk5HInT1Cw1QCztSGnFuPPqfSSjzu"
baseUrl = "http://s3.out"
bucket = "likedream"
bucket = "likedream"
[cpdaily]
appVersion = "9.8.8"
systemVersion = "iOS 17"
calVersion = "firstv"
sliderIds = ["1018764144934965", "sdhyxy", "hqdx"]
[basePoint]
cpdailyTask = "0.5"
cpdailyLeave = "4"
cpdailyPhone = "0.25"
[task]
concurrency = 80
+204
View File
@@ -0,0 +1,204 @@
# 四川智慧教育刷课 API 文档
## 基础信息
- 基础路径: `/v1/course`
- 需要认证: 是 (Bearer Token)
- Content-Type: `application/json`
---
## 1. 查询课程列表
登录四川智慧教育平台,获取用户的课程列表。
**请求**
```
POST /v1/course/smartedu-sc/query
```
**请求参数**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| username | string | 是 | 登录账号 |
| password | string | 是 | 登录密码 |
**请求示例**
```json
{
"username": "13800138000",
"password": "your_password"
}
```
**响应示例**
```json
{
"code": 200,
"msg": "success",
"data": [
{
"id": "course_id_1",
"courseName": "课程名称1",
"image": "https://xxx.com/image.jpg"
},
{
"id": "course_id_2",
"courseName": "课程名称2",
"image": "https://xxx.com/image2.jpg"
}
]
}
```
---
## 2. 添加刷课任务
添加一个新的刷课任务,提交后立即开始秒刷。
**请求**
```
POST /v1/course/smartedu-sc
```
**请求参数**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| username | string | 是 | 登录账号 |
| password | string | 是 | 登录密码 |
| course_name | string | 是 | 课程名称 |
| course_id | string | 是 | 课程ID(从查询接口获取) |
| desc | string | 否 | 备注说明 |
**请求示例**
```json
{
"username": "13800138000",
"password": "your_password",
"course_name": "教师培训课程",
"course_id": "abc123",
"desc": "2024年培训"
}
```
**响应示例**
```json
{
"code": 200,
"msg": "任务已提交,正在刷课中"
}
```
---
## 3. 获取刷课任务列表
获取当前用户的四川智慧教育刷课任务列表。
**请求**
```
GET /v1/course/smartedu-sc
```
**请求参数**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| username | string | 否 | 按账号筛选 |
| status | string | 否 | 按状态筛选: not_start/waiting/doing/done/fail/pause |
| page | int | 否 | 页码,默认1 |
| size | int | 否 | 每页数量,默认10 |
**响应示例**
```json
{
"code": 200,
"msg": "success",
"data": [
{
"id": 1,
"createTime": "2024-12-09 10:00:00",
"updateTime": "2024-12-09 10:05:00",
"username": "13800138000",
"type": "smartedu_sc",
"course_name": "教师培训课程",
"course_id": "abc123",
"need": "task",
"status": "done",
"task_count": 20,
"success_count": 20,
"violence": "yes",
"desc": "2024年培训"
}
],
"total": 1
}
```
**状态说明**
| 状态值 | 说明 |
|--------|------|
| not_start | 未开始 |
| waiting | 等待中 |
| doing | 进行中 |
| done | 已完成 |
| fail | 失败 |
| pause | 已暂停 |
---
## 4. 删除刷课任务
删除指定的刷课任务。
**请求**
```
DELETE /v1/course/smartedu-sc/{id}
```
**路径参数**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | int64 | 是 | 任务ID |
**响应示例**
```json
{
"code": 200,
"msg": "删除成功"
}
```
---
## 错误码说明
| 错误码 | 说明 |
|--------|------|
| 200 | 成功 |
| 400 | 请求参数错误 |
| 401 | 未授权 |
| 403 | 无权限 |
| 500 | 服务器内部错误 |
## 使用流程
1. 调用 **查询课程列表** 接口,获取用户在四川智慧教育平台的课程
2. 选择需要刷的课程,调用 **添加刷课任务** 接口
3. 任务提交后自动开始秒刷,可通过 **获取刷课任务列表** 查看进度
4. 刷课完成后状态变为 `done`
+16 -9
View File
@@ -1,6 +1,6 @@
module likedream-goframe
go 1.24
go 1.24.0
require (
entgo.io/ent v0.14.5
@@ -28,10 +28,11 @@ require (
github.com/wenlng/go-captcha-assets v1.0.7
github.com/wenlng/go-captcha/v2 v2.0.4
github.com/wneessen/go-mail v0.6.2
github.com/yalue/onnxruntime_go v1.9.0
go.uber.org/automaxprocs v1.6.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.39.0
golang.org/x/crypto v0.43.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
resty.dev/v3 v3.0.0-beta.3
)
@@ -69,6 +70,12 @@ require (
github.com/gorilla/websocket v1.5.3 // indirect
github.com/grokify/html-strip-tags-go v0.1.0 // indirect
github.com/hashicorp/hcl/v2 v2.18.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/cpuid/v2 v2.0.9 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.9 // indirect
@@ -98,13 +105,13 @@ require (
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
golang.org/x/image v0.29.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/mod v0.28.0 // indirect
golang.org/x/net v0.45.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.34.0 // indirect
golang.org/x/tools v0.37.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+33 -18
View File
@@ -111,6 +111,18 @@ github.com/hashicorp/hcl/v2 v2.18.1 h1:6nxnOJFku1EuSawSD81fuviYUV8DxFr3fp2dUi3ZY
github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE=
github.com/hibiken/asynq v0.25.1 h1:phj028N0nm15n8O2ims+IvJ2gz4k2auvermngh9JhTw=
github.com/hibiken/asynq v0.25.1/go.mod h1:pazWNOLBu0FEynQRBvHA26qdIKRSmfdIfUm4HdsLmXg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joomcode/errorx v1.2.0 h1:7Y/fguon+9r6a/75Rv3nrUwS7nXNEcJjLShjCvz00Og=
github.com/joomcode/errorx v1.2.0/go.mod h1:Mbz68VA9hsQLT50iCQQUZ2Z1XYAKYB4EoFkFCTFyiJM=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
@@ -134,8 +146,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
@@ -177,6 +189,7 @@ github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqe
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -202,8 +215,6 @@ github.com/wenlng/go-captcha/v2 v2.0.4 h1:5cSUF36ZyA03qeDMjKmeXGpbYJMXEexZIYK3Vg
github.com/wenlng/go-captcha/v2 v2.0.4/go.mod h1:5hac1em3uXoyC5ipZ0xFv9umNM/waQvYAQdr0cx/h34=
github.com/wneessen/go-mail v0.6.2 h1:c6V7c8D2mz868z9WJ+8zDKtUyLfZ1++uAZmo2GRFji8=
github.com/wneessen/go-mail v0.6.2/go.mod h1:L/PYjPK3/2ZlNb2/FjEBIn9n1rUWjW+Toy531oVmeb4=
github.com/yalue/onnxruntime_go v1.9.0 h1:AhgkpBjphJZsHT5karKt93xPkPFNP0Iz6ENUbNAFQU4=
github.com/yalue/onnxruntime_go v1.9.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
@@ -236,8 +247,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs=
golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas=
golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA=
@@ -246,8 +257,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -257,8 +268,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -267,8 +278,8 @@ golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -286,8 +297,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -308,8 +319,8 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -318,8 +329,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
@@ -331,6 +342,10 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
resty.dev/v3 v3.0.0-beta.3 h1:3kEwzEgCnnS6Ob4Emlk94t+I/gClyoah7SnNi67lt+E=
resty.dev/v3 v3.0.0-beta.3/go.mod h1:OgkqiPvTDtOuV4MGZuUDhwOpkY8enjOsjjMzeOHefy4=
+15 -1
View File
@@ -2,15 +2,19 @@ package api
import (
"context"
"likedream-goframe/internal/common/cst"
"likedream-goframe/internal/controller/agent"
"likedream-goframe/internal/controller/balance"
"likedream-goframe/internal/controller/card"
"likedream-goframe/internal/controller/common"
"likedream-goframe/internal/controller/config"
"likedream-goframe/internal/controller/course"
"likedream-goframe/internal/controller/cpdaily"
"likedream-goframe/internal/controller/menu"
"likedream-goframe/internal/controller/music"
"likedream-goframe/internal/controller/role"
"likedream-goframe/internal/controller/user"
"likedream-goframe/internal/controller/user_login"
"likedream-goframe/internal/cst"
"likedream-goframe/internal/middleware"
"likedream-goframe/internal/utils"
@@ -39,10 +43,12 @@ func StartServer(ctx context.Context) {
systemGroup.Bind(role.NewV1())
systemGroup.Bind(menu.NewV1())
systemGroup.Bind(user.NewUserCtrlV1())
systemGroup.Bind(config.NewConfigCtrlV1())
//systemGroup.Bind(agent.NewV1())
// 用户信息路由
userGroup := group.Group("/user").Middleware(middleware.AuthHandler, middleware.PermissionHandler)
userGroup.Bind(user_login.NewUserInfoV1())
userGroup.Bind(balance.NewBalanceCtrlV1())
// 代理信息路由
agentGroup := group.Group("/agent").Middleware(middleware.AuthHandler, middleware.PermissionHandler)
agentGroup.Bind(agent.NewV1())
@@ -53,6 +59,14 @@ func StartServer(ctx context.Context) {
cpdailyGroup.Bind(cpdaily.NewCpTaskCtrlV1())
cpdailyGroup.Bind(cpdaily.NewCpImageCtrlV1())
cpdailyGroup.Bind(cpdaily.NewCpTaskLogCtrlV1())
cpdailyGroup.Bind(cpdaily.NewCpLeaveCtrlV1())
// 娱乐
musicGroup := group.Group("/music").Middleware(middleware.AuthHandler, middleware.PermissionHandler)
musicGroup.Bind(music.NewMusicCtrlV1())
// 网课
courseGroup := group.Group("/course").Middleware(middleware.AuthHandler, middleware.PermissionHandler)
courseGroup.Bind(course.NewXxtCtrlV1())
courseGroup.Bind(course.NewSmartEduSCCtrlV1())
})
s.SetPort(port.Int())
+10
View File
@@ -0,0 +1,10 @@
package v1
import "github.com/gogf/gf/v2/frame/g"
type BalanceRecordListReq struct {
g.Meta `path:"/balance/records" method:"get" tags:"Balance" summary:"获取用户余额变动记录列表"`
Type string `json:"type" dc:"变动类型: increase-增加, decrease-减少"`
Page int `json:"page" d:"1"`
Size int `json:"size" d:"10"`
}
+29
View File
@@ -0,0 +1,29 @@
package v1
import (
"github.com/gogf/gf/v2/frame/g"
)
type ConfigGetReq struct {
g.Meta `path:"/config/{key}" method:"get" tags:"Config" summary:"获取配置"`
Key string `json:"key" v:"required#配置键不能为空"`
}
type ConfigUpdateReq struct {
g.Meta `path:"/config/{key}" method:"put" tags:"Config" summary:"更新配置" permission:"sys:config:edit"`
Key string `json:"key" v:"required#配置键不能为空"`
Value string `json:"value" v:"required#配置值不能为空"`
Description string `json:"description"`
}
type ConfigListReq struct {
g.Meta `path:"/config" method:"get" tags:"Config" summary:"获取配置列表"`
Key string `json:"key"`
Page int `json:"page" d:"1"`
Size int `json:"size" d:"10"`
}
type ConfigPublicGetReq struct {
g.Meta `path:"/config/public/{key}" method:"get" tags:"Config" summary:"获取公开配置(无需认证)" auth:"false"`
Key string `json:"key" v:"required#配置键不能为空"`
}
+55
View File
@@ -0,0 +1,55 @@
package v1
import (
"likedream-goframe/internal/ent/courseentity"
"github.com/gogf/gf/v2/frame/g"
)
type CourseListReq struct {
g.Meta `path:"/course" method:"get" tags:"Course" summary:"获取刷课列表"`
Type courseentity.Type `json:"type"`
Username string `json:"username"`
Status courseentity.Status `json:"status"`
Page int `json:"page" d:"1"`
Size int `json:"size" d:"10"`
}
type AddCourseReq struct {
g.Meta `path:"/course" method:"post" tags:"Course" summary:"添加刷课"`
Username string `json:"username" v:"required#账号不能为空"`
Password string `json:"password" v:"required#密码不能为空"`
Type courseentity.Type `json:"type" v:"required#类型不能为空"`
CourseName string `json:"course_name" v:"required#课程名称不能为空"`
CourseID string `json:"course_id" v:"required#课程ID不能为空"`
Need courseentity.Need `json:"need" v:"required#需要刷的内容不能为空"`
Violence courseentity.Violence `json:"violence" d:"no"`
Desc string `json:"desc"`
}
type UpdateCoursePasswordReq struct {
g.Meta `path:"/course/password/{id}" method:"put" tags:"Course" summary:"修改刷课密码"`
ID int64 `json:"id"`
Password string `json:"password" v:"required#密码不能为空"`
}
type RestartCourseReq struct {
g.Meta `path:"/course/restart/{id}" method:"post" tags:"Course" summary:"重刷课程"`
ID int64 `json:"id"`
}
type PauseCourseReq struct {
g.Meta `path:"/course/pause/{id}" method:"post" tags:"Course" summary:"暂停刷课"`
ID int64 `json:"id"`
}
type DeleteCourseReq struct {
g.Meta `path:"/course/{id}" method:"delete" tags:"Course" summary:"删除刷课"`
ID int64 `json:"id"`
}
type QueryCourseReq struct {
g.Meta `path:"/course/query" method:"post" tags:"Course" summary:"查询课程列表"`
Username string `json:"username" v:"required#账号不能为空"`
Password string `json:"password" v:"required#密码不能为空"`
}
+8
View File
@@ -0,0 +1,8 @@
package v1
// TaskStatisticsItem 每日任务统计项
type TaskStatisticsItem struct {
Date string `json:"date"` // 日期 格式:2006-01-02
PendingTask int `json:"pendingTask"` // 需要完成的任务数
SuccessTask int `json:"successTask"` // 成功执行的任务数
}
+37
View File
@@ -0,0 +1,37 @@
package v1
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
type CpLeaveListReq struct {
g.Meta `path:"/leave" method:"get" tags:"CpLeave" summary:"获取销假记录列表"`
CpUserId int64 `json:"cpUserId"`
Username string `json:"username"`
Status string `json:"status"`
Page int `json:"page" d:"1"`
Size int `json:"size" d:"10"`
}
type GetLeaveListFromSystemReq struct {
g.Meta `path:"/leave/system/{cpUserId}" method:"get" tags:"CpLeave" summary:"获取今日校园系统中的请假列表"`
CpUserId int64 `json:"cpUserId" v:"required#今日校园账号ID不能为空"`
}
type CpLeaveReportReq struct {
g.Meta `path:"/leave/report" method:"post" tags:"CpLeave" summary:"执行销假"`
CpUserId int64 `json:"cpUserId" v:"required#今日校园账号ID不能为空"`
LeaveId string `json:"leaveId" v:"required#请假记录ID不能为空"`
Title string `json:"title" v:"required#请假记录标题不能为空"`
Longitude string `json:"longitude"` // 经度(可选,不传则使用系统默认)
Latitude string `json:"latitude"` // 纬度(可选,不传则使用系统默认)
Address string `json:"address"` // 地址(可选,不传则使用系统默认)
ImageUrl string `json:"imageUrl"`
}
type CpLeaveUploadReq struct {
g.Meta `path:"/leave/upload" method:"post" tags:"CpLeave" summary:"上传销假图片"`
File *ghttp.UploadFile `json:"file"`
CpUserId int64 `json:"cpUserId"`
}
+9
View File
@@ -67,3 +67,12 @@ type PauseTaskReq struct {
g.Meta `path:"/task/pause/{id}" method:"post" tags:"CpTask" summary:"暂停任务"`
Id int64 `json:"id"`
}
type FindLocationReq struct {
g.Meta `path:"/task/location" method:"get" tags:"CpTask" summary:"查找地址"`
Address string `json:"address"`
}
type GetTaskStatisticsReq struct {
g.Meta `path:"/task/statistics" method:"get" tags:"CpTask" summary:"获取近七日任务统计数据"`
}
+27 -2
View File
@@ -1,12 +1,19 @@
package v1
import "github.com/gogf/gf/v2/frame/g"
import (
"likedream-goframe/internal/framework/captcha"
"github.com/gogf/gf/v2/frame/g"
)
type CpSchoolListReq struct {
g.Meta `path:"/school" method:"get" tags:"CpSchool" summary:"获取cp学校列表"`
Name string `json:"name"`
}
type CpUserLoginUrlReq struct {
g.Meta `path:"/loginUrl" method:"get" tags:"CpUser" summary:"获取登录url" auth:"false"`
Name string `json:"name"`
}
type CpUserAddReq struct {
g.Meta `path:"/user" method:"post" tags:"CpUser" summary:"添加cp用户"`
Username string `json:"username" v:"required"`
@@ -14,6 +21,24 @@ type CpUserAddReq struct {
SchoolId string `json:"school_id"`
Email string `json:"email" v:"email|regex:^.*@qq\\.com$#邮箱格式不正确|必须是QQ邮箱"`
}
type LoginGetCode struct {
g.Meta `path:"/user/getCode" method:"post" tags:"CpUser" summary:"获取手机号登录验证码"`
Mobile string `json:"mobile" v:"required#手机号不能为空"`
CaptchaKey string `json:"captchaKey" v:"required#验证码不能不能为空"`
Dots []captcha.ConfirmData `json:"dots" v:"required#验证码不能为空"`
}
type AddByCode struct {
g.Meta `path:"/user/addByCode" method:"post" tags:"CpUser" summary:"手机号登录"`
Id int64 `json:"id"`
CaptchaKey string `json:"captchaKey" v:"required#验证码不能不能为空"`
Dots []captcha.ConfirmData `json:"dots" v:"required#验证码不能为空"`
Mobile string `json:"mobile" v:"required#手机号不能为空"`
SchoolId string `json:"school_id"`
Code string `json:"code"`
Email string `json:"email" v:"email|regex:^.*@qq\\.com$#邮箱格式不正确|必须是QQ邮箱"`
}
type CpUserConfirmAddReq struct {
g.Meta `path:"/user/confirm" method:"post" tags:"CpUser" summary:"多因子确认添加cp用户"`
Key string `json:"key"`
+1
View File
@@ -31,6 +31,7 @@ type MenuAddReq struct {
Route string `json:"route"`
Component string `json:"component"`
Hidden bool `json:"hidden"`
KeepAlive bool `json:"keepAlive"`
Icon string `json:"icon"`
Order uint `json:"order"`
ParentId int64 `json:"parentId"`
+13
View File
@@ -0,0 +1,13 @@
package v1
import "github.com/gogf/gf/v2/frame/g"
type SearchMusicReq struct {
g.Meta `path:"/search" method:"get" tags:"Music" summary:"搜索音乐"`
Keyword string `json:"keyword"`
}
type DownloadMusicReq struct {
g.Meta `path:"/download" method:"get" tags:"Music" summary:"下载音乐"`
Path string `json:"path"`
}
+29
View File
@@ -0,0 +1,29 @@
package v1
import (
"github.com/gogf/gf/v2/frame/g"
)
// PriceConfigGetReq 获取所有费率配置
type PriceConfigGetReq struct {
g.Meta `path:"/config/price" method:"get" tags:"PriceConfig" summary:"获取所有费率配置"`
}
// PriceConfigUpdateReq 更新费率配置
type PriceConfigUpdateReq struct {
g.Meta `path:"/config/price" method:"put" tags:"PriceConfig" summary:"更新费率配置" permission:"sys:config:edit"`
CpdailyTask string `json:"cpdaily_task"` // 今日校园签到单价
CpdailyLeave string `json:"cpdaily_leave"` // 今日校园请假单价
CpdailyPhone string `json:"cpdaily_phone"` // 今日校园手机号单价
Xxt string `json:"xxt"` // 学习通单价
SmarteduSc string `json:"smartedu_sc"` // 四川智慧教育单价
}
// PriceConfigResp 费率配置响应
type PriceConfigResp struct {
CpdailyTask string `json:"cpdaily_task"`
CpdailyLeave string `json:"cpdaily_leave"`
CpdailyPhone string `json:"cpdaily_phone"`
Xxt string `json:"xxt"`
SmarteduSc string `json:"smartedu_sc"`
}
+37
View File
@@ -0,0 +1,37 @@
package v1
import (
"github.com/gogf/gf/v2/frame/g"
)
// SmartEduSCQueryCourseReq 查询四川智慧教育课程列表
type SmartEduSCQueryCourseReq struct {
g.Meta `path:"/smartedu-sc/query" method:"post" tags:"SmartEduSC" summary:"查询四川智慧教育课程列表"`
Username string `json:"username" v:"required#账号不能为空"`
Password string `json:"password" v:"required#密码不能为空"`
}
// SmartEduSCAddCourseReq 添加四川智慧教育刷课任务
type SmartEduSCAddCourseReq struct {
g.Meta `path:"/smartedu-sc" method:"post" tags:"SmartEduSC" summary:"添加四川智慧教育刷课任务"`
Username string `json:"username" v:"required#账号不能为空"`
Password string `json:"password" v:"required#密码不能为空"`
CourseName string `json:"course_name" v:"required#课程名称不能为空"`
CourseID string `json:"course_id" v:"required#课程ID不能为空"`
Desc string `json:"desc"`
}
// SmartEduSCListReq 获取四川智慧教育刷课列表
type SmartEduSCListReq struct {
g.Meta `path:"/smartedu-sc" method:"get" tags:"SmartEduSC" summary:"获取四川智慧教育刷课列表"`
Username string `json:"username"`
Status string `json:"status"`
Page int `json:"page" d:"1"`
Size int `json:"size" d:"10"`
}
// SmartEduSCDeleteReq 删除四川智慧教育刷课任务
type SmartEduSCDeleteReq struct {
g.Meta `path:"/smartedu-sc/{id}" method:"delete" tags:"SmartEduSC" summary:"删除四川智慧教育刷课任务"`
ID int64 `json:"id"`
}
+1
View File
@@ -16,6 +16,7 @@ type UserMenuRes struct {
Route string `json:"route"`
Component string `json:"component"`
Hidden bool `json:"hidden"`
KeepAlive bool `json:"keepAlive"`
Icon string `json:"icon"`
Children []*UserMenuRes `json:"children"`
Order uint `json:"order"`
+51 -9
View File
@@ -12,22 +12,64 @@ type CaptchaReq struct {
type UserLoginReq struct {
g.Meta `path:"/user/login" method:"POST" tags:"UserLogin" summary:"用户登录"`
Username string `json:"username" v:"required"`
Password string `json:"password" v:"required"`
CaptchaKey string `json:"captchaKey" v:"required"`
Dots []captcha.ConfirmData `json:"dots" v:"required"`
Username string `json:"username" v:"required#用户名或密码不能为空"`
Password string `json:"password" v:"required#用户名或密码不能为空"`
CaptchaKey string `json:"captchaKey" v:"required#验证码不能不能为空"`
Dots []captcha.ConfirmData `json:"dots" v:"required#验证码不能为空"`
}
type UserRegisterReq struct {
g.Meta `path:"/user/register" method:"POST" tags:"UserLogin" summary:"用户注册"`
Email string `json:"email" v:"required|email|regex:^.*@qq\\.com$#邮箱不能为空|邮箱格式不正确|必须是QQ邮箱"`
Username string `json:"username" v:"required|length:2,20#用户名不能为空|用户名长度应在2-20之间" `
Password string `json:"password" v:"required|length:6,20#密码不能为空|密码长度应在6-20之间" `
ConfirmPassword string `json:"confirmPassword" v:"required|same:password#确认密码不能为空|两次输入的密码不一致"`
InviteCode string `json:"inviteCode"`
Email string `json:"email" v:"required|email|regex:^.*@qq\\.com$#邮箱不能为空|邮箱格式不正确|必须是QQ邮箱"`
Username string `json:"username" v:"required|length:2,20#用户名不能为空|用户名长度应在2-20之间" `
Password string `json:"password" v:"required|length:6,20#密码不能为空|密码长度应在6-20之间" `
ConfirmPassword string `json:"confirmPassword" v:"required|same:password#确认密码不能为空|两次输入的密码不一致"`
InviteCode string `json:"inviteCode"`
CaptchaKey string `json:"captchaKey" v:"required#验证码不能不能为空"`
Dots []captcha.ConfirmData `json:"dots" v:"required#验证码不能为空"`
}
type UserConfirmReq struct {
g.Meta `path:"/user/confirm" method:"POST" tags:"UserLogin" summary:"邮箱二次验证"`
Token string `json:"token" v:"required"`
}
type ResendVerificationReq struct {
g.Meta `path:"/user/resendVerification" method:"POST" tags:"UserLogin" summary:"重新发送验证邮件"`
Email string `json:"email" v:"required|email|regex:^.*@qq\\.com$#邮箱不能为空|邮箱格式不正确|必须是QQ邮箱"`
CaptchaKey string `json:"captchaKey" v:"required#验证码不能不能为空"`
Dots []captcha.ConfirmData `json:"dots" v:"required#验证码不能为空"`
}
type ForgotPasswordReq struct {
g.Meta `path:"/user/forgotPassword" method:"POST" tags:"UserLogin" summary:"忘记密码-发送重置密码邮件"`
Email string `json:"email" v:"required|email|regex:^.*@qq\\.com$#邮箱不能为空|邮箱格式不正确|必须是QQ邮箱"`
CaptchaKey string `json:"captchaKey" v:"required#验证码不能不能为空"`
Dots []captcha.ConfirmData `json:"dots" v:"required#验证码不能为空"`
}
type ForgetResetPasswordReq struct {
g.Meta `path:"/user/forgetResetPassword" method:"POST" tags:"UserLogin" summary:"忘记密码-重置密码"`
Token string `json:"token" v:"required#token不能为空"`
NewPassword string `json:"newPassword" v:"required|length:6,20#密码不能为空|密码长度应在6-20之间"`
ConfirmPassword string `json:"confirmPassword" v:"required|same:newPassword#确认密码不能为空|两次输入的密码不一致"`
}
type ResetPasswordInfoReq struct {
g.Meta `path:"/user/resetPassword/info" method:"POST" tags:"UserLogin" summary:"获取重置密码用户信息"`
Token string `json:"token" v:"required#token不能为空"`
}
type RequestBalanceMigrateReq struct {
g.Meta `path:"/user/requestBalanceMigrate" method:"POST" tags:"UserLogin" summary:"请求积分转移链接"`
Email string `json:"email" v:"required|email|regex:^.*@qq\\.com$#邮箱不能为空|邮箱格式不正确|必须是QQ邮箱"`
CaptchaKey string `json:"captchaKey" v:"required#验证码不能不能为空"`
Dots []captcha.ConfirmData `json:"dots" v:"required#验证码不能为空"`
}
type BalanceMigrateReq struct {
g.Meta `path:"/user/balanceMigrate" method:"POST" tags:"UserLogin" summary:"执行积分转移" auth:"false"`
Token string `json:"token" v:"required#token不能为空"`
CaptchaKey string `json:"captchaKey" v:"required#验证码不能不能为空"`
Dots []captcha.ConfirmData `json:"dots" v:"required#验证码不能为空"`
}
@@ -0,0 +1,56 @@
package captcha
import (
"encoding/base64"
"likedream-goframe/internal/application/httpclient"
"likedream-goframe/internal/common/cst"
"likedream-goframe/internal/utils"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
"github.com/samber/oops"
"github.com/tidwall/gjson"
)
func ApiSemiOCRVerification(bytes []byte, shape1, shape2 int64) string {
data := Bytes2Base64(bytes)
ctx := gctx.GetInitCtx()
baseUrl := g.Config().MustGetWithEnv(ctx, cst.ConfigCaptchaBaseUrl).String() + "/semiOcr"
client := httpclient.Cpdaily.Clone(ctx)
client.SetDoNotParseResponse(false)
post, err := client.R().SetBody(map[string]any{
"base64": data,
"shape1": shape1,
"shape2": shape2,
}).Post(baseUrl)
utils.OkOrPanic(err, func(err error) error {
return oops.
With("SemiOCRVerification", baseUrl).
WithContext(ctx).
Wrap(err)
})
result := post.String()
return gjson.Get(result, "data").String()
}
func ApiAutoOCRVerification(bytes []byte) string {
data := Bytes2Base64(bytes)
ctx := gctx.GetInitCtx()
baseUrl := g.Config().MustGetWithEnv(ctx, cst.ConfigCaptchaBaseUrl).String() + "/ocr"
client := httpclient.Cpdaily.Clone(ctx)
client.SetDoNotParseResponse(false)
post, err := client.R().SetBody(map[string]any{
"base64": data,
}).Post(baseUrl)
utils.OkOrPanic(err, func(err error) error {
return oops.
With("AutoOCRVerification", baseUrl).
WithContext(ctx).
Wrap(err)
})
result := post.String()
return gjson.Get(result, "data").String()
}
func Bytes2Base64(data []byte) string {
return base64.StdEncoding.EncodeToString(data)
}
@@ -1,225 +0,0 @@
package classifier
import (
"bufio"
"fmt"
_ "image/jpeg"
_ "image/png"
"likedream-goframe/internal/framework/logger"
"os"
"path/filepath"
"runtime"
"strings"
ort "github.com/yalue/onnxruntime_go"
"go.uber.org/zap"
)
type ImageClassifier struct {
session *ort.AdvancedSession
inputShape []int64
classes []string
inputTensor *ort.Tensor[float32]
outputTensor *ort.Tensor[float32]
}
// NewImageClassifier 创建新的图片分类器
func NewImageClassifier(modelPath, classesPath string) (*ImageClassifier, error) {
// 初始化ONNX Runtime
exePath := strings.ToLower(os.Args[0])
// 获取绝对路径
absPath, err := filepath.Abs(exePath)
dir := filepath.Dir(absPath)
systemOS := runtime.GOOS
switch systemOS {
case "windows":
dir = filepath.Join(dir, "onnxruntime.dll")
break
case "darwin":
dir = filepath.Join(dir, "onnxruntime.dylib")
break
case "linux":
dir = filepath.Join(dir, "onnxruntime.so")
break
}
logger.Logger.Info("初始化onnx:", zap.String("绝对路径", absPath), zap.String("父文件夹", dir))
ort.SetSharedLibraryPath(dir)
err = ort.InitializeEnvironment()
if err != nil {
return nil, fmt.Errorf("初始化ONNX Runtime失败: %v", err)
}
// 加载类别名称
classes, err := loadClasses(classesPath)
if err != nil {
return nil, fmt.Errorf("加载类别失败: %v", err)
}
// 创建session选项
options, err := ort.NewSessionOptions()
if err != nil {
return nil, fmt.Errorf("创建session选项失败: %v", err)
}
defer options.Destroy()
// 创建输入输出tensor
inputShape := ort.NewShape(1, 3, 224, 224)
outputShape := ort.NewShape(1, int64(len(classes)))
inputTensor, err := ort.NewEmptyTensor[float32](inputShape)
if err != nil {
return nil, fmt.Errorf("创建输入tensor失败: %v", err)
}
outputTensor, err := ort.NewEmptyTensor[float32](outputShape)
if err != nil {
inputTensor.Destroy()
return nil, fmt.Errorf("创建输出tensor失败: %v", err)
}
// 加载模型
session, err := ort.NewAdvancedSession(modelPath,
[]string{"input"}, []string{"output"},
[]ort.ArbitraryTensor{inputTensor}, []ort.ArbitraryTensor{outputTensor},
options)
if err != nil {
inputTensor.Destroy()
outputTensor.Destroy()
return nil, fmt.Errorf("加载模型失败: %v", err)
}
return &ImageClassifier{
session: session,
inputShape: []int64{1, 3, 224, 224},
classes: classes,
inputTensor: inputTensor,
outputTensor: outputTensor,
}, nil
}
// loadClasses 从文件加载类别名称
func loadClasses(path string) ([]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
var classes []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
classes = append(classes, scanner.Text())
}
if err := scanner.Err(); err != nil {
return nil, err
}
return classes, nil
}
// Predict 预测图片类别
func (ic *ImageClassifier) Predict(imgPath string) (string, float32, error) {
// 加载并预处理图片
inputData, err := ic.preprocessImage(imgPath)
if err != nil {
return "", 0, fmt.Errorf("预处理图片失败: %v", err)
}
// 设置输入数据
copy(ic.inputTensor.GetData(), inputData)
// 运行推理
err = ic.session.Run()
if err != nil {
return "", 0, fmt.Errorf("推理失败: %v", err)
}
// 获取输出数据
outputData := ic.outputTensor.GetData()
// 找到最大概率的类别
maxIdx := 0
maxProb := outputData[0]
for i, prob := range outputData {
if prob > maxProb {
maxProb = prob
maxIdx = i
}
}
// 应用softmax获取概率
expSum := float32(0)
for _, v := range outputData {
expSum += exp(v)
}
probability := exp(outputData[maxIdx]) / expSum
return ic.classes[maxIdx], probability, nil
}
// PredictFromBytes 从字节数据预测图片类别
func (ic *ImageClassifier) PredictFromBytes(imgData []byte) (string, float32, error) {
// 预处理图片
inputData, err := ic.preprocessImageBytes(imgData)
if err != nil {
return "", 0, fmt.Errorf("预处理图片失败: %v", err)
}
// 设置输入数据
copy(ic.inputTensor.GetData(), inputData)
// 运行推理
err = ic.session.Run()
if err != nil {
return "", 0, fmt.Errorf("推理失败: %v", err)
}
// 获取输出数据
outputData := ic.outputTensor.GetData()
// 找到最大概率的类别
maxIdx := 0
maxProb := outputData[0]
for i, prob := range outputData {
if prob > maxProb {
maxProb = prob
maxIdx = i
}
}
// 应用softmax获取概率
expSum := float32(0)
for _, v := range outputData {
expSum += exp(v)
}
probability := exp(outputData[maxIdx]) / expSum
return ic.classes[maxIdx], probability, nil
}
// Close 关闭分类器
func (ic *ImageClassifier) Close() error {
if ic.inputTensor != nil {
ic.inputTensor.Destroy()
}
if ic.outputTensor != nil {
ic.outputTensor.Destroy()
}
if ic.session != nil {
ic.session.Destroy()
}
return nil
}
// exp 计算e的x次方(简单实现)
func exp(x float32) float32 {
// 使用泰勒级数近似
result := float32(1.0)
term := float32(1.0)
for i := 1; i < 20; i++ {
term *= x / float32(i)
result += term
}
return result
}
@@ -1,120 +0,0 @@
package classifier
import (
"bytes"
"image"
"os"
)
// preprocessImage 预处理图片文件
func (ic *ImageClassifier) preprocessImage(imgPath string) ([]float32, error) {
file, err := os.Open(imgPath)
if err != nil {
return nil, err
}
defer file.Close()
img, _, err := image.Decode(file)
if err != nil {
return nil, err
}
return ic.processImage(img)
}
// preprocessImageBytes 预处理图片字节数据
func (ic *ImageClassifier) preprocessImageBytes(imgData []byte) ([]float32, error) {
img, _, err := image.Decode(bytes.NewReader(imgData))
if err != nil {
return nil, err
}
return ic.processImage(img)
}
// processImage 处理图片为模型输入格式
func (ic *ImageClassifier) processImage(img image.Image) ([]float32, error) {
// 调整图片大小到224x224
resized := resizeImage(img, 224, 224)
// 转换为float32数组,并进行归一化
// ImageNet的均值和标准差
mean := []float32{0.485, 0.456, 0.406}
std := []float32{0.229, 0.224, 0.225}
data := make([]float32, 3*224*224)
for y := 0; y < 224; y++ {
for x := 0; x < 224; x++ {
r, g, b, _ := resized.At(x, y).RGBA()
// 转换为0-1范围
rNorm := float32(r) / 65535.0
gNorm := float32(g) / 65535.0
bNorm := float32(b) / 65535.0
// 应用归一化
idx := y*224 + x
data[idx] = (rNorm - mean[0]) / std[0] // R通道
data[224*224+idx] = (gNorm - mean[1]) / std[1] // G通道
data[2*224*224+idx] = (bNorm - mean[2]) / std[2] // B通道
}
}
return data, nil
}
// resizeImage 调整图片大小
func resizeImage(img image.Image, width, height int) image.Image {
bounds := img.Bounds()
srcW := bounds.Dx()
srcH := bounds.Dy()
dst := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
// 简单的最近邻插值
srcX := x * srcW / width
srcY := y * srcH / height
if srcX >= srcW {
srcX = srcW - 1
}
if srcY >= srcH {
srcY = srcH - 1
}
dst.Set(x, y, img.At(srcX, srcY))
}
}
return dst
}
// centerCrop 中心裁剪图片
func centerCrop(img image.Image, size int) image.Image {
bounds := img.Bounds()
width := bounds.Dx()
height := bounds.Dy()
var x0, y0 int
if width > height {
x0 = (width - height) / 2
y0 = 0
width = height
} else {
x0 = 0
y0 = (height - width) / 2
height = width
}
cropped := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
cropped.Set(x, y, img.At(x0+x, y0+y))
}
}
return resizeImage(cropped, size, size)
}
@@ -1,17 +0,0 @@
package classifier
import "likedream-goframe/internal/utils"
var imageClassifier, _ = NewImageClassifier("static/model.onnx", "static/classes.txt")
// 图片数据 & 类别名称
// return 是否匹配
func ClassifyImage(imgData []byte, className string) bool {
if imageClassifier == nil {
c, err := NewImageClassifier("static/model.onnx", "static/classes.txt")
utils.OkOrPanic(err)
imageClassifier = c
}
name, _, _ := imageClassifier.PredictFromBytes(imgData)
return className == name
}
@@ -0,0 +1,233 @@
package cookiejar
import (
"bytes"
"encoding/gob"
"github.com/bytedance/sonic"
"io"
"likedream-goframe/internal/utils"
"net/http"
"net/url"
"sync"
)
type domainMap map[string]cookieMap
// AllSavePersistCookieJar 完全无限制的 Cookie Jar
// 所有域名的 cookies 都保存在一起,访问任何域名都返回所有 cookies
// 不检查域名、路径等限制,适用于需要跨域共享 session 的场景
type AllSavePersistCookieJar struct {
domainMap domainMap
*sync.RWMutex
}
const globalDomain = "*" // 使用全局域名存储所有 cookies
func NewAllSavePersistCookieJar() *AllSavePersistCookieJar {
return &AllSavePersistCookieJar{
domainMap: make(domainMap),
RWMutex: &sync.RWMutex{},
}
}
func (a *AllSavePersistCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
a.Lock()
defer a.Unlock()
// 使用全局域名,所有 cookies 保存在一起
if _, exists := a.domainMap[globalDomain]; !exists {
a.domainMap[globalDomain] = make(cookieMap)
}
for _, cookie := range cookies {
if a.domainMap[globalDomain] == nil {
a.domainMap[globalDomain] = make(cookieMap)
}
// 查找是否存在相同 name 的 cookie(忽略 path 和 domain)
existingCookies := a.domainMap[globalDomain][cookie.Name]
found := false
for i, existing := range existingCookies {
if existing.Path == cookie.Path && existing.Domain == cookie.Domain {
// 找到完全匹配的 cookie,替换它
existingCookies[i] = cookie
found = true
break
}
}
if !found {
// 没有找到匹配的,添加新的 cookie
a.domainMap[globalDomain][cookie.Name] = append(a.domainMap[globalDomain][cookie.Name], cookie)
}
}
}
func (a *AllSavePersistCookieJar) Cookies(u *url.URL) []*http.Cookie {
a.RLock()
defer a.RUnlock()
// 直接返回全局域名下的所有 cookies,不检查域名和路径
if cookiesList, exists := a.domainMap[globalDomain]; exists {
result := make([]*http.Cookie, 0, len(cookiesList))
for _, cookies := range cookiesList {
// 如果只有一个 cookie,直接返回
if len(cookies) == 1 {
result = append(result, cookies[0])
continue
}
// 有多个同名 cookie,返回最后设置的那个(列表最后一个)
if len(cookies) > 0 {
result = append(result, cookies[len(cookies)-1])
}
}
return result
}
return nil
}
func (a *AllSavePersistCookieJar) Serialize() string {
a.RLock()
defer a.RUnlock()
json, err := sonic.Marshal(a.domainMap)
utils.OkOrPanic(err)
return string(json)
}
func (a *AllSavePersistCookieJar) Deserialize(content string) {
a.Lock()
defer a.Unlock()
if content == "" {
a.domainMap = make(domainMap)
return
}
var domainMap domainMap
err := sonic.Unmarshal([]byte(content), &domainMap)
// 如果反序列化失败,尝试从旧格式转换
if err != nil {
// 旧格式: map[string]map[string]*http.Cookie
type oldCookieMap map[string]*http.Cookie
type oldDomainMap map[string]oldCookieMap
var oldMap oldDomainMap
oldErr := sonic.Unmarshal([]byte(content), &oldMap)
if oldErr != nil {
// 两种格式都失败,抛出原始错误
utils.OkOrPanic(err)
return
}
// 转换旧格式到新格式
domainMap = make(map[string]cookieMap)
for domain, cookies := range oldMap {
domainMap[domain] = make(map[string][]*http.Cookie)
for name, cookie := range cookies {
domainMap[domain][name] = []*http.Cookie{cookie}
}
}
}
a.domainMap = domainMap
}
func (a *AllSavePersistCookieJar) SerializeBytes() []byte {
a.RLock()
defer a.RUnlock()
ret := make([]byte, 0)
retWriter := bytes.NewBuffer(ret)
err := gob.NewEncoder(retWriter).
Encode(a.domainMap)
utils.OkOrPanic(err)
return retWriter.Bytes()
}
func (a *AllSavePersistCookieJar) DeserializeBytes(content []byte) {
a.Lock()
defer a.Unlock()
if len(content) == 0 {
a.domainMap = make(domainMap)
return
}
retReader := bytes.NewBuffer(content)
var domainMap domainMap
err := gob.NewDecoder(retReader).
Decode(&domainMap)
// 如果反序列化失败,尝试从旧格式转换
if err != nil {
// 旧格式: map[string]map[string]*http.Cookie
type oldCookieMap map[string]*http.Cookie
type oldDomainMap map[string]oldCookieMap
retReader = bytes.NewBuffer(content)
var oldMap oldDomainMap
oldErr := gob.NewDecoder(retReader).Decode(&oldMap)
if oldErr != nil {
// 两种格式都失败,抛出原始错误
utils.OkOrPanic(err)
return
}
// 转换旧格式到新格式
domainMap = make(map[string]cookieMap)
for domain, cookies := range oldMap {
domainMap[domain] = make(map[string][]*http.Cookie)
for name, cookie := range cookies {
domainMap[domain][name] = []*http.Cookie{cookie}
}
}
}
a.domainMap = domainMap
}
func (a *AllSavePersistCookieJar) DeserializeReader(reader io.Reader) {
a.Lock()
defer a.Unlock()
// 先读取所有数据,以便在失败时可以重试
data, err := io.ReadAll(reader)
utils.OkOrPanic(err)
var domainMap domainMap
err = gob.NewDecoder(bytes.NewBuffer(data)).Decode(&domainMap)
// 如果反序列化失败,尝试从旧格式转换
if err != nil {
// 旧格式: map[string]map[string]*http.Cookie
type oldCookieMap map[string]*http.Cookie
type oldDomainMap map[string]oldCookieMap
var oldMap oldDomainMap
oldErr := gob.NewDecoder(bytes.NewBuffer(data)).Decode(&oldMap)
if oldErr != nil {
// 两种格式都失败,抛出原始错误
utils.OkOrPanic(err)
return
}
// 转换旧格式到新格式
domainMap = make(map[string]cookieMap)
for domain, cookies := range oldMap {
domainMap[domain] = make(map[string][]*http.Cookie)
for name, cookie := range cookies {
domainMap[domain][name] = []*http.Cookie{cookie}
}
}
}
a.domainMap = domainMap
}
+234 -16
View File
@@ -3,17 +3,19 @@ package cookiejar
import (
"bytes"
"encoding/gob"
"github.com/bytedance/sonic"
"io"
"likedream-goframe/internal/utils"
"net/http"
"net/url"
"strings"
"sync"
"github.com/bytedance/sonic"
)
type hostMap map[string]cookieMap
type cookieMap map[string]*http.Cookie
type cookieMap map[string][]*http.Cookie
type PersistCookieJar struct {
hostMap hostMap
*sync.RWMutex
@@ -33,14 +35,35 @@ func (p *PersistCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
host := u.Host
if _, exists := p.hostMap[host]; !exists {
p.hostMap = make(hostMap)
p.hostMap[host] = make(cookieMap)
}
for _, cookie := range cookies {
if p.hostMap[host] == nil {
p.hostMap[host] = make(cookieMap)
}
p.hostMap[host][cookie.Name] = cookie
// 检查并删除全局 cookie 中同名的 cookie
if globalCookies, exists := p.hostMap[""]; exists {
delete(globalCookies, cookie.Name)
}
// 查找是否存在相同 name, path 和 domain 的 cookie
existingCookies := p.hostMap[host][cookie.Name]
found := false
for i, existing := range existingCookies {
if existing.Path == cookie.Path && existing.Domain == cookie.Domain {
// 找到完全匹配的 cookie,替换它
existingCookies[i] = cookie
found = true
break
}
}
if !found {
// 没有找到匹配的,添加新的 cookie
p.hostMap[host][cookie.Name] = append(p.hostMap[host][cookie.Name], cookie)
}
}
}
@@ -50,14 +73,110 @@ func (p *PersistCookieJar) Cookies(u *url.URL) []*http.Cookie {
defer p.RUnlock()
host := u.Host
if cookies, exists := p.hostMap[host]; exists {
result := make([]*http.Cookie, 0, len(cookies))
for _, cookie := range cookies {
result = append(result, cookie)
}
return result
requestPath := u.Path
if requestPath == "" {
requestPath = "/"
}
return nil
result := make([]*http.Cookie, 0)
addedNames := make(map[string]bool)
// 先添加匹配 host 的 cookie
if cookiesList, exists := p.hostMap[host]; exists {
for cookieName, cookies := range cookiesList {
// 如果只有一个 cookie,直接返回
if len(cookies) == 1 {
result = append(result, cookies[0])
addedNames[cookieName] = true
continue
}
// 有多个同名 cookie,选择最匹配的(path 最长且匹配请求路径的)
var bestMatch *http.Cookie
bestMatchLen := -1
for _, cookie := range cookies {
cookiePath := cookie.Path
if cookiePath == "" {
cookiePath = "/"
}
// 检查 path 是否匹配
if pathMatch(requestPath, cookiePath) {
if len(cookiePath) > bestMatchLen {
bestMatch = cookie
bestMatchLen = len(cookiePath)
}
}
}
// 如果找到匹配的,添加到结果中
if bestMatch != nil {
result = append(result, bestMatch)
addedNames[cookieName] = true
}
}
}
// 再添加没有 host 的全局 cookie(避免重复)
if globalCookies, exists := p.hostMap[""]; exists {
for cookieName, cookies := range globalCookies {
// 如果该名称的 cookie 已经从 host 中添加了,跳过
if addedNames[cookieName] {
continue
}
// 如果只有一个 cookie,直接返回
if len(cookies) == 1 {
result = append(result, cookies[0])
continue
}
// 有多个同名 cookie,选择最匹配的
var bestMatch *http.Cookie
bestMatchLen := -1
for _, cookie := range cookies {
cookiePath := cookie.Path
if cookiePath == "" {
cookiePath = "/"
}
if pathMatch(requestPath, cookiePath) {
if len(cookiePath) > bestMatchLen {
bestMatch = cookie
bestMatchLen = len(cookiePath)
}
}
}
if bestMatch != nil {
result = append(result, bestMatch)
}
}
}
return result
}
// pathMatch 检查请求路径是否匹配 cookie 的 path
// 根据 RFC 6265 的规则
func pathMatch(requestPath, cookiePath string) bool {
if requestPath == cookiePath {
return true
}
if strings.HasPrefix(requestPath, cookiePath) {
// cookiePath 以 / 结尾,或者 requestPath 的下一个字符是 /
if strings.HasSuffix(cookiePath, "/") {
return true
}
if len(requestPath) > len(cookiePath) && requestPath[len(cookiePath)] == '/' {
return true
}
}
return false
}
func (p *PersistCookieJar) Serialize() string {
@@ -83,7 +202,29 @@ func (p *PersistCookieJar) Deserialize(content string) {
var hostMap hostMap
err := sonic.Unmarshal([]byte(content), &hostMap)
utils.OkOrPanic(err)
// 如果反序列化失败,尝试从旧格式转换
if err != nil {
// 旧格式: map[string]map[string]*http.Cookie
type oldCookieMap map[string]*http.Cookie
type oldHostMap map[string]oldCookieMap
var oldMap oldHostMap
oldErr := sonic.Unmarshal([]byte(content), &oldMap)
if oldErr != nil {
// 两种格式都失败,抛出原始错误
utils.OkOrPanic(err)
return
}
// 转换旧格式到新格式
hostMap = make(map[string]cookieMap)
for host, cookies := range oldMap {
hostMap[host] = make(map[string][]*http.Cookie)
for name, cookie := range cookies {
hostMap[host][name] = []*http.Cookie{cookie}
}
}
}
p.hostMap = hostMap
}
@@ -116,7 +257,31 @@ func (p *PersistCookieJar) DeserializeBytes(content []byte) {
var hostMap hostMap
err := gob.NewDecoder(retReader).
Decode(&hostMap)
utils.OkOrPanic(err)
// 如果反序列化失败,尝试从旧格式转换
if err != nil {
// 旧格式: map[string]map[string]*http.Cookie
type oldCookieMap map[string]*http.Cookie
type oldHostMap map[string]oldCookieMap
retReader = bytes.NewBuffer(content)
var oldMap oldHostMap
oldErr := gob.NewDecoder(retReader).Decode(&oldMap)
if oldErr != nil {
// 两种格式都失败,抛出原始错误
utils.OkOrPanic(err)
return
}
// 转换旧格式到新格式
hostMap = make(map[string]cookieMap)
for host, cookies := range oldMap {
hostMap[host] = make(map[string][]*http.Cookie)
for name, cookie := range cookies {
hostMap[host][name] = []*http.Cookie{cookie}
}
}
}
p.hostMap = hostMap
}
@@ -125,10 +290,63 @@ func (p *PersistCookieJar) DeserializeReader(reader io.Reader) {
p.Lock()
defer p.Unlock()
var hostMap hostMap
err := gob.NewDecoder(reader).
Decode(&hostMap)
// 先读取所有数据,以便在失败时可以重试
data, err := io.ReadAll(reader)
utils.OkOrPanic(err)
var hostMap hostMap
err = gob.NewDecoder(bytes.NewBuffer(data)).Decode(&hostMap)
// 如果反序列化失败,尝试从旧格式转换
if err != nil {
// 旧格式: map[string]map[string]*http.Cookie
type oldCookieMap map[string]*http.Cookie
type oldHostMap map[string]oldCookieMap
var oldMap oldHostMap
oldErr := gob.NewDecoder(bytes.NewBuffer(data)).Decode(&oldMap)
if oldErr != nil {
// 两种格式都失败,抛出原始错误
utils.OkOrPanic(err)
return
}
// 转换旧格式到新格式
hostMap = make(map[string]cookieMap)
for host, cookies := range oldMap {
hostMap[host] = make(map[string][]*http.Cookie)
for name, cookie := range cookies {
hostMap[host][name] = []*http.Cookie{cookie}
}
}
}
p.hostMap = hostMap
}
// RemoveCookieInfo 将所有 host 的 cookie 移除 host 信息,存储为全局 cookie
func (p *PersistCookieJar) RemoveCookieInfo() {
p.Lock()
defer p.Unlock()
// 确保全局 cookie map 存在
if _, exists := p.hostMap[""]; !exists {
p.hostMap[""] = make(cookieMap)
}
// 将所有 host 的 cookie 移动到全局
for host, hostCookies := range p.hostMap {
// 跳过全局 cookie 本身
if host == "" {
continue
}
// 将该 host 的所有 cookie 移动到全局
for cookieName, cookies := range hostCookies {
p.hostMap[""][cookieName] = cookies
}
// 删除该 host 的 cookie
delete(p.hostMap, host)
}
}
@@ -114,3 +114,10 @@ func TestPersistCookieJar_DeserializeReader(t *testing.T) {
}
}
}
func TestDeseralizeCookie(t *testing.T) {
var str = `{"cdpc.campusphere.net":{"MOD_AUTH_CAS":{"Name":"MOD_AUTH_CAS","Value":"ST-iap:1021988086918169:ST:8d7fe60b-dc7d-4fcd-b26b-5517e3ca1926:20251113162807","Quoted":false,"Path":"/","Domain":"","Expires":"0001-01-01T00:00:00Z","RawExpires":"","MaxAge":0,"Secure":false,"HttpOnly":true,"SameSite":0,"Partitioned":false,"Raw":"MOD_AUTH_CAS=ST-iap:1021988086918169:ST:8d7fe60b-dc7d-4fcd-b26b-5517e3ca1926:20251113162807; path=/; Httponly","Unparsed":null},"HWWAFSESID":{"Name":"HWWAFSESID","Value":"88d84731cf506df0bf","Quoted":false,"Path":"/","Domain":"","Expires":"0001-01-01T00:00:00Z","RawExpires":"","MaxAge":0,"Secure":false,"HttpOnly":false,"SameSite":0,"Partitioned":false,"Raw":"HWWAFSESID=88d84731cf506df0bf; path=/","Unparsed":null},"HWWAFSESTIME":{"Name":"HWWAFSESTIME","Value":"1763022486728","Quoted":false,"Path":"/","Domain":"","Expires":"0001-01-01T00:00:00Z","RawExpires":"","MaxAge":0,"Secure":false,"HttpOnly":false,"SameSite":0,"Partitioned":false,"Raw":"HWWAFSESTIME=1763022486728; path=/","Unparsed":null},"CONVERSATION":{"Name":"CONVERSATION","Value":"iap-1021988086918169-CONV-83bee566-7010-4ef6-ad17-4b9eebcccdf8","Quoted":false,"Path":"/iap","Domain":"","Expires":"0001-01-01T00:00:00Z","RawExpires":"","MaxAge":0,"Secure":false,"HttpOnly":true,"SameSite":0,"Partitioned":false,"Raw":"CONVERSATION=iap-1021988086918169-CONV-83bee566-7010-4ef6-ad17-4b9eebcccdf8; Path=/iap; HttpOnly","Unparsed":null},"CASTGC":{"Name":"CASTGC","Value":"iap-1021988086918169-TGT-717aa5db-92cb-46ac-a178-64b630946f43","Quoted":false,"Path":"/iap","Domain":"","Expires":"0001-01-01T00:00:00Z","RawExpires":"","MaxAge":0,"Secure":true,"HttpOnly":true,"SameSite":4,"Partitioned":false,"Raw":"CASTGC=iap-1021988086918169-TGT-717aa5db-92cb-46ac-a178-64b630946f43; Path=/iap; HttpOnly; Secure; SameSite=None","Unparsed":null}}}`
jar := NewPersistCookieJar()
jar.Deserialize(str)
t.Log(jar)
}
@@ -0,0 +1,15 @@
package cryptoutil
import (
"encoding/base64"
"strings"
"testing"
)
func TestAesDecrypt(t *testing.T) {
str := "gTIjjNBTwN0Vdv2o5ZlTZ6gHbFcIA0MV0caRqvv/50tptfoSO+VASLxpQZWy Xa86lY21QtmXhwlvTsfd3XR819O0IUi2FynPOM6s0pDsSndM2JhhqoRNXBTK 4WqfMgt0RCQJ38KP8XtM5O+Ktkj1YHHM/OVEGqOxRcUh2M++JJCheJbSs+aQ 2T3va13/GWJH7PEd8JVEJgBiO3wXLOb9+CvvWuU/Frc9nNNxQL7LrgZTdQAv C9Yt+5/a2n41reOeOs7Bu5C4FFlHQSs71Rom/HtIhL67x1ecIre1rbgK9xxz 1AhTEHY6nhdZmktPGJD5CfxvLVWIeLv09CjKqdSaNodkP4e9YTScNEZ2s8TK y8Xhv8L0r6gllC9B3nla4GRe+lqMvhDPDZx7PFu7kxFspf9yoM4eOpgWfCr9 xbDwxO9cXphiktfWsQiX28W4g2jCFFEazh01WBtpjM9LsLfbBgZA+z3jgfYa AXZuOvFQMJuUnLyOIGPtsaSTwXFDlJwNRgEw+Ctl22JSJbbu1yOoM9S6Ge27 rUVMhbAS5snVbNbLhgAymQLQQVLRwfsMLbtUfLTZuCC8zO8d16eOYw56+DqR QWNG1EXQz3CLeFDs2hFqcNWrAt773qANQ3zLPA65"
str = strings.ReplaceAll(str, " ", "")
decodeString, _ := base64.StdEncoding.DecodeString(str)
decrypt := AesDecryptForCat(decodeString)
t.Log(string(decrypt))
}
@@ -0,0 +1,76 @@
package cryptoutil
import (
"crypto/md5"
"encoding/base64"
"encoding/hex"
"net/url"
"strings"
"testing"
"github.com/gogf/gf/v2/container/gvar"
)
func TestDesDecrypt(t *testing.T) {
decodeString, _ := base64.StdEncoding.DecodeString("64JITpWPkKuGMhi1m7Ikxu1ekL6VQOdjmJvaQPI07MWbLETtJsxzkk2bCUVNaWgUULa3wS9LH+ANO5CUBeOXi02CcS/8lN/tYDFYCyuK7dLN+q0fD0gQlUsuHwy/78EkwZa9VrLh4e519vM9AxR2GnK4WGpumeH7TGJjDkLH0AitKfUYQnil9AKZEVgSSkHkg9ko64qj0QJvzmo76zOkMpIb1+1Pn2cO+5soTSl8BCsP/8hOP5HdOMVBCYH++iSK3O5sYz/cwZq/G8aJrqmEFcDHbgTouXyD5kxa4lb72Vw3Ku+eH+3qDwYMYxWrIcAm")
decrypt := DesDecrypt(decodeString, Cat)
str := "{\"lon\":\"113.045246\",\"lat\":\"28.077659\",\"model\":\"847b5b9c-2d97-41ec-8a5d-e9ac79da090d\",\"appVersion\":\"9.7.2\",\"userId\":\"430181200504150038\",\"systemName\":\"android\",\"deviceId\":\"09E46EC9-57C1-4724-BE53-5879CA80A2A0\",\"cpdailyVersion\":\"9.7.2\",\"systemVersion\":\"iOS 17\"}"
encrypt := DesEncrypt([]byte(str), Cat)
toString := base64.StdEncoding.EncodeToString(encrypt)
t.Log(toString)
t.Log(string(decrypt))
d2, _ := base64.StdEncoding.DecodeString("86JzX4MBsorI5KasKBHUj6orO1qcPWbo758Ux6G2NUoRuGCoZnE+cJW5slZvsflBrKITPeitF7GTisMNRJxRqlA8cLXAqyo9ZlBPPx0MM3KjILHiA/EvrRrmamo7vi1d/F0ChQc8jLfMgkkcOg4FBmM1H8Yg6KyYdMNN7anIbIdJ+6RPEsakjAIw0r4eawofSh5E0QclDry0v36GK3UA3GDBBfqSub8V5PRL7fQty/R0lx7+mfc2AIXXjUVsv85WQKoyyUwg3siWpQL5S1cbtQNUXClSQP1IAGROnBftvdrib01PR1h7Cp+kvE3HK0+/8l0FoGJ1aijff1t9T9MaVXM/jSUFwkA7tmZIjodrUDhZcCMNm5YLm1ZO4UVBQO118FyOBjSLet0nPc0Wg0/dTO0XZoo9e2bCQwjIQC2G8TdzHDxvDjGEc6hX++nZrN23n19bAgqdvzZt58KftznE9s5t4CEjiHx3ykyidGEGr/VEi/05uOtm8/EsFUVp8+MyVgaTxoaq/4LBu28L59U6+6mZjOFUt49STR5p3UPI3ZJQLe2lmnx9BdqrIHbdXgYNu5oNzrDM8m3z3ns9M2HEGg==")
d := AesDecryptForCat(d2)
t.Log(d)
form := map[string]*gvar.Var{
"appVersion": gvar.New("9.7.2"),
"bodyString": gvar.New("0u7hBd+6O11ZaGsT51FUAVfB72waqtTx5oQQLVEEAuCM29x8lQc41i9/dF6+9wjQNC05jdJTBmHlwFoLD09hfsnQWSyjl/ys124RUQL3r/Um5AwNdKiJE24YQsyQlP3ZamUIFlrNSxmn/DfZ/jR9oZhiVKZSlygA2LNLxtNOyk4rETfqKpZKFOnThbXy5Hf779jUDmPNwn1ZrdWN8UHm6wO+EahrrX60vC02VUHs7r3rMrDWZjBJ57eSgD0QwZabcIb/u9kx1AD8OAxINar3X+N+uAhfrxjHd6v6OVvnc2I9iPLEGNFRKZ94jq64hnATOSH6S/IR/QbDoxebWpGUXm3ALYZeoni09uyhG7IE5NtRw/OmqYtdknrs+c0qyOOBkvxPlqKo+SV4qq7xtrn9RWeZIigiyM8mVtdovN1T/x3DnHxd/AYoSS3/cGkg+2QHzWey/Xf5c4g0EvhaGlE0C7ejKIn5wp0XeHyvAjm4ZVyUhgTN2dZVCQ6ChxXOpLnVDWPNeeZ9vysqVl1ZSkQdCz20gy3p9RfYuArluQqjwIoWQe632Cc/Ep++99V5Tc9iIlLr93PfxFaJJfov3avLKw=="),
"calVersion": gvar.New("firstv"),
"deviceId": gvar.New("09E46EC9-57C1-4724-BE53-5879CA80A2A0"),
"lat": gvar.New("28.077659"),
"lon": gvar.New("113.045246"),
"model": gvar.New("847b5b9c-2d97-41ec-8a5d-e9ac79da090d"),
"systemName": gvar.New("android"),
"systemVersion": gvar.New("iOS 17"),
"userId": gvar.New("430181200504150038"),
"version": gvar.New("first_v4"),
}
sign := GetSubmitSign(form)
t.Log(sign)
}
const submitSignSalt = "hru89rwr87Nthifa" // 请根据实际情况替换为真实盐值
func GetSubmitSign(form map[string]*gvar.Var) string {
// 按照指定顺序构建哈希数据
keys := []string{
"appVersion",
"bodyString",
"deviceId",
"lat",
"lon",
"model",
"systemName",
"systemVersion",
"userId",
}
var sb strings.Builder
for _, key := range keys {
val := ""
if v, ok := form[key]; ok && v != nil {
val = v.String()
}
// 这里是 key=UrlEncode(key)&,注意是对 key 进行 url encode
sb.WriteString(key)
sb.WriteString("=")
sb.WriteString(url.QueryEscape(val))
sb.WriteString("&")
}
sb.WriteString(submitSignSalt)
// 计算MD5
hash := md5.Sum([]byte(sb.String()))
return hex.EncodeToString(hash[:])
}
@@ -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[:])
}
@@ -0,0 +1,838 @@
package cryptoutil
import (
"fmt"
"math"
"strings"
)
// RSA Constants
const (
biRadixBits = 16
bitsPerDigit = biRadixBits
biRadix = 1 << 16 // 65536
biHalfRadix = biRadix >> 1
biRadixSquared = biRadix * biRadix
maxDigitVal = biRadix - 1
maxInteger = 9999999999999998
dpl10 = 15
)
var (
maxDigits int
zeroArray []int
bigZero *BigInt
bigOne *BigInt
lr10 *BigInt
highBitMasks = []int{0x0000, 0x8000, 0xC000, 0xE000, 0xF000, 0xF800,
0xFC00, 0xFE00, 0xFF00, 0xFF80, 0xFFC0, 0xFFE0,
0xFFF0, 0xFFF8, 0xFFFC, 0xFFFE, 0xFFFF}
lowBitMasks = []int{0x0000, 0x0001, 0x0003, 0x0007, 0x000F, 0x001F,
0x003F, 0x007F, 0x00FF, 0x01FF, 0x03FF, 0x07FF,
0x0FFF, 0x1FFF, 0x3FFF, 0x7FFF, 0xFFFF}
hexToChar = []byte{'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'a', 'b', 'c', 'd', 'e', 'f'}
hexatrigesimalToChar = []byte{
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j',
'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't',
'u', 'v', 'w', 'x', 'y', 'z'}
)
// BigInt represents a big integer
type BigInt struct {
digits []int
isNeg bool
}
// NewBigInt creates a new BigInt
func NewBigInt() *BigInt {
digits := make([]int, len(zeroArray))
copy(digits, zeroArray)
return &BigInt{
digits: digits,
isNeg: false,
}
}
// NewBigIntEmpty creates an empty BigInt
func NewBigIntEmpty() *BigInt {
return &BigInt{
digits: nil,
isNeg: false,
}
}
// SetMaxDigits sets the maximum number of digits
func SetMaxDigits(value int) {
maxDigits = value
zeroArray = make([]int, maxDigits)
bigZero = NewBigInt()
bigOne = NewBigInt()
bigOne.digits[0] = 1
lr10 = BiFromNumber(1000000000000000)
}
// BiFromNumber creates a BigInt from a number
func BiFromNumber(i int64) *BigInt {
result := NewBigInt()
result.isNeg = i < 0
i = int64(math.Abs(float64(i)))
j := 0
for i > 0 {
result.digits[j] = int(i & maxDigitVal)
j++
i = i / biRadix
}
return result
}
// BiFromDecimal creates a BigInt from a decimal string
func BiFromDecimal(s string) *BigInt {
isNeg := len(s) > 0 && s[0] == '-'
i := 0
if isNeg {
i = 1
}
// Skip leading zeros
for i < len(s) && s[i] == '0' {
i++
}
if i == len(s) {
return NewBigInt()
}
digitCount := len(s) - i
fgl := digitCount % dpl10
if fgl == 0 {
fgl = dpl10
}
num := parseNumber(s[i : i+fgl])
result := BiFromNumber(num)
i += fgl
for i < len(s) {
result = BiAdd(BiMultiply(result, lr10), BiFromNumber(parseNumber(s[i:min(i+dpl10, len(s))])))
i += dpl10
}
result.isNeg = isNeg
return result
}
// BiCopy copies a BigInt
func BiCopy(bi *BigInt) *BigInt {
result := NewBigIntEmpty()
result.digits = make([]int, len(bi.digits))
copy(result.digits, bi.digits)
result.isNeg = bi.isNeg
return result
}
// ReverseStr reverses a string
func ReverseStr(s string) string {
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}
// BiToString converts BigInt to string with given radix
func BiToString(x *BigInt, radix int) string {
b := NewBigInt()
b.digits[0] = radix
qr := BiDivideModulo(x, b)
result := string(hexatrigesimalToChar[qr[1].digits[0]])
for BiCompare(qr[0], bigZero) == 1 {
qr = BiDivideModulo(qr[0], b)
result += string(hexatrigesimalToChar[qr[1].digits[0]])
}
prefix := ""
if x.isNeg {
prefix = "-"
}
return prefix + ReverseStr(result)
}
// BiToDecimal converts BigInt to decimal string
func BiToDecimal(x *BigInt) string {
b := NewBigInt()
b.digits[0] = 10
qr := BiDivideModulo(x, b)
result := fmt.Sprintf("%d", qr[1].digits[0])
for BiCompare(qr[0], bigZero) == 1 {
qr = BiDivideModulo(qr[0], b)
result += fmt.Sprintf("%d", qr[1].digits[0])
}
prefix := ""
if x.isNeg {
prefix = "-"
}
return prefix + ReverseStr(result)
}
// DigitToHex converts a digit to hex string
func DigitToHex(n int) string {
mask := 0xf
result := ""
for i := 0; i < 4; i++ {
result += string(hexToChar[n&mask])
n >>= 4
}
return ReverseStr(result)
}
// BiToHex converts BigInt to hex string
func BiToHex(x *BigInt) string {
result := ""
for i := BiHighIndex(x); i > -1; i-- {
result += DigitToHex(x.digits[i])
}
return result
}
// CharToHex converts a character code to hex value
func CharToHex(c byte) int {
const (
ZERO = 48
NINE = ZERO + 9
littleA = 97
littleZ = littleA + 25
bigA = 65
bigZ = 65 + 25
)
if c >= ZERO && c <= NINE {
return int(c - ZERO)
} else if c >= bigA && c <= bigZ {
return 10 + int(c-bigA)
} else if c >= littleA && c <= littleZ {
return 10 + int(c-littleA)
}
return 0
}
// HexToDigit converts hex string to digit
func HexToDigit(s string) int {
result := 0
sl := min(len(s), 4)
for i := 0; i < sl; i++ {
result <<= 4
result |= CharToHex(s[i])
}
return result
}
// BiFromHex creates BigInt from hex string
func BiFromHex(s string) *BigInt {
result := NewBigInt()
sl := len(s)
for i, j := sl, 0; i > 0; i, j = i-4, j+1 {
start := max(i-4, 0)
length := min(i, 4)
if start+length > sl {
length = sl - start
}
result.digits[j] = HexToDigit(s[start : start+length])
}
return result
}
// BiFromString creates BigInt from string with given radix
func BiFromString(s string, radix int) *BigInt {
isNeg := len(s) > 0 && s[0] == '-'
istop := 0
if isNeg {
istop = 1
}
result := NewBigInt()
place := NewBigInt()
place.digits[0] = 1
for i := len(s) - 1; i >= istop; i-- {
c := s[i]
digit := CharToHex(c)
biDigit := BiMultiplyDigit(place, digit)
result = BiAdd(result, biDigit)
place = BiMultiplyDigit(place, radix)
}
result.isNeg = isNeg
return result
}
// BiAdd adds two BigInts
func BiAdd(x, y *BigInt) *BigInt {
var result *BigInt
if x.isNeg != y.isNeg {
y.isNeg = !y.isNeg
result = BiSubtract(x, y)
y.isNeg = !y.isNeg
} else {
result = NewBigInt()
c := 0
for i := 0; i < len(x.digits); i++ {
n := x.digits[i] + y.digits[i] + c
result.digits[i] = n % biRadix
if n >= biRadix {
c = 1
} else {
c = 0
}
}
result.isNeg = x.isNeg
}
return result
}
// BiSubtract subtracts two BigInts
func BiSubtract(x, y *BigInt) *BigInt {
var result *BigInt
if x.isNeg != y.isNeg {
y.isNeg = !y.isNeg
result = BiAdd(x, y)
y.isNeg = !y.isNeg
} else {
result = NewBigInt()
c := 0
for i := 0; i < len(x.digits); i++ {
n := x.digits[i] - y.digits[i] + c
result.digits[i] = n % biRadix
if result.digits[i] < 0 {
result.digits[i] += biRadix
}
if n < 0 {
c = -1
} else {
c = 0
}
}
if c == -1 {
c = 0
for i := 0; i < len(x.digits); i++ {
n := 0 - result.digits[i] + c
result.digits[i] = n % biRadix
if result.digits[i] < 0 {
result.digits[i] += biRadix
}
if n < 0 {
c = -1
} else {
c = 0
}
}
result.isNeg = !x.isNeg
} else {
result.isNeg = x.isNeg
}
}
return result
}
// BiHighIndex returns the highest non-zero digit index
func BiHighIndex(x *BigInt) int {
result := len(x.digits) - 1
for result > 0 && x.digits[result] == 0 {
result--
}
return result
}
// BiNumBits returns the number of bits
func BiNumBits(x *BigInt) int {
n := BiHighIndex(x)
d := x.digits[n]
m := (n + 1) * bitsPerDigit
result := m
for result > m-bitsPerDigit {
if (d & 0x8000) != 0 {
break
}
d <<= 1
result--
}
return result
}
// BiMultiply multiplies two BigInts
func BiMultiply(x, y *BigInt) *BigInt {
result := NewBigInt()
n := BiHighIndex(x)
t := BiHighIndex(y)
for i := 0; i <= t; i++ {
c := 0
k := i
for j := 0; j <= n; j, k = j+1, k+1 {
uv := result.digits[k] + x.digits[j]*y.digits[i] + c
result.digits[k] = uv & maxDigitVal
c = uv >> biRadixBits
}
result.digits[i+n+1] = c
}
result.isNeg = x.isNeg != y.isNeg
return result
}
// BiMultiplyDigit multiplies BigInt by a digit
func BiMultiplyDigit(x *BigInt, y int) *BigInt {
result := NewBigInt()
n := BiHighIndex(x)
c := 0
for j := 0; j <= n; j++ {
uv := result.digits[j] + x.digits[j]*y + c
result.digits[j] = uv & maxDigitVal
c = uv >> biRadixBits
}
result.digits[1+n] = c
return result
}
// ArrayCopy copies array elements
func ArrayCopy(src []int, srcStart int, dest []int, destStart int, n int) {
m := min(srcStart+n, len(src))
for i, j := srcStart, destStart; i < m; i, j = i+1, j+1 {
dest[j] = src[i]
}
}
// BiShiftLeft shifts BigInt left by n bits
func BiShiftLeft(x *BigInt, n int) *BigInt {
digitCount := n / bitsPerDigit
result := NewBigInt()
ArrayCopy(x.digits, 0, result.digits, digitCount, len(result.digits)-digitCount)
bits := n % bitsPerDigit
rightBits := bitsPerDigit - bits
for i, i1 := len(result.digits)-1, len(result.digits)-2; i > 0; i, i1 = i-1, i1-1 {
result.digits[i] = ((result.digits[i] << bits) & maxDigitVal) |
((result.digits[i1] & highBitMasks[bits]) >> rightBits)
}
result.digits[0] = ((result.digits[0] << bits) & maxDigitVal)
result.isNeg = x.isNeg
return result
}
// BiShiftRight shifts BigInt right by n bits
func BiShiftRight(x *BigInt, n int) *BigInt {
digitCount := n / bitsPerDigit
result := NewBigInt()
ArrayCopy(x.digits, digitCount, result.digits, 0, len(x.digits)-digitCount)
bits := n % bitsPerDigit
leftBits := bitsPerDigit - bits
for i, i1 := 0, 1; i < len(result.digits)-1; i, i1 = i+1, i1+1 {
result.digits[i] = (result.digits[i] >> bits) |
((result.digits[i1] & lowBitMasks[bits]) << leftBits)
}
result.digits[len(result.digits)-1] >>= bits
result.isNeg = x.isNeg
return result
}
// BiMultiplyByRadixPower multiplies BigInt by radix^n
func BiMultiplyByRadixPower(x *BigInt, n int) *BigInt {
result := NewBigInt()
ArrayCopy(x.digits, 0, result.digits, n, len(result.digits)-n)
return result
}
// BiDivideByRadixPower divides BigInt by radix^n
func BiDivideByRadixPower(x *BigInt, n int) *BigInt {
result := NewBigInt()
ArrayCopy(x.digits, n, result.digits, 0, len(result.digits)-n)
return result
}
// BiModuloByRadixPower computes BigInt mod radix^n
func BiModuloByRadixPower(x *BigInt, n int) *BigInt {
result := NewBigInt()
ArrayCopy(x.digits, 0, result.digits, 0, n)
return result
}
// BiCompare compares two BigInts
func BiCompare(x, y *BigInt) int {
if x.isNeg != y.isNeg {
if x.isNeg {
return -1
}
return 1
}
for i := len(x.digits) - 1; i >= 0; i-- {
if x.digits[i] != y.digits[i] {
if x.isNeg {
if x.digits[i] > y.digits[i] {
return -1
}
return 1
} else {
if x.digits[i] < y.digits[i] {
return -1
}
return 1
}
}
}
return 0
}
// BiDivideModulo divides two BigInts and returns quotient and remainder
func BiDivideModulo(x, y *BigInt) []*BigInt {
nb := BiNumBits(x)
tb := BiNumBits(y)
origYIsNeg := y.isNeg
var q, r *BigInt
if nb < tb {
if x.isNeg {
q = BiCopy(bigOne)
q.isNeg = !y.isNeg
x.isNeg = false
y.isNeg = false
r = BiSubtract(y, x)
x.isNeg = true
y.isNeg = origYIsNeg
} else {
q = NewBigInt()
r = BiCopy(x)
}
return []*BigInt{q, r}
}
q = NewBigInt()
r = x
t := int(math.Ceil(float64(tb)/float64(bitsPerDigit))) - 1
lambda := 0
for y.digits[t] < biHalfRadix {
y = BiShiftLeft(y, 1)
lambda++
tb++
t = int(math.Ceil(float64(tb)/float64(bitsPerDigit))) - 1
}
r = BiShiftLeft(r, lambda)
nb += lambda
n := int(math.Ceil(float64(nb)/float64(bitsPerDigit))) - 1
b := BiMultiplyByRadixPower(y, n-t)
for BiCompare(r, b) != -1 {
q.digits[n-t]++
r = BiSubtract(r, b)
}
for i := n; i > t; i-- {
ri := 0
if i < len(r.digits) {
ri = r.digits[i]
}
ri1 := 0
if i-1 < len(r.digits) {
ri1 = r.digits[i-1]
}
ri2 := 0
if i-2 < len(r.digits) {
ri2 = r.digits[i-2]
}
yt := 0
if t < len(y.digits) {
yt = y.digits[t]
}
yt1 := 0
if t-1 < len(y.digits) {
yt1 = y.digits[t-1]
}
if ri == yt {
q.digits[i-t-1] = maxDigitVal
} else {
q.digits[i-t-1] = (ri*biRadix + ri1) / yt
}
c1 := q.digits[i-t-1] * ((yt * biRadix) + yt1)
c2 := (ri * biRadixSquared) + ((ri1 * biRadix) + ri2)
for c1 > c2 {
q.digits[i-t-1]--
c1 = q.digits[i-t-1] * ((yt * biRadix) | yt1)
c2 = (ri * biRadix * biRadix) + ((ri1 * biRadix) + ri2)
}
b = BiMultiplyByRadixPower(y, i-t-1)
r = BiSubtract(r, BiMultiplyDigit(b, q.digits[i-t-1]))
if r.isNeg {
r = BiAdd(r, b)
q.digits[i-t-1]--
}
}
r = BiShiftRight(r, lambda)
q.isNeg = x.isNeg != origYIsNeg
if x.isNeg {
if origYIsNeg {
q = BiAdd(q, bigOne)
} else {
q = BiSubtract(q, bigOne)
}
y = BiShiftRight(y, lambda)
r = BiSubtract(y, r)
}
if r.digits[0] == 0 && BiHighIndex(r) == 0 {
r.isNeg = false
}
return []*BigInt{q, r}
}
// BiDivide divides two BigInts
func BiDivide(x, y *BigInt) *BigInt {
return BiDivideModulo(x, y)[0]
}
// BiModulo computes x mod y
func BiModulo(x, y *BigInt) *BigInt {
return BiDivideModulo(x, y)[1]
}
// BiMultiplyMod computes (x * y) mod m
func BiMultiplyMod(x, y, m *BigInt) *BigInt {
return BiModulo(BiMultiply(x, y), m)
}
// BiPowMod computes x^y mod m
func BiPowMod(x, y, m *BigInt) *BigInt {
result := BiCopy(bigOne)
a := x
k := y
for {
if (k.digits[0] & 1) != 0 {
result = BiMultiplyMod(result, a, m)
}
k = BiShiftRight(k, 1)
if k.digits[0] == 0 && BiHighIndex(k) == 0 {
break
}
a = BiMultiplyMod(a, a, m)
}
return result
}
// BarrettMu represents Barrett reduction
type BarrettMu struct {
modulus *BigInt
k int
mu *BigInt
bkplus1 *BigInt
}
// NewBarrettMu creates a new BarrettMu
func NewBarrettMu(m *BigInt) *BarrettMu {
bm := &BarrettMu{
modulus: BiCopy(m),
k: BiHighIndex(m) + 1,
}
b2k := NewBigInt()
if 2*bm.k < len(b2k.digits) {
b2k.digits[2*bm.k] = 1
}
bm.mu = BiDivide(b2k, bm.modulus)
bm.bkplus1 = NewBigInt()
if bm.k+1 < len(bm.bkplus1.digits) {
bm.bkplus1.digits[bm.k+1] = 1
}
return bm
}
// Modulo performs Barrett modulo
func (bm *BarrettMu) Modulo(x *BigInt) *BigInt {
q1 := BiDivideByRadixPower(x, bm.k-1)
q2 := BiMultiply(q1, bm.mu)
q3 := BiDivideByRadixPower(q2, bm.k+1)
r1 := BiModuloByRadixPower(x, bm.k+1)
r2term := BiMultiply(q3, bm.modulus)
r2 := BiModuloByRadixPower(r2term, bm.k+1)
r := BiSubtract(r1, r2)
if r.isNeg {
r = BiAdd(r, bm.bkplus1)
}
for BiCompare(r, bm.modulus) >= 0 {
r = BiSubtract(r, bm.modulus)
}
return r
}
// MultiplyMod performs modular multiplication
func (bm *BarrettMu) MultiplyMod(x, y *BigInt) *BigInt {
xy := BiMultiply(x, y)
return bm.Modulo(xy)
}
// PowMod performs modular exponentiation
func (bm *BarrettMu) PowMod(x, y *BigInt) *BigInt {
result := NewBigInt()
result.digits[0] = 1
a := x
k := y
for {
if (k.digits[0] & 1) != 0 {
result = bm.MultiplyMod(result, a)
}
k = BiShiftRight(k, 1)
if k.digits[0] == 0 && BiHighIndex(k) == 0 {
break
}
a = bm.MultiplyMod(a, a)
}
return result
}
// NutRSAKeyPair represents an RSA key pair
type NutRSAKeyPair struct {
e *BigInt
d *BigInt
m *BigInt
chunkSize int
radix int
barrett *BarrettMu
}
// NewNutRSAKeyPair creates a new RSA key pair
func NewNutRSAKeyPair(encryptionExponent, decryptionExponent, modulus string) *NutRSAKeyPair {
kp := &NutRSAKeyPair{
e: BiFromHex(encryptionExponent),
d: BiFromHex(decryptionExponent),
m: BiFromHex(modulus),
radix: 16,
}
kp.chunkSize = 2 * BiHighIndex(kp.m)
kp.barrett = NewBarrettMu(kp.m)
return kp
}
// EncryptedString encrypts a string
func (kp *NutRSAKeyPair) EncryptedString(s string) string {
a := make([]int, 0)
for i := 0; i < len(s); i++ {
a = append(a, int(s[i]))
}
// Padding
for len(a)%kp.chunkSize != 0 {
a = append(a, 0)
}
result := ""
for i := 0; i < len(a); i += kp.chunkSize {
block := NewBigInt()
j := 0
for k := i; k < i+kp.chunkSize; j++ {
if k < len(a) {
block.digits[j] = a[k]
k++
}
if k < len(a) {
block.digits[j] += a[k] << 8
k++
}
}
crypt := kp.barrett.PowMod(block, kp.e)
var text string
if kp.radix == 16 {
text = BiToHex(crypt)
} else {
text = BiToString(crypt, kp.radix)
}
result += text + " "
}
if len(result) > 0 {
return result[:len(result)-1]
}
return result
}
// DecryptedString decrypts a string
func (kp *NutRSAKeyPair) DecryptedString(s string) string {
blocks := strings.Split(s, " ")
result := ""
for i := 0; i < len(blocks); i++ {
var bi *BigInt
if kp.radix == 16 {
bi = BiFromHex(blocks[i])
} else {
bi = BiFromString(blocks[i], kp.radix)
}
block := kp.barrett.PowMod(bi, kp.d)
for j := 0; j <= BiHighIndex(block); j++ {
result += string(rune(block.digits[j] & 255))
result += string(rune(block.digits[j] >> 8))
}
}
// Remove trailing null
if len(result) > 0 && result[len(result)-1] == 0 {
result = result[:len(result)-1]
}
return result
}
// Helper functions
func min(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func parseNumber(s string) int64 {
var result int64
fmt.Sscanf(s, "%d", &result)
return result
}
func init() {
SetMaxDigits(130)
}
@@ -0,0 +1,153 @@
package cryptoutil
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"fmt"
"math/big"
)
// RSAKeyPair RSA密钥对结构体
type RSAKeyPair struct {
n *big.Int // 模数
e *big.Int // 公钥指数
}
// NewRSAKeyPair 创建RSA密钥对
// exponent: 公钥指数的十六进制字符串,通常是 "10001" (65537)
// privateExponent: 私钥指数的十六进制字符串,加密时可以为空
// modulus: 模数的十六进制字符串
func NewRSAKeyPair(exponent, privateExponent, modulus string) (*RSAKeyPair, error) {
// 解析公钥指数
e := new(big.Int)
if _, ok := e.SetString(exponent, 16); !ok {
return nil, fmt.Errorf("invalid exponent: %s", exponent)
}
// 解析模数
n := new(big.Int)
if _, ok := n.SetString(modulus, 16); !ok {
return nil, fmt.Errorf("invalid modulus: %s", modulus)
}
return &RSAKeyPair{
n: n,
e: e,
}, nil
}
// NewRSAKeyPairFromPublicKey 从 X.509 格式的 base64 公钥字符串创建 RSA 密钥对
// publicKeyBase64: X.509 DER 格式的 base64 编码公钥字符串
func NewRSAKeyPairFromPublicKey(publicKeyBase64 string) (*RSAKeyPair, error) {
// 解码 base64
derBytes, err := base64.StdEncoding.DecodeString(publicKeyBase64)
if err != nil {
return nil, fmt.Errorf("failed to decode base64: %w", err)
}
// 解析 X.509 格式的公钥
pubKey, err := x509.ParsePKIXPublicKey(derBytes)
if err != nil {
return nil, fmt.Errorf("failed to parse public key: %w", err)
}
// 转换为 RSA 公钥
rsaPubKey, ok := pubKey.(*rsa.PublicKey)
if !ok {
return nil, fmt.Errorf("not an RSA public key")
}
// 创建密钥对
return &RSAKeyPair{
n: rsaPubKey.N,
e: big.NewInt(int64(rsaPubKey.E)),
}, nil
}
// Encrypt 使用公钥加密数据,返回十六进制字符串
func (k *RSAKeyPair) Encrypt(plaintext string) (string, error) {
ciphertext, err := k.EncryptBytes([]byte(plaintext))
if err != nil {
return "", err
}
// 返回十六进制编码的密文
return hex.EncodeToString(ciphertext), nil
}
// EncryptBytes 使用公钥加密字节数据,返回加密后的字节
// 使用 PKCS1v15 填充方式,兼容 JavaScript RSA 实现
func (k *RSAKeyPair) EncryptBytes(plaintext []byte) ([]byte, error) {
// 计算密钥长度(字节)
keySize := (k.n.BitLen() + 7) / 8
// PKCS1v15 填充
padded, err := pkcs1v15Pad(plaintext, keySize)
if err != nil {
return nil, err
}
// 将填充后的数据转换为大整数
m := new(big.Int).SetBytes(padded)
// 执行 RSA 加密: c = m^e mod n
c := new(big.Int).Exp(m, k.e, k.n)
// 将结果转换为字节数组,确保长度与密钥长度一致
ciphertext := c.Bytes()
if len(ciphertext) < keySize {
// 如果结果长度不足,在前面填充零
result := make([]byte, keySize)
copy(result[keySize-len(ciphertext):], ciphertext)
return result, nil
}
return ciphertext, nil
}
// pkcs1v15Pad 实现 PKCS#1 v1.5 填充
func pkcs1v15Pad(data []byte, keySize int) ([]byte, error) {
if len(data) > keySize-11 {
return nil, fmt.Errorf("data too long: %d bytes (max %d)", len(data), keySize-11)
}
// PKCS1v15 格式: 0x00 || 0x02 || PS || 0x00 || data
// 其中 PS 是随机非零字节,长度至少为 8 字节
padLen := keySize - len(data) - 3
padded := make([]byte, keySize)
padded[0] = 0x00
padded[1] = 0x02
// 生成随机非零字节作为填充
ps := make([]byte, padLen)
_, err := rand.Read(ps)
if err != nil {
return nil, fmt.Errorf("failed to generate random padding: %w", err)
}
// 确保所有填充字节都是非零
for i := 0; i < padLen; i++ {
for ps[i] == 0 {
rand.Read(ps[i : i+1])
}
padded[2+i] = ps[i]
}
padded[2+padLen] = 0x00
copy(padded[3+padLen:], data)
return padded, nil
}
// GetModulus 获取模数
func (k *RSAKeyPair) GetModulus() *big.Int {
return k.n
}
// GetExponent 获取公钥指数
func (k *RSAKeyPair) GetExponent() *big.Int {
return k.e
}
@@ -0,0 +1,87 @@
package cryptoutil
import (
"testing"
)
func TestRSAKeyPair(t *testing.T) {
// 使用你提供的参数创建 RSA 密钥对
exponent := "10001"
modulus := "a1155dc73de04614604cbed62cc3b3198a639eb7b7d19cc2dfe3e787fe435f1464beee42a9a3cab6f1d3bfe66cd00317220ee770fd779b24c12c7698e251e875"
keyPair, err := NewRSAKeyPair(exponent, "", modulus)
if err != nil {
t.Fatalf("创建 RSA 密钥对失败: %v", err)
}
// 测试加密
plaintext := "241703070163"
ciphertext, err := keyPair.Encrypt(plaintext)
if err != nil {
t.Fatalf("加密失败: %v", err)
}
t.Logf("明文: %s", plaintext)
t.Logf("密文(十六进制): %s", ciphertext)
// 验证密文不为空
if len(ciphertext) == 0 {
t.Error("密文为空")
}
}
func TestRSAKeyPair_EncryptBytes(t *testing.T) {
exponent := "10001"
modulus := "a1155dc73de04614604cbed62cc3b3198a639eb7b7d19cc2dfe3e787fe435f1464beee42a9a3cab6f1d3bfe66cd00317220ee770fd779b24c12c7698e251e875"
keyPair, err := NewRSAKeyPair(exponent, "", modulus)
if err != nil {
t.Fatalf("创建 RSA 密钥对失败: %v", err)
}
// 测试字节加密
plaintext := []byte("Test data 测试数据")
ciphertext, err := keyPair.EncryptBytes(plaintext)
if err != nil {
t.Fatalf("字节加密失败: %v", err)
}
if len(ciphertext) == 0 {
t.Error("密文字节为空")
}
t.Logf("密文长度: %d bytes", len(ciphertext))
}
func TestNewRSAKeyPairFromPublicKey(t *testing.T) {
// 使用你提供的 X.509 格式的 base64 公钥
publicKey := "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDCpbRy8ZoyQvRPpUDIXycglTwVDcYrLcv7P9HI4E7/TAPZiI3GN0ckTUVVWRvoo/re3uCOOJK+F+Ufh19XiRIdoPYCULsSyEjcLLiyAS09mVOzhAQco84E/lM24T7rRTVID7LIgWPewyN8Nd5vzHET4Q3qTthoL8n82BwC1iXuVQIDAQAB"
keyPair, err := NewRSAKeyPairFromPublicKey(publicKey)
if err != nil {
t.Fatalf("从公钥创建 RSA 密钥对失败: %v", err)
}
// 测试加密
plaintext := "241703070163"
ciphertext, err := keyPair.Encrypt(plaintext)
if err != nil {
t.Fatalf("加密失败: %v", err)
}
t.Logf("明文: %s", plaintext)
t.Logf("密文(十六进制): %s", ciphertext)
// 验证密文不为空
if len(ciphertext) == 0 {
t.Error("密文为空")
}
// 验证密文长度(1024位密钥应该产生128字节 = 256个十六进制字符)
expectedHexLen := (keyPair.GetModulus().BitLen() + 7) / 8 * 2
if len(ciphertext) != expectedHexLen {
t.Errorf("密文长度不正确: 期望 %d, 实际 %d", expectedHexLen, len(ciphertext))
}
t.Logf("密钥长度: %d bits", keyPair.GetModulus().BitLen())
}
@@ -5,13 +5,14 @@ import (
"encoding/json"
"likedream-goframe/internal/application/cpdaily/types"
"likedream-goframe/internal/application/filestorage"
"likedream-goframe/internal/common/db"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/db"
"likedream-goframe/internal/ent"
"likedream-goframe/internal/ent/cpimage"
"likedream-goframe/internal/utils"
"github.com/bytedance/sonic"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/google/uuid"
"github.com/samber/oops"
"resty.dev/v3"
@@ -75,7 +76,48 @@ func UploadImageFromStorage(ctx context.Context, host string, imageId uuid.UUID,
utils.OkOrPanic(errors.NewBusinessError(503, "上传图片失败: "+utils.ReadString(resp.Body)), oops.WithContext(ctx).With("response", utils.ReadString(resp.Body)).Wrap)
}
previewUrl := GetPreviewUrlAsync(ctx, host, targetImage.Key, taskType, client)
previewUrl := GetPreviewUrlAsync(ctx, host, imageUploadPolicy.FileName, taskType, client)
return previewUrl
}
func UploadImageFromFile(ctx context.Context, host string, file *ghttp.UploadFile, taskType types.TaskType, client *resty.Client) string {
imageUploadPolicy := GetUploadPolicyAsync(ctx, host, taskType, client)
open, _ := file.Open()
resp, e := client.R().
SetMultipartFields(&resty.MultipartField{
Name: "x-obs-acl",
Values: []string{"public-read"},
}, &resty.MultipartField{
Name: "AccessKeyId",
Values: []string{imageUploadPolicy.AccessId},
}, &resty.MultipartField{
Name: "key",
Values: []string{imageUploadPolicy.FileName},
}, &resty.MultipartField{
Name: "signature",
Values: []string{imageUploadPolicy.Signature},
}, &resty.MultipartField{
Name: "policy",
Values: []string{imageUploadPolicy.Policy},
}, &resty.MultipartField{
Name: "content-type",
Values: []string{file.Header.Get("Content-Type")},
}, &resty.MultipartField{
Name: "file",
FileName: "file",
Reader: open,
}).
Post(imageUploadPolicy.Host)
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
if resp.StatusCode() > 299 {
utils.OkOrPanic(errors.NewBusinessError(503, "上传图片失败: "+utils.ReadString(resp.Body)), oops.WithContext(ctx).With("response", utils.ReadString(resp.Body)).Wrap)
}
previewUrl := GetPreviewUrlAsync(ctx, host, imageUploadPolicy.FileName, taskType, client)
return previewUrl
}
@@ -85,6 +127,8 @@ func GetPreviewUrlAsync(ctx context.Context, host string, fileKey string, taskTy
var apiPath string
if taskType == types.TaskTypeSleepCheck {
apiPath = "/previewAttachment"
} else if taskType == types.TaskTypeLeave {
apiPath = "/obs/previewAttachment"
} else {
apiPath = "/sign/previewAttachment"
}
@@ -164,6 +208,8 @@ func GetImageUploadUrlPrefix(ctx context.Context, taskType types.TaskType) strin
return "/wec-counselor-sign-apps/stu"
case types.TaskTypeCollection:
return "/wec-counselor-collector-apps/student/collector"
case types.TaskTypeLeave:
return "/wec-counselor-leave-apps/leave"
default:
utils.OkOrPanic(errors.NewBusinessError(503, "未知的任务类型"), oops.WithContext(ctx).Wrap)
panic("unreachable")
+2 -1
View File
@@ -2,8 +2,9 @@ package cpdaily
import (
"context"
"likedream-goframe/internal/common/cst"
"github.com/gogf/gf/v2/frame/g"
"likedream-goframe/internal/cst"
)
type Info struct {
@@ -0,0 +1,6 @@
package model
type Attach struct {
Type int `json:"type"`
Url string `json:"url"`
}
@@ -0,0 +1,210 @@
package leave
import (
"context"
"fmt"
v1 "likedream-goframe/internal/api/v1"
"likedream-goframe/internal/application/cpdaily"
"likedream-goframe/internal/application/cpdaily/cryptoutil"
"likedream-goframe/internal/application/cpdaily/imageuploader"
"likedream-goframe/internal/application/cpdaily/leave/model"
"likedream-goframe/internal/application/cpdaily/task/leavetype"
"likedream-goframe/internal/application/cpdaily/taskhelper"
"likedream-goframe/internal/application/cpdaily/types"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/utils"
"github.com/gogf/gf/v2/container/gvar"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/google/uuid"
"github.com/samber/oops"
"github.com/tidwall/gjson"
"resty.dev/v3"
)
/**
* @Description: 销假相关功能
*/
type CpdailyReportApp struct {
client *resty.Client
host string
}
func NewCpdailyReportApp(client *resty.Client, host string) *CpdailyReportApp {
return &CpdailyReportApp{
client: client,
host: host,
}
}
// 获取请假记录
func (c *CpdailyReportApp) GetLeaveList() []leavetype.LeaveListOne {
// https://fzfu.campusphere.net/wec-counselor-leave-apps/leave/stu/listOther
post, err := c.client.R().SetBody(map[string]int{
"pageNumber": 1,
"pageSize": 20,
"needApproval": 1,
}).Post(fmt.Sprintf("%s/wec-counselor-leave-apps/leave/stu/listOther", c.host))
utils.OkOrPanic(err, func(err error) error {
return oops.
With("host", c.host).
In("CpdailyReport.GetLeaveList").
Wrap(err)
})
responseJSON := utils.ReadGjsonResult(post.Body)
rows := responseJSON.Get("datas.rows")
if !rows.Exists() {
utils.OkOrPanic(errors.NewBusinessError(503, "没有获取到待销假信息"), func(err error) error {
return oops.
With("host", c.host).
In("CpdailyReport.GetLeaveList").
Wrap(err)
})
}
var listList []leavetype.LeaveListOne
for _, row := range rows.Array() {
// 已完成
if row.Get("status").String() == "4" {
continue
}
id := row.Get("id").String()
detail := c.LeaveDetail(id)
leaveOne := leavetype.LeaveListOne{
Id: row.Get("id").String(),
LeaveType: row.Get("leaveType").String(),
CreateTime: row.Get("createTime").String(),
Reason: row.Get("leaveReason").String(),
AceEndTime: row.Get("actEndTime").String(),
Status: row.Get("status").String(),
ChooseAttendance: detail.Get("leaveType.chooseAttendance").Int(),
//Longitude: detail.Get("detail.applyLocation.longitude").String(),
//Latitude: detail.Get("detail.applyLocation.latitude").String(),
//Address: detail.Get("detail.applyLocation.address").String(),
Longitude: detail.Get("leaveExtends.0.longitude").String(),
Latitude: detail.Get("leaveExtends.0.latitude").String(),
Address: detail.Get("leaveExtends.0.address").String(),
}
listList = append(listList, leaveOne)
}
return listList
}
func (c *CpdailyReportApp) LeaveDetail(leaveId string) gjson.Result {
// https://fzfu.campusphere.net/wec-counselor-leave-apps/leave/stu/detailOther
post, err := c.client.R().SetBody(map[string]any{
"id": leaveId,
"needApproval": 1,
"uaIsCpadaily": "true",
}).Post(fmt.Sprintf("%s/wec-counselor-leave-apps/leave/stu/detailOther", c.host))
utils.OkOrPanic(err, func(err error) error {
return oops.
With("host", c.host).
In("CpdailyReport.LeaveDetail").
Wrap(err)
})
datas := utils.ReadGjsonResult(post.Body).Get("datas")
if !datas.Exists() {
utils.OkOrPanic(errors.NewBusinessError(503, "没有获取到待销假详情信息"), func(err error) error {
return oops.
With("host", c.host).
In("CpdailyReport.LeaveDetail").
Wrap(err)
})
}
return datas
}
func (c *CpdailyReportApp) UploadImage(ctx context.Context, file *ghttp.UploadFile) string {
return imageuploader.UploadImageFromFile(ctx, c.host, file, types.TaskTypeLeave, c.client)
}
func (c *CpdailyReportApp) FillReportForm(detail gjson.Result, req *v1.CpLeaveReportReq) map[string]*gvar.Var {
resultForm := make(map[string]*gvar.Var)
resultForm["id"] = gvar.New(detail.Get("detail.id").String())
resultForm["needApproval"] = gvar.New(1)
resultForm["leaveType"] = gvar.New(detail.Get("leaveType.code").String())
if req.Address == "" {
resultForm["reportLocation"] = gvar.New(detail.Get("leaveExtends.0.address").String())
} else {
resultForm["reportLocation"] = gvar.New(req.Address)
}
resultForm["approverWid"] = gvar.New("")
resultForm["reportApplyExplain"] = gvar.New(nil)
resultForm["uaIsCpadaily"] = gvar.New(true)
if req.ImageUrl != "" {
var attach []model.Attach
attach = append(attach, model.Attach{
Type: 1,
Url: req.ImageUrl,
})
resultForm["reportAttach"] = gvar.New(attach)
} else {
resultForm["reportAttach"] = gvar.New([]string{})
}
return resultForm
}
func (c *CpdailyReportApp) GetLocation(detail gjson.Result) (lon, lat string) {
applyLocation := detail.Get("detail.applyLocation")
lon = applyLocation.Get("longitude").String()
lat = applyLocation.Get("latitude").String()
return
}
func (c *CpdailyReportApp) SubmitReport(ctx context.Context, form map[string]*gvar.Var, lon, lat, username, deviceId string) {
cpdailyInfo := cpdaily.GetInfo(ctx)
extensionForm := map[string]*gvar.Var{
"lon": gvar.New(lon),
"lat": gvar.New(lat),
"model": gvar.New(uuid.New().String()),
"appVersion": gvar.New(cpdailyInfo.AppVersion),
"systemVersion": gvar.New(cpdailyInfo.SystemVersion),
"userId": gvar.New(username),
"systemName": gvar.New("android"),
"deviceId": gvar.New(deviceId),
}
extensionString := taskhelper.GetCpdailyExtension(ctx, extensionForm)
bodyString := taskhelper.GetBodyString(ctx, form)
// 构造提交数据
submitData := map[string]*gvar.Var{
"lon": gvar.New(lon),
"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": gvar.New(lat),
"systemVersion": gvar.New(cpdailyInfo.SystemVersion),
"appVersion": gvar.New(cpdailyInfo.AppVersion),
"model": extensionForm["model"],
}
// https://fzfu.campusphere.net/wec-counselor-leave-apps/leave/report
response, e := c.client.R().
SetBody(submitData).
WithContext(ctx).
SetHeader("Cpdaily-Extension", extensionString).
Post(fmt.Sprintf("%s/wec-counselor-leave-apps/leave/report", c.host))
utils.OkOrPanic(e, func(err error) error {
return oops.
With("host", c.host).
With("username", username).
With("deviceId", deviceId).
In("CpdailySleepCheckTask.Submit").
Wrap(err)
})
responseJSON := utils.ReadGjsonResult(response.Body)
if responseJSON.Get("code").String() != "0" {
utils.OkOrPanic(errors.NewBusinessError(503, responseJSON.Get("message").String()), oops.WithContext(ctx).Wrap)
}
}
File diff suppressed because one or more lines are too long
+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.NewCpdailyClient(),
}
}
@@ -7,6 +7,21 @@ import (
func GetCpdailyLoginBySchoolInfo(ctx context.Context, info *school.SchoolDetailInfo) CpdailyLogin {
switch info.Id {
// 南昌理工学院
case "d6e8f2ca-10b2-4599-bdb8-4b33d419658d":
return NewNut(ctx)
// 安徽建筑大学
case "ahjzu":
return NewAhjzu(ctx)
// 南宁师范大学
case "2ba0ea59-a764-4482-bb2b-c924b726b04c":
return NewNnnu(ctx)
// 长春大学
case "ccu":
fallthrough
// 河北石油职业技术大学
case "01e270d1-00e2-4ce9-b0af-5d5fbd462b8a":
return NewIap(ctx)
default:
if info.IsIap() {
return NewIap(ctx)
+64 -23
View File
@@ -9,18 +9,21 @@ import (
"likedream-goframe/internal/application/cookiejar"
"likedream-goframe/internal/application/cpdaily/school"
"likedream-goframe/internal/application/httpclient"
"likedream-goframe/internal/common/cst"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/cst"
"likedream-goframe/internal/framework/redis"
"likedream-goframe/internal/utils"
"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/gogf/gf/v2/frame/g"
"github.com/samber/oops"
"github.com/tidwall/gjson"
"resty.dev/v3"
@@ -37,7 +40,7 @@ type CpdailyCasLogin struct {
func NewCas(ctx context.Context) CpdailyLogin {
return &CpdailyCasLogin{
client: httpclient.Cpdaily.Clone(ctx),
client: httpclient.NewCpdailyClient(),
}
}
@@ -56,7 +59,7 @@ func (c *CpdailyCasLogin) GetClient() *resty.Client {
func (c *CpdailyCasLogin) Login(ctx context.Context, schoolDetail *school.SchoolDetailInfo, username, password string) {
jar := cookiejar.NewPersistCookieJar()
c.client = c.client.SetCookieJar(jar).SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
c.client.SetCookieJar(jar).SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
// 获取合法的登录URL
loginUrl := getCasValidLoginUrl(schoolDetail)
@@ -76,7 +79,7 @@ func (c *CpdailyCasLogin) Login(ctx context.Context, schoolDetail *school.School
loginResponseUrl := loginPageResponse.RawResponse.Request.URL
// 关闭client的重定向
c.client = c.client.SetRedirectPolicy(resty.NoRedirectPolicy())
c.client.SetRedirectPolicy(resty.NoRedirectPolicy())
// 解析登录页面
loginPage, err := goquery.NewDocumentFromReader(bytes.NewReader(loginPageBytes))
@@ -155,11 +158,11 @@ func (c *CpdailyCasLogin) Login(ctx context.Context, schoolDetail *school.School
allInputs[name] = value
}
} else {
utils.OkOrPanic(oops.With("schoolDetail", schoolDetail).
utils.OkOrPanic(errors.NewBusinessError(503, "页面中未找到登录表单"), oops.With("schoolDetail", schoolDetail).
With("username", username).
With("password", password).
WithContext(ctx).
Wrap(errors.NewBusinessError(503, "页面中未找到登录表单")))
Wrap)
}
// 修改部分参数
@@ -174,9 +177,9 @@ func (c *CpdailyCasLogin) Login(ctx context.Context, schoolDetail *school.School
if saltValue != "" {
salt = saltValue
} else {
saltMatches := saltRegexp.FindAllString(string(loginPageBytes), -1)
if len(saltMatches) > 0 {
salt = saltMatches[0]
saltMatches := saltRegexp.FindStringSubmatch(string(loginPageBytes))
if len(saltMatches) > 1 {
salt = saltMatches[1]
}
}
}
@@ -189,19 +192,37 @@ func (c *CpdailyCasLogin) Login(ctx context.Context, schoolDetail *school.School
allInputs["password"] = encryptCasPassword(password, salt)
}
// 声明需要滑块验证码的学校id
var SliderCaptchaIds = g.Cfg().MustGet(ctx, cst.ConfigSliderSchoolIds).Strings()
// []string{"1018764144934965", "sdhyxy", "hqdx"}
// 判断是否需要验证码
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().
@@ -237,6 +258,7 @@ func (c *CpdailyCasLogin) Login(ctx context.Context, schoolDetail *school.School
if isCasNeedKickOut(doc) {
kickoutResponse := submitCasKickOut(ctx, loginResponseUrl.String(), doc, c.client)
handleCasLoginSuccess(ctx, kickoutResponse, schoolDetail, username, password, c.client)
return
}
message := getCasErrorMessage(doc)
@@ -269,7 +291,7 @@ func (c *CpdailyCasLogin) ConfirmAdd(ctx context.Context, sc *school.SchoolDetai
utils.OkOrPanic(err)
// 提交确认添加
client := httpclient.Cpdaily.Clone(ctx)
client := httpclient.NewCpdailyClient()
client.SetCookieJar(cookieJar)
post, err := client.R().SetFormData(map[string]string{
"service": service,
@@ -333,7 +355,7 @@ func handleCasLoginSuccess(ctx context.Context, response *resty.Response, school
jumpUrl, err := response.RawResponse.Location()
utils.OkOrPanic(err)
client = client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
verifyResponse, err := client.R().
WithContext(ctx).
@@ -369,6 +391,10 @@ func handleCasLoginSuccess(ctx context.Context, response *resty.Response, school
utils.OkOrPanic(errors.NewBusinessError(201, "多因子验证码认证").WithData(key))
// 否则应该是登录成功
} else if statusCode == 200 || statusCode == 404 {
all, _ := io.ReadAll(verifyResponse.Body)
if strings.Contains(string(all), "完善资料") || strings.Contains(string(all), "为了你的帐号安全") {
utils.OkOrPanic(errors.NewBusinessError(503, "请手动学工号登录完善信息,修改密码").WithData(schoolInfo.WecloudUrl))
}
ActivateSession(ctx, schoolInfo.WecloudUrl, client)
} else {
utils.OkOrPanic(errors.NewBusinessError(503, "未知错误导致今日校园登录失败"),
@@ -398,7 +424,7 @@ func submitCasKickOut(ctx context.Context, requestUrl string, doc *goquery.Docum
submitForm[name] = value
}
client = client.SetRedirectPolicy(resty.NoRedirectPolicy())
client.SetRedirectPolicy(resty.NoRedirectPolicy())
response, err := client.R().
SetFormData(submitForm).
@@ -430,11 +456,17 @@ func submitCasKickOut(ctx context.Context, requestUrl string, doc *goquery.Docum
}
func getCasValidLoginUrl(info *school.SchoolDetailInfo) string {
if strings.Contains(info.AmpUrl, "campusphere") {
// 特殊学校单独处理
// 甘肃农业大学 西安体育学院
ids := []string{"gsau", "ddf0639d-394d-48a2-a166-07ed84b7e6f0"}
if slices.Contains(ids, info.Id) {
return info.IdsUrl
}
if strings.Contains(info.AmpUrl, "campusphere") || strings.Contains(info.AmpUrl, "wec-portal-mobile") {
return info.AmpUrl
} else if strings.Contains(info.AmpUrl2, "campusphere") {
} else if strings.Contains(info.AmpUrl2, "campusphere") || strings.Contains(info.AmpUrl2, "wec-portal-mobile") {
return info.AmpUrl2
} else if strings.Contains(info.AmpUrl3, "campusphere") {
} else if strings.Contains(info.AmpUrl3, "campusphere") || strings.Contains(info.AmpUrl3, "wec-portal-mobile") {
return info.AmpUrl3
}
@@ -528,3 +560,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)
}
+18 -16
View File
@@ -6,6 +6,7 @@ import (
"likedream-goframe/internal/application/cookiejar"
"likedream-goframe/internal/application/httpclient"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/ent/cpuser"
"likedream-goframe/internal/utils"
"strings"
@@ -14,7 +15,12 @@ import (
)
func ActivateSession(ctx context.Context, host string, client *resty.Client) {
client = client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
// 处理cookie,干掉path
jar := client.CookieJar()
realJar := jar.(*cookiejar.PersistCookieJar)
realJar.RemoveCookieInfo()
client.SetCookieJar(realJar)
client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
host = strings.TrimSuffix(host, "/")
@@ -27,7 +33,7 @@ func ActivateSession(ctx context.Context, host string, client *resty.Client) {
defer sessionResponse.Body.Close()
if !sessionResponse.IsSuccess() {
if !sessionResponse.IsSuccess() && sessionResponse.StatusCode() != 502 {
utils.OkOrPanic(errors.NewBusinessError(503, "Session激活失败"),
func(err error) error {
return oops.
@@ -78,34 +84,30 @@ func ActivateSession(ctx context.Context, host string, client *resty.Client) {
activateStep1()
activateStep2()
client = client.SetRedirectPolicy(resty.NoRedirectPolicy())
client.SetRedirectPolicy(resty.NoRedirectPolicy())
}
func CookieEnabled(ctx context.Context, host string, client *resty.Client) bool {
client = client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
defer func() {
client = client.SetRedirectPolicy(resty.NoRedirectPolicy())
}()
func CookieEnabled(ctx context.Context, host string, client *resty.Client, loginType cpuser.LoginType) bool {
client.SetRedirectPolicy(resty.NoRedirectPolicy())
host = strings.TrimSuffix(host, "/")
sessionResponse, err := client.R().
WithContext(ctx).
SetBody(map[string]any{}).
Post(fmt.Sprintf("%s/wec-counselor-attendance-apps/student/attendance/getStuSignInfosByWeekMonth", host))
Post(fmt.Sprintf("%s/wec-counselor-attendance-apps/student/attendance/getStuAttendacesInOneDay", host))
utils.OkOrPanic(err)
defer sessionResponse.Body.Close()
if !sessionResponse.IsSuccess() {
return sessionResponse.StatusCode() == 405
needLogin := sessionResponse.StatusCode() == 302
if needLogin && loginType == cpuser.LoginTypePhone {
utils.OkOrPanic(errors.NewBusinessError(500, "登录信息失效,请重新通过短信验证码登录"))
}
responseContentType := sessionResponse.Header().Get("Content-Type")
return strings.Contains(responseContentType, "json")
return !needLogin
}
func GetClient(ctx context.Context, cookie string) *resty.Client {
cookieJar := cookiejar.NewPersistCookieJar()
cookieJar.Deserialize(cookie)
client := httpclient.Cpdaily.Clone(ctx)
// 不使用 Clone,创建全新的独立 client 实例避免状态共享
client := httpclient.NewCpdailyClient()
client.SetCookieJar(cookieJar)
return client
}
+43 -10
View File
@@ -5,25 +5,29 @@ import (
"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"
"strings"
"github.com/samber/oops"
"github.com/tidwall/gjson"
"resty.dev/v3"
)
const iapRsaPubKey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDCpbRy8ZoyQvRPpUDIXycglTwVDcYrLcv7P9HI4E7/TAPZiI3GN0ckTUVVWRvoo/re3uCOOJK+F+Ufh19XiRIdoPYCULsSyEjcLLiyAS09mVOzhAQco84E/lM24T7rRTVID7LIgWPewyN8Nd5vzHET4Q3qTthoL8n82BwC1iXuVQIDAQAB"
type CpdailyIapLogin struct {
client *resty.Client
}
func NewIap(ctx context.Context) *CpdailyIapLogin {
return &CpdailyIapLogin{
client: httpclient.Cpdaily.Clone(ctx),
client: httpclient.NewCpdailyClient(),
}
}
@@ -34,20 +38,39 @@ func (c *CpdailyIapLogin) Login(ctx context.Context, schoolDetail *school.School
loginUrl := getIapValidLoginUrl(schoolDetail)
jar := cookiejar.NewPersistCookieJar()
c.client = c.client.SetCookieJar(jar).
c.client.SetCookieJar(jar).
SetRedirectPolicy(resty.NoRedirectPolicy())
// 获取基础lt
preLoginUrl := loginUrl + "/portal/login"
c.client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
loginRedirectResponse, err := c.client.R().Get(preLoginUrl)
oldLt := ""
// 部分学校无法直接访问登录页
if err == nil {
u := loginRedirectResponse.RawResponse.Request.URL
oldLt = strings.Split(u.String(), "=")[1]
c.client.SetRedirectPolicy(resty.NoRedirectPolicy())
} else {
preLoginUrl = loginUrl + "/iap/login/mobile.html"
c.client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
loginRedirectResponse, err = c.client.R().Get(preLoginUrl)
}
c.client.SetRedirectPolicy(resty.NoRedirectPolicy())
ltInfoResponse, err := c.client.R().
WithContext(ctx).
SetBody(map[string]any{}).
SetFormData(map[string]string{
"lt": oldLt,
}).
Post(fmt.Sprintf("%s/iap/security/lt", loginUrl))
rsa, _ := cryptoutil.NewRSAKeyPairFromPublicKey(iapRsaPubKey)
password, _ = rsa.Encrypt(password)
utils.OkOrPanic(err, func(err error) error {
return oops.
In("CpdailyIapLogin.Login").
With("schoolDetail", schoolDetail).
With("username", username).
With("password", password).
With("password", "{rsa}"+password).
Wrap(err)
})
@@ -64,8 +87,9 @@ func (c *CpdailyIapLogin) Login(ctx context.Context, schoolDetail *school.School
"rememberMe": "true",
"dllt": "",
"mobile": "",
"captcha": "",
"username": username,
"password": password,
"password": "{rsa}" + password,
}
if isIapNeedCaptcha(ctx, loginUrl, username, c.client) {
@@ -103,7 +127,7 @@ func (c *CpdailyIapLogin) Login(ctx context.Context, schoolDetail *school.School
Wrap(err)
})
c.client = c.client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
c.client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
finalLoginResponse, err := c.client.R().
WithContext(ctx).
@@ -123,7 +147,7 @@ func (c *CpdailyIapLogin) Login(ctx context.Context, schoolDetail *school.School
ActivateSession(ctx, schoolDetail.WecloudUrl, c.client)
c.client = c.client.SetRedirectPolicy(resty.NoRedirectPolicy())
c.client.SetRedirectPolicy(resty.NoRedirectPolicy())
default:
loginResponseBytes, err := io.ReadAll(loginResponse.Body)
utils.OkOrPanic(err, func(err error) error {
@@ -142,9 +166,18 @@ func (c *CpdailyIapLogin) Login(ctx context.Context, schoolDetail *school.School
case "CAPTCHA_NOTMATCH":
utils.OkOrPanic(errors.NewBusinessError(503, "Iap登录失败, 验证码识别失败"))
case "FAIL_UPNOTMATCH":
utils.OkOrPanic(errors.NewBusinessError(503, "Iap登录失败, 用户名或密码错误"))
utils.OkOrPanic(errors.NewBusinessError(503, "Iap登录失败, 用户名或密码错误"), func(err error) error {
return oops.With("username", username).
With("password", password).
With("userId", utils.GetUserIdNotPanic(ctx)).
In("Cpdaily.Login").New("Iap登录失败, 用户名或密码错误")
})
default:
utils.OkOrPanic(errors.NewBusinessError(503, fmt.Sprintf("Iap登录失败, 状态识别码: %s", resultCode)))
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").Wrap)
}
}
}
+149
View File
@@ -0,0 +1,149 @@
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"
)
// 南宁师范大学登录实现
type CpdailyNnnuLogin struct {
client *resty.Client
}
func (c CpdailyNnnuLogin) Login(ctx context.Context, schoolDetail *school.SchoolDetailInfo, username, password string) {
jar := cookiejar.NewPersistCookieJar()
c.client.SetCookieJar(jar).SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
// 获取合法的登录URL
loginUrl := getCasValidLoginUrl(schoolDetail)
// 请求登录页面并获取真正的登录URL
loginPageResponse, err := c.client.R().
WithContext(ctx).
Get(loginUrl)
defer loginPageResponse.Body.Close()
loginPageBytes, err := io.ReadAll(loginPageResponse.Body)
utils.OkOrPanic(err)
loginResponseUrl := loginPageResponse.RawResponse.Request.URL
// 关闭client的重定向
c.client.SetRedirectPolicy(resty.NoRedirectPolicy())
// 解析登录页面
loginPage, err := goquery.NewDocumentFromReader(bytes.NewReader(loginPageBytes))
utils.OkOrPanic(err)
// 判断登录类型
var (
allInputs = map[string]string{}
form = loginPage.Find("form").First()
)
inputs := form.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 getNuuuCaptchaUrl(url *url.URL) string {
return fmt.Sprintf("%s://%s/sso/code/code.jsp", url.Scheme, url.Host)
}
func (c CpdailyNnnuLogin) GetCookieJar() http.CookieJar {
return c.client.CookieJar()
}
func (c CpdailyNnnuLogin) GetCookieString() string {
cookieJar := c.GetCookieJar()
serializableJar := cookieJar.(*cookiejar.PersistCookieJar)
return serializableJar.Serialize()
}
func (c CpdailyNnnuLogin) GetClient() *resty.Client {
return c.client
}
func NewNnnu(ctx context.Context) CpdailyLogin {
return &CpdailyNnnuLogin{
client: httpclient.NewCpdailyClient(),
}
}
+211
View File
@@ -0,0 +1,211 @@
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"
"strings"
"github.com/PuerkitoBio/goquery"
"github.com/bytedance/sonic"
"github.com/samber/oops"
"resty.dev/v3"
)
// 南阳理工学院登录实现 RSA加密 & 验证码必出
type CpdailyNutLogin struct {
client *resty.Client
}
func (c CpdailyNutLogin) Login(ctx context.Context, schoolDetail *school.SchoolDetailInfo, username, password string) {
jar := cookiejar.NewPersistCookieJar()
c.client.SetCookieJar(jar).SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
// 获取合法的登录URL
loginUrl := getCasValidLoginUrl(schoolDetail)
// 请求登录页面并获取真正的登录URL
loginPageResponse, err := c.client.R().
WithContext(ctx).
Get(loginUrl)
utils.OkOrPanic(err)
defer loginPageResponse.Body.Close()
loginPageBytes, err := io.ReadAll(loginPageResponse.Body)
utils.OkOrPanic(err)
loginResponseUrl := loginPageResponse.RawResponse.Request.URL
// 关闭client的重定向
c.client.SetRedirectPolicy(resty.NoRedirectPolicy())
// 解析登录页面
loginPage, err := goquery.NewDocumentFromReader(bytes.NewReader(loginPageBytes))
utils.OkOrPanic(err)
// 判断登录类型
var (
allInputs = map[string]string{}
fm1Node = loginPage.Find("#fm1").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
// 处理密码
type NutPubKeyResponse struct {
Modulus string `json:"modulus"`
Exponent string `json:"exponent"`
}
var pubKey NutPubKeyResponse
pubKeyResponse, err := c.client.R().Get(getNutPubKey(loginResponseUrl))
utils.OkOrPanic(err)
defer pubKeyResponse.Body.Close()
pubKeyBytes, err := io.ReadAll(pubKeyResponse.Body)
utils.OkOrPanic(err)
sonic.Unmarshal(pubKeyBytes, &pubKey)
rsa := cryptoutil.NewNutRSAKeyPair(pubKey.Exponent, "", pubKey.Modulus)
password = rsa.EncryptedString(reverseStringASCII(password))
allInputs["password"] = password
if isNutNeedCaptcha(ctx, loginResponseUrl, c.client) {
// 解决常规图片验证码
captchaUrl := getNutCaptchaUrl(loginResponseUrl)
captchaResult := ResolveOcrCaptcha(ctx, captchaUrl, c.client)
allInputs["authcode"] = captchaResult
} else {
allInputs["authcode"] = ""
}
// 登录提交
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 getNutCaptchaUrl(url *url.URL) string {
return fmt.Sprintf("%s://%s/cas/kaptcha", url.Scheme, url.Host)
}
func isNutNeedCaptcha(ctx context.Context, url *url.URL, client *resty.Client) bool {
requestURL := getNutNeedCaptchaUrl(url)
captchaResponse, err := client.R().
WithContext(ctx).
Get(requestURL)
utils.OkOrPanic(oops.With().
With("requestURL", requestURL).
WithContext(ctx).
Wrap(err))
defer captchaResponse.Body.Close()
if captchaResponse.IsError() {
utils.OkOrPanic(oops.With().
With("requestURL", requestURL).
WithContext(ctx).
Wrap(errors.NewBusinessError(503, "请求验证码失败")))
}
responseBytes, err := io.ReadAll(captchaResponse.Body)
utils.OkOrPanic(err)
return strings.ToLower(strings.TrimSpace(string(responseBytes))) != "false"
}
func getNutPubKey(url *url.URL) string {
return fmt.Sprintf("%s://%s/cas/v2/getPubKey", url.Scheme, url.Host)
}
func getNutNeedCaptchaUrl(url *url.URL) string {
return fmt.Sprintf("%s://%s/cas/v2/getKaptchaStatus", url.Scheme, url.Host)
}
func (c CpdailyNutLogin) GetCookieJar() http.CookieJar {
return c.client.CookieJar()
}
func (c CpdailyNutLogin) GetCookieString() string {
cookieJar := c.GetCookieJar()
serializableJar := cookieJar.(*cookiejar.PersistCookieJar)
return serializableJar.Serialize()
}
func (c CpdailyNutLogin) GetClient() *resty.Client {
return c.client
}
func NewNut(ctx context.Context) *CpdailyNutLogin {
return &CpdailyNutLogin{
client: httpclient.NewCpdailyClient(),
}
}
func reverseStringASCII(s string) string {
bytes := []byte(s)
for i, j := 0, len(bytes)-1; i < j; i, j = i+1, j-1 {
bytes[i], bytes[j] = bytes[j], bytes[i]
}
return string(bytes)
}
+10 -45
View File
@@ -3,70 +3,35 @@ package login
import (
"context"
"io"
"likedream-goframe/internal/application/httpclient"
"likedream-goframe/internal/cst"
"likedream-goframe/internal/application/captcha"
"likedream-goframe/internal/utils"
"github.com/gogf/gf/v2/frame/g"
"github.com/samber/oops"
"github.com/tidwall/gjson"
"resty.dev/v3"
)
func ResolveOcrCaptcha(ctx context.Context, url string, client *resty.Client) string {
captchaResponse, err := client.R().
byteData := DownCode2Byte(ctx, url, client)
return captcha.ApiAutoOCRVerification(byteData)
}
func DownCode2Byte(ctx context.Context, url string, client *resty.Client) []byte {
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 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")
defer res.Body.Close()
all, err := io.ReadAll(res.Body)
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服务返回数据为空"))
}
return data
return all
}
@@ -0,0 +1,90 @@
package login
import (
"context"
"io"
"likedream-goframe/internal/application/httpclient"
"likedream-goframe/internal/common/cst"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/utils"
"github.com/bytedance/sonic"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
"github.com/samber/oops"
"github.com/tidwall/gjson"
"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)
width := ApiVerifySliderWidthByBase64(captcha.BigImage, captcha.SmallImage)
res2, err2 := client.R().
WithContext(ctx).
SetFormData(map[string]string{
"canvasLength": "280",
"moveLength": 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, "滑块解析错误")))
}
}
}
func ApiVerifySliderWidthByBase64(bigImage string, smallImage string) string {
ctx := gctx.GetInitCtx()
baseUrl := g.Config().MustGetWithEnv(ctx, cst.ConfigCaptchaBaseUrl).String() + "/slider"
client := httpclient.Cpdaily.Clone(ctx)
client.SetDoNotParseResponse(false)
post, err := client.R().SetBody(map[string]any{
"big_image": bigImage,
"small_image": smallImage,
}).Post(baseUrl)
utils.OkOrPanic(err, func(err error) error {
return oops.
With("AutoOCRVerification", baseUrl).
WithContext(ctx).
Wrap(err)
})
result := post.String()
return gjson.Get(result, "data").String()
}
+280
View File
@@ -0,0 +1,280 @@
package login
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"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/cst"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/utils"
"net/http"
"net/url"
"github.com/bytedance/sonic"
"github.com/samber/oops"
"resty.dev/v3"
)
type cpdailySmsCommonResponse[T any] struct {
ErrCode int `json:"errCode"`
ErrMsg string `json:"errMsg"`
Data T `json:"data"`
}
func (c *cpdailySmsCommonResponse[T]) EnsureSuccess() {
if c.ErrCode != 0 {
utils.OkOrPanic(errors.NewBusinessError(503, c.ErrMsg))
}
}
type CpdailySmsSendSmsData struct {
Countdown int `json:"countdown"`
TipMsg string `json:"tipMsg"`
Status int `json:"status"`
CookieJar *cookiejar.PersistCookieJar `json:"-"`
}
type CpdailySmsLoginByCodeData struct {
AuthID string `json:"authId"`
AuthStatus string `json:"authStatus"`
CompleteUserInfoStatus string `json:"completeUserInfoStatus"`
DeviceExceptionMsg string `json:"deviceExceptionMsg"`
DeviceStatus string `json:"deviceStatus"`
FirstLogin bool `json:"firstLogin"`
Mobile string `json:"mobile"`
MsgTip string `json:"msgTip"`
Name string `json:"name"`
OpenID string `json:"openId"`
PersonID string `json:"personId"`
SessionToken string `json:"sessionToken"`
Status string `json:"status"`
TenantID string `json:"tenantId"`
TenantName string `json:"tenantName"`
Tgc string `json:"tgc"`
TgcStatus string `json:"tgcStatus"`
UserID string `json:"userId"`
UserType string `json:"userType"`
}
func NewSms(deviceID string) *SmsLoginHandler {
return &SmsLoginHandler{
deviceID: deviceID,
}
}
func NewSmsWithoutDeviceID() *SmsLoginHandler {
return &SmsLoginHandler{
deviceID: "",
}
}
type SmsLoginHandler struct {
deviceID string
client *resty.Client
}
func (s *SmsLoginHandler) SendSms(ctx context.Context, phone string) *CpdailySmsSendSmsData {
jar := cookiejar.NewPersistCookieJar()
phoneEncrypted := base64.StdEncoding.EncodeToString(cryptoutil.AesEncrypt([]byte(phone)))
s.client = httpclient.NewCpdailyClient()
req := s.client.
SetCookieJar(jar).
R().
SetContext(utils.NewCpdailySmsContextBuilder().
WithDeviceID(s.deviceID).
Build(ctx)).
SetBody(map[string]string{
"a": phoneEncrypted,
"b": cryptoutil.EncryptVersion,
})
setSmsRequestHeaders(ctx, req)
resp, e := req.
Post("https://mobile.campushoy.com/app/auth/authentication/mobile/messageCode/v-8222")
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
respJson := utils.ReadJson[cpdailySmsCommonResponse[CpdailySmsSendSmsData]](resp.Body)
respJson.EnsureSuccess()
respJson.Data.CookieJar = jar
return &respJson.Data
}
func (s *SmsLoginHandler) LoginByCode(ctx context.Context, phone, code string, jar http.CookieJar) *CpdailySmsLoginByCodeData {
payload := map[string]any{
"d": code,
"e": "CPDAILY",
"c": phone,
"f": "86",
}
payloadString, e := sonic.Marshal(payload)
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
encryptedPayload := cryptoutil.AesEncrypt(payloadString)
encodedPayload := base64.StdEncoding.EncodeToString(encryptedPayload)
payload = map[string]any{
"a": encodedPayload,
"b": cryptoutil.EncryptVersion,
}
s.client = httpclient.NewCpdailyClient()
s.client.SetCookieJar(jar)
req := s.client.R().
SetBody(payload).
SetContext(utils.NewCpdailySmsContextBuilder().
WithDeviceID(s.deviceID).
Build(ctx))
setSmsRequestHeaders(ctx, req)
resp, e := req.
Post("https://mobile.campushoy.com/app/auth/authentication/mobileLogin/v-8222")
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
respJson := utils.ReadJson[cpdailySmsCommonResponse[string]](resp.Body)
respJson.EnsureSuccess()
data := decodeData[CpdailySmsLoginByCodeData](respJson.Data)
return &data
}
func (s *SmsLoginHandler) SetLoginCookie(ctx context.Context, jar http.CookieJar, loginData *CpdailySmsLoginByCodeData) {
detail := school.GetSchoolDetail(ctx, loginData.TenantID)
getCookieUrl := fmt.Sprintf("%s%s", detail.WecloudUrl, "/wec-im-group/group/imUserToken/getImUserToken")
cookieMap := map[string]any{
"AMP2": []map[string]any{
{
"path": "/",
"value": loginData.SessionToken,
"domain": "",
"name": "sessionToken",
},
},
"AMP1": []map[string]any{
{
"path": "/",
"value": loginData.SessionToken,
"domain": "",
"name": "sessionToken",
},
},
}
ampTokenString, e := sonic.Marshal(cookieMap)
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
ampTokenEncrypted := cryptoutil.DesEncrypt(ampTokenString, cryptoutil.Normal)
ampToken := base64.StdEncoding.EncodeToString(ampTokenEncrypted)
// 设置cookie到jar: clientType=cpdaily_student; tenantId=ahnu; standAlone=0; sessionToken=5e55bfa7-d17e-4f5a-a06d-99a649855521;
targetSchoolUrl, e := url.Parse(detail.WecloudUrl)
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
targetApiUrl, e := url.Parse("https://api.campushoy.com")
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
cookieNameValues := map[string]string{
"clientType": "cpdaily_student",
"tenantId": detail.Id,
"standAlone": "0",
"sessionToken": loginData.SessionToken,
}
for name, value := range cookieNameValues {
cookie := &http.Cookie{
Name: name,
Value: value,
}
jar.SetCookies(targetSchoolUrl, []*http.Cookie{cookie})
jar.SetCookies(targetApiUrl, []*http.Cookie{cookie})
}
s.client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(50))
req := s.client.R().
SetHeader("AmpCookies", ampToken).
SetContext(ctx)
ctx = utils.NewCpdailySmsContextBuilder().
WithDeviceID(s.deviceID).
WithTenantID(detail.Id).
WithSessionToken(loginData.SessionToken).
Build(ctx)
setSmsRequestHeaders(ctx, req)
resp, e := req.
Get(getCookieUrl)
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
respJson := utils.ReadJson[cpdailySmsCommonResponse[any]](resp.Body)
respJson.EnsureSuccess()
}
func decodeData[T any](encodedData string) T {
decodedBytes, e := base64.StdEncoding.DecodeString(encodedData)
utils.OkOrPanic(e)
decryptedBytes := cryptoutil.AesDecrypt(decodedBytes)
result := utils.ReadJson[T](bytes.NewReader(decryptedBytes))
return result
}
func setSmsRequestHeaders(ctx context.Context, request *resty.Request) {
sessionToken, ok := ctx.Value(cst.CtxCpdailySessionToken).(string)
if ok {
encryptedSessionToken := cryptoutil.DesEncrypt([]byte(sessionToken), cryptoutil.Normal)
encodedSessionToken := base64.StdEncoding.EncodeToString(encryptedSessionToken)
request.SetHeader("SessionToken", encodedSessionToken)
request.SetHeader("sessionTokenKey", encodedSessionToken)
} else {
request.SetHeader("SessionToken", sessionToken)
request.SetHeader("sessionTokenKey", sessionToken)
}
deviceId, _ := ctx.Value(cst.CtxCpdailyDeviceID).(string)
cpdailyInfoDetail := map[string]any{
"deviceId": deviceId,
"systemName": "android",
"userId": "",
"appVersion": "9.8.8",
"model": "Unknown",
"lon": "0",
"lat": "0",
"cpdailyVersion": "9.8.8",
"systemVersion": "11",
}
cpdailyInfoStr, e := sonic.Marshal(cpdailyInfoDetail)
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
encryptedCpdailyInfo := cryptoutil.DesEncrypt(cpdailyInfoStr, cryptoutil.Normal)
encodedCpdailyInfo := base64.StdEncoding.EncodeToString(encryptedCpdailyInfo)
request.SetHeaders(map[string]string{
"clientType": "cpdaily_student",
"deviceType": "1",
"CpdailyClientType": "CPDAILY",
"CpdailyStandAlone": "0",
"User-Agent": "Mozilla/5.0 (Linux; Android 11; Redmi Note 8 Pro Build/RP1A.230805.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/83.0.4103.106 Mobile Safari/537.36 cpdaily/9.8.8 wisedu/9.8.8",
"CpdailyInfo": encodedCpdailyInfo,
})
}
func (s *SmsLoginHandler) GetCookieString() string {
cookieJar := s.client.CookieJar()
serializableJar := cookieJar.(*cookiejar.PersistCookieJar)
return serializableJar.Serialize()
}
@@ -0,0 +1,43 @@
package login
import (
"context"
"fmt"
"likedream-goframe/internal/application/cookiejar"
"testing"
"github.com/gogf/gf/v2/os/gctx"
)
const (
testSmsPhoneNumber = ""
testSmsCode = ""
)
func TestSmsLoginHandler_SendSms(t *testing.T) {
handler := SmsLoginHandler{}
data := handler.SendSms(context.Background(), testSmsPhoneNumber)
t.Logf("SendSms Data: %+v", data)
}
func TestSmsLoginHandler_LoginByCode(t *testing.T) {
handler := SmsLoginHandler{}
jar := cookiejar.NewPersistCookieJar()
data := handler.LoginByCode(context.Background(), testSmsPhoneNumber, testSmsCode, jar)
t.Logf("LoginByCode Data: %+v", data)
handler.SetLoginCookie(context.Background(), jar, data)
}
func TestLogin(t *testing.T) {
handler := SmsLoginHandler{}
ctx := gctx.GetInitCtx()
sendSmsData := handler.SendSms(ctx, testSmsPhoneNumber)
// 读取验证码手动输入
var code string
println("请输入收到的验证码:")
_, _ = fmt.Scanln(&code)
loginData := handler.LoginByCode(ctx, testSmsPhoneNumber, code, sendSmsData.CookieJar)
handler.SetLoginCookie(ctx, sendSmsData.CookieJar, loginData)
}
@@ -0,0 +1,78 @@
package map_info
import (
"context"
"fmt"
"likedream-goframe/internal/application/httpclient"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/utils"
"regexp"
"time"
"github.com/bytedance/sonic"
"github.com/samber/oops"
)
func SearchAddressLongitudeAndLatitude(ctx context.Context, address string) []MapPoi {
client := httpclient.Cpdaily.Clone(ctx).SetDoNotParseResponse(false)
// 首先访问获取ak
response, err := client.R().Get("https://lbsyun.baidu.com/jsdemo/demo/webgl0_5.htm")
utils.OkOrPanic(err, func(err error) error {
return oops.WithContext(ctx).With("url", "https://lbsyun.baidu.com/jsdemo/demo/webgl0_5.htm").In("SearchAddressLongitudeAndLatitude.GetAk").Wrap(err)
})
ak := ParseAkString(response.String())
timestamp := fmt.Sprintf("%d", time.Now().UnixMilli())
mapResponse, err := client.R().
SetQueryParams(map[string]string{
"qt": "placesug",
"query": address,
"callback": "BMapGL._rd._cbk25241",
"region": "131",
"city_limit": "false",
"t": timestamp,
"from": "jsapi",
"ak": ak,
"v": "gl",
"timeStamp": timestamp,
}).
SetHeaders(map[string]string{
"Accept": "*/*",
"Accept-Language": "zh-CN,zh;q=0.9",
"Connection": "keep-alive",
"Referer": "https://lbsyun.baidu.com/",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36",
}).
Get("https://api.map.baidu.com/")
utils.OkOrPanic(err, func(err error) error {
return oops.WithContext(ctx).With("address", address).In("SearchAddressLongitudeAndLatitude.QueryMap").Wrap(err)
})
// 从JSONP响应中提取JSON数据
str := ParseJsonpToJson(mapResponse.String())
var jsonData MapJSONData
sonic.Unmarshal([]byte(str), &jsonData)
return jsonData.Result.PoiList
}
// 正则提取ak // <script type="text/javascript" src="//api.map.baidu.com/api?type=webgl&v=1.0&ak=E4805d16520de693a3fe707cdc962045"></script>
func ParseAkString(data string) string {
reg := regexp.MustCompile(`ak=([^"]+)`)
matches := reg.FindStringSubmatch(data)
if len(matches) < 2 {
utils.OkOrPanic(errors.NewBusinessError(500, "获取ak出错"))
}
return matches[1]
}
// 从JSONP响应中提取JSON数据
// 输入: /**/BMapGL._rd._cbk25241 && BMapGL._rd._cbk25241({...})
// 输出: {...}
func ParseJsonpToJson(jsonpData string) string {
// 匹配 callback(json) 格式,提取括号中的内容
reg := regexp.MustCompile(`\((\{.*\})\)`)
matches := reg.FindStringSubmatch(jsonpData)
if len(matches) < 2 {
utils.OkOrPanic(errors.NewBusinessError(500, "解析JSONP数据出错"))
}
return matches[1]
}
@@ -0,0 +1,14 @@
package map_info
import (
"encoding/json"
"testing"
"github.com/gogf/gf/v2/os/gctx"
)
func TestMapSearch(t *testing.T) {
res := SearchAddressLongitudeAndLatitude(gctx.New(), "河北石油职业技术大学c座")
marshal, _ := json.Marshal(res)
t.Log(string(marshal))
}
@@ -0,0 +1,29 @@
package map_info
type MapJSONData struct {
Status int `json:"status"`
Message string `json:"message"`
Result MapResult `json:"result"`
}
type MapLocation struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
}
type MapPoi struct {
Name string `json:"name"`
Location MapLocation `json:"location"`
UID string `json:"uid"`
Province string `json:"province"`
City string `json:"city"`
District string `json:"district"`
Business string `json:"business"`
Cityid string `json:"cityid"`
Tag string `json:"tag"`
Address string `json:"address"`
Children []interface{} `json:"children"`
Adcode string `json:"adcode"`
}
type MapResult struct {
Q string `json:"q"`
PoiList []MapPoi `json:"poi_list"`
}
@@ -1,22 +1,27 @@
package school
import "C"
import (
"context"
"encoding/base64"
"encoding/json"
cryptoutil2 "likedream-goframe/internal/application/cpdaily/cryptoutil"
"likedream-goframe/internal/application/httpclient"
cst2 "likedream-goframe/internal/common/cst"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/framework/redis"
"likedream-goframe/internal/utils"
"sync"
"time"
"github.com/bytedance/sonic"
"github.com/samber/oops"
"github.com/tidwall/gjson"
)
var lock = sync.RWMutex{}
func GetSchoolList(ctx context.Context) []CpdailySchoolBriefInfo {
response, err := httpclient.Cpdaily.R().
response, err := httpclient.Cpdaily.Clone(ctx).R().
WithContext(ctx).
Get("https://static.campushoy.com/apicache/tenantListSort")
@@ -52,7 +57,19 @@ func GetSchoolList(ctx context.Context) []CpdailySchoolBriefInfo {
}
func GetSchoolDetail(ctx context.Context, tenantId string) SchoolDetailInfo {
response, err := httpclient.Cpdaily.R().Clone(ctx).
lock.Lock()
defer lock.Unlock()
if !utils.IsDebug() {
client := redis.GetClient()
redisResult := client.Get(ctx, cst2.RedisSchoolInfo+tenantId)
if redisResult.Err() == nil {
var ret SchoolDetailInfo
err := sonic.Unmarshal([]byte(redisResult.Val()), &ret)
utils.OkOrPanic(err)
return parseSpecialSchool(ret)
}
}
response, err := httpclient.Cpdaily.Clone(ctx).R().
SetHeaders(map[string]string{
"deviceType": "2",
"CpdailyClientType": "CPDAILY",
@@ -89,6 +106,17 @@ func GetSchoolDetail(ctx context.Context, tenantId string) SchoolDetailInfo {
WithContext(ctx).
Wrap(errors.NewBusinessError(503, "学校ID不存在").Decorate("empty school detail")))
}
return ret[0]
if !utils.IsDebug() {
client := redis.GetClient()
client.Set(ctx, cst2.RedisSchoolInfo+tenantId, ret[0], time.Duration(cst2.RedisSchoolInfoExpire)*time.Second)
}
return parseSpecialSchool(ret[0])
}
func parseSpecialSchool(s SchoolDetailInfo) SchoolDetailInfo {
// 甘肃农业大学
if s.Id == "gsau" {
s.WecloudUrl = "https://fdm.gsau.edu.cn"
}
return s
}
@@ -0,0 +1,182 @@
package submitcaptcha
//
//import (
// "context"
// "io"
// "likedream-goframe/internal/application/cpdaily/types"
// "likedream-goframe/internal/application/recognizer/classifier"
// "likedream-goframe/internal/common/errors"
// "likedream-goframe/internal/utils"
// "strings"
//
// "github.com/bytedance/sonic"
// "resty.dev/v3"
//)
//
//type AIResolver struct {
// client *resty.Client
// host string
// deviceId string
// taskType types.TaskType
//}
//
//func NewAIResolver(client *resty.Client, host, deviceId string, taskType types.TaskType) SubmitCaptchaResolver {
// return &AIResolver{
// client: client,
// host: host,
// deviceId: deviceId,
// taskType: taskType,
// }
//}
//func (r *AIResolver) GetValidationInfo(ctx context.Context) ValidationInfo {
// // GetValidationInfo 获取验证信息
// reqData := map[string]string{
// "deviceId": r.deviceId,
// }
//
// resp, err := r.client.R().
// SetBody(reqData).
// Post(r.getValidateUrl())
// utils.OkOrPanic(err)
//
// apiResp := utils.ReadJson[ApiResponse](resp.Body)
//
// if apiResp.Datas == nil {
// message := "获取提交验证码信息失败"
// if apiResp.Message != "" {
// message = apiResp.Message
// }
// utils.OkOrPanic(errors.NewBusinessError(503, message))
// }
//
// var validationInfo ValidationInfo
// err = sonic.Unmarshal(apiResp.Datas, &validationInfo)
// utils.OkOrPanic(err)
//
// return validationInfo
//
//}
//func (r *AIResolver) GetCaptchaTicket(ctx context.Context, info ValidationInfo) string {
// captchaInfo := r.createCaptcha(info)
// codes := r.getResult(captchaInfo)
// return r.submitCaptcha(info, captchaInfo, codes)
//}
//
//func (r *AIResolver) getResult(data CpSubmitCaptchaForAI) []string {
// name := data.Name
// infos := data.ImageInfos
// var result []string
// for _, item := range infos {
// url := item.Path
// get, err := r.client.R().Get(url)
// utils.OkOrPanic(err)
// defer get.Body.Close()
// bytes, _ := io.ReadAll(get.Body)
// //todo 将图片保存方便后面训练
// //os.WriteFile(item.Code+".png", bytes, 0666)
// if classifier.ClassifyImage(bytes, name) {
// result = append(result, item.Code)
// }
// }
// return result
//}
//
//// getValidateUrl 根据任务类型获取验证URL
//func (r *AIResolver) getValidateUrl() string {
// baseUrl := strings.TrimSuffix(r.host, "/")
//
// switch r.taskType {
// case types.TaskTypeCollection:
// return baseUrl + "/wec-counselor-collector-apps/stu/collector/checkValidation"
// case types.TaskTypeSign:
// return baseUrl + "/wec-counselor-sign-apps/stu/sign/checkValidation"
// case types.TaskTypeSleepCheck:
// return baseUrl + "/wec-counselor-attendance-apps/student/attendance/checkValidation"
// default:
// utils.OkOrPanic(errors.NewBusinessError(400, "不支持的任务类型"))
// return ""
// }
//}
//
//// createCaptcha 创建验证码
//func (r *AIResolver) createCaptcha(info ValidationInfo) CpSubmitCaptchaForAI {
// baseUrl := strings.TrimSuffix(r.host, "/")
// url := baseUrl + "/captcha-open-api/v1/captcha/create/scenesImage"
//
// resp, err := r.client.R().
// SetHeader("deviceId", r.deviceId).
// SetMultipartFormData(map[string]string{
// "accountKey": info.AccountKey,
// "sceneCode": info.SceneCode,
// "tenantId": info.TenantId,
// "userId": info.UserId,
// }).
// Post(url)
// utils.OkOrPanic(err)
//
// apiResp := utils.ReadJson[ApiResponse](resp.Body)
//
// if apiResp.Result == nil {
// message := "获取提交验证码失败"
// if apiResp.Message != "" {
// message = apiResp.Message
// }
// utils.OkOrPanic(errors.NewBusinessError(503, message))
// }
//
// var result CpSubmitCaptchaForAI
// err = sonic.Unmarshal(apiResp.Result, &result)
// utils.OkOrPanic(err)
// return result
//}
//
//// submitCaptcha 提交验证码
//func (r *AIResolver) submitCaptcha(info ValidationInfo, submitCaptcha CpSubmitCaptchaForAI, codes []string) string {
// baseUrl := strings.TrimSuffix(r.host, "/")
// url := baseUrl + "/captcha-open-api/v1/captcha/validate/scenesImage"
//
// formData := map[string]string{
// "accountKey": info.AccountKey,
// "sceneCode": info.SceneCode,
// "tenantId": info.TenantId,
// "userId": info.UserId,
// "scenesImageCode": submitCaptcha.Code,
// }
//
// // 创建multipart form request
// req := r.client.R().SetHeader("deviceId", r.deviceId)
//
// // 添加基本表单数据
// for key, value := range formData {
// req = req.SetMultipartFormData(map[string]string{key: value})
// }
//
// // 添加验证码列表
// for _, code := range codes {
// req = req.SetMultipartField("scenesImageCodes", "", "", strings.NewReader(code))
// }
//
// resp, err := req.Post(url)
// utils.OkOrPanic(err)
//
// apiResp := utils.ReadJson[ApiResponse](resp.Body)
//
// if apiResp.Code != "200" {
// message := "提交验证码失败"
// if apiResp.Message != "" {
// message = apiResp.Message
// }
// utils.OkOrPanic(errors.NewBusinessError(503, message))
// }
//
// if apiResp.Result == nil {
// utils.OkOrPanic(errors.NewBusinessError(503, "今日校园返回空验证码结果"))
// }
//
// var result string
// err = sonic.Unmarshal(apiResp.Result, &result)
// utils.OkOrPanic(err)
//
// return result
//}
@@ -20,6 +20,15 @@ type CpSubmitCaptcha struct {
RawResponseJson json.RawMessage `json:"-"`
}
type CpSubmitCaptchaForAI struct {
Code string `json:"code"`
Name string `json:"name"`
ImageInfos []struct {
Code string `json:"code"`
Path string `json:"path"`
} `json:"imageInfos"`
}
// ApiResponse 通用API响应结构体
type ApiResponse struct {
Code json.Number `json:"code"`
@@ -33,9 +42,7 @@ type ApiResponse struct {
type ITStarResponse struct {
Code json.Number `json:"code"`
Msg string `json:"msg"`
Data struct {
SuccCode []string `json:"succCode"`
} `json:"data"`
Data []string `json:"data"`
}
type SubmitCaptchaResolver interface {
@@ -5,8 +5,8 @@ import (
"encoding/json"
"fmt"
"likedream-goframe/internal/application/cpdaily/types"
"likedream-goframe/internal/common/cst"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/cst"
"likedream-goframe/internal/utils"
"strings"
@@ -16,7 +16,7 @@ import (
"resty.dev/v3"
)
type RuoliSubmitCaptchaResolver struct {
type NormalSubmitCaptchaResolver struct {
client *resty.Client
host string
deviceId string
@@ -24,12 +24,12 @@ type RuoliSubmitCaptchaResolver struct {
itstarUrl string
}
var _ SubmitCaptchaResolver = (*RuoliSubmitCaptchaResolver)(nil)
var _ SubmitCaptchaResolver = (*NormalSubmitCaptchaResolver)(nil)
// NewRuoliResolver 创建若李验证码解析器
func NewRuoliResolver(client *resty.Client, host, deviceId string, taskType types.TaskType) *RuoliSubmitCaptchaResolver {
baseUrl := g.Config().MustGetWithEnv(gctx.GetInitCtx(), cst.ConfigRuoliCaptchaBaseUrl).String()
return &RuoliSubmitCaptchaResolver{
// NewNormalResolver 创建若李验证码解析器
func NewNormalResolver(client *resty.Client, host, deviceId string, taskType types.TaskType) *NormalSubmitCaptchaResolver {
baseUrl := g.Config().MustGetWithEnv(gctx.GetInitCtx(), cst.ConfigCaptchaBaseUrl).String() + "/classifier"
return &NormalSubmitCaptchaResolver{
client: client,
host: host,
deviceId: deviceId,
@@ -39,12 +39,12 @@ func NewRuoliResolver(client *resty.Client, host, deviceId string, taskType type
}
// getITStarUrl 获取ITStar服务URL
func (r *RuoliSubmitCaptchaResolver) getITStarUrl() string {
func (r *NormalSubmitCaptchaResolver) getITStarUrl() string {
if r.itstarUrl != "" {
return r.itstarUrl
}
r.itstarUrl = g.Config().MustGetWithEnv(gctx.GetInitCtx(), cst.ConfigRuoliCaptchaBaseUrl).String()
r.itstarUrl = g.Config().MustGetWithEnv(gctx.GetInitCtx(), cst.ConfigCaptchaBaseUrl).String() + "/classifier"
if r.itstarUrl == "" {
utils.OkOrPanic(errors.NewBusinessError(500, "未配置ITStarUrl"))
}
@@ -53,7 +53,7 @@ func (r *RuoliSubmitCaptchaResolver) getITStarUrl() string {
}
// getValidateUrl 根据任务类型获取验证URL
func (r *RuoliSubmitCaptchaResolver) getValidateUrl() string {
func (r *NormalSubmitCaptchaResolver) getValidateUrl() string {
baseUrl := strings.TrimSuffix(r.host, "/")
switch r.taskType {
@@ -70,7 +70,7 @@ func (r *RuoliSubmitCaptchaResolver) getValidateUrl() string {
}
// GetValidationInfo 获取验证信息
func (r *RuoliSubmitCaptchaResolver) GetValidationInfo(ctx context.Context) ValidationInfo {
func (r *NormalSubmitCaptchaResolver) GetValidationInfo(ctx context.Context) ValidationInfo {
reqData := map[string]string{
"deviceId": r.deviceId,
}
@@ -98,14 +98,14 @@ func (r *RuoliSubmitCaptchaResolver) GetValidationInfo(ctx context.Context) Vali
}
// GetCaptchaTicket 获取验证码票据
func (r *RuoliSubmitCaptchaResolver) GetCaptchaTicket(ctx context.Context, info ValidationInfo) string {
func (r *NormalSubmitCaptchaResolver) GetCaptchaTicket(ctx context.Context, info ValidationInfo) string {
captchaInfo := r.createCaptcha(info)
codes := r.getResultFromItStar(captchaInfo.RawResponseJson)
return r.submitCaptcha(info, captchaInfo, codes)
}
// createCaptcha 创建验证码
func (r *RuoliSubmitCaptchaResolver) createCaptcha(info ValidationInfo) CpSubmitCaptcha {
func (r *NormalSubmitCaptchaResolver) createCaptcha(info ValidationInfo) CpSubmitCaptcha {
baseUrl := strings.TrimSuffix(r.host, "/")
url := baseUrl + "/captcha-open-api/v1/captcha/create/scenesImage"
@@ -139,16 +139,13 @@ func (r *RuoliSubmitCaptchaResolver) createCaptcha(info ValidationInfo) CpSubmit
}
// getResultFromItStar 从ITStar获取识别结果
func (r *RuoliSubmitCaptchaResolver) getResultFromItStar(rawResponse json.RawMessage) []string {
func (r *NormalSubmitCaptchaResolver) getResultFromItStar(rawResponse json.RawMessage) []string {
// 解析原始响应为map以便添加authKey
var responseMap map[string]any
err := sonic.Unmarshal(rawResponse, &responseMap)
utils.OkOrPanic(err)
responseMap["authKey"] = "ITStarYYDS"
itstarUrl := strings.TrimSuffix(r.getITStarUrl(), "/")
url := itstarUrl + "/captcha/unLimitValidate"
url := r.getITStarUrl()
resp, err := r.client.R().
SetBody(responseMap).
@@ -161,11 +158,11 @@ func (r *RuoliSubmitCaptchaResolver) getResultFromItStar(rawResponse json.RawMes
utils.OkOrPanic(errors.NewBusinessError(503, fmt.Sprintf("验证码识别出错: %s", itstarResp.Msg)))
}
return itstarResp.Data.SuccCode
return itstarResp.Data
}
// submitCaptcha 提交验证码
func (r *RuoliSubmitCaptchaResolver) submitCaptcha(info ValidationInfo, submitCaptcha CpSubmitCaptcha, codes []string) string {
func (r *NormalSubmitCaptchaResolver) submitCaptcha(info ValidationInfo, submitCaptcha CpSubmitCaptcha, codes []string) string {
baseUrl := strings.TrimSuffix(r.host, "/")
url := baseUrl + "/captcha-open-api/v1/captcha/validate/scenesImage"
@@ -10,7 +10,7 @@ import (
func TestRuoliSubmitCaptchaResolver_GetCaptchaTicket(t *testing.T) {
loginClient, detail := testutil.LoginTestUser()
resolver := NewRuoliResolver(loginClient, detail.WecloudUrl, "abcdefg", types.TaskTypeSleepCheck)
resolver := NewNormalResolver(loginClient, detail.WecloudUrl, "abcdefg", types.TaskTypeSleepCheck)
info := resolver.GetValidationInfo(context.Background())
+6 -11
View File
@@ -2,27 +2,22 @@ package task
import (
"context"
"likedream-goframe/internal/application/cookiejar"
"likedream-goframe/internal/application/cpdaily/school"
"likedream-goframe/internal/application/httpclient"
errors2 "likedream-goframe/internal/common/errors"
"likedream-goframe/internal/ent/cptask"
"likedream-goframe/internal/utils"
"resty.dev/v3"
)
func GetCpdailyTaskService(ctx context.Context, Type cptask.TaskType, cookie string, schoolId string) CpdailyTask {
detail := school.GetSchoolDetail(ctx, schoolId)
cookieJar := cookiejar.NewPersistCookieJar()
cookieJar.Deserialize(cookie)
client := httpclient.Cpdaily.Clone(ctx)
client.SetCookieJar(cookieJar)
func GetCpdailyTaskService(ctx context.Context, Type cptask.TaskType, client *resty.Client, schoolDetail school.SchoolDetailInfo) CpdailyTask {
switch Type {
case cptask.TaskTypeCollection:
return NewCollectionTask(client, detail.WecloudUrl)
return NewCollectionTask(client, schoolDetail.WecloudUrl)
case cptask.TaskTypeSign:
return NewAutoSignTask(client, detail.WecloudUrl)
return NewAutoSignTask(client, schoolDetail.WecloudUrl)
case cptask.TaskTypeSleepCheck:
return NewSleepCheckTask(client, detail.WecloudUrl)
return NewSleepCheckTask(client, schoolDetail.WecloudUrl)
default:
utils.OkOrPanic(errors2.NewBusinessError(400, "不支持的任务类型"))
}
@@ -161,7 +161,7 @@ func (c *CpdailyCollectionTask) QueryFormByTitle(ctx context.Context, titleGlob
}
if matchTask == nil {
utils.OkOrPanic(errors.NewBusinessError(404, fmt.Sprintf("未找到表单信息: %s", titleGlob)), func(err error) error {
utils.OkOrPanic(errors.NewBusinessError(503, fmt.Sprintf("未找到表单信息: %s", titleGlob)), func(err error) error {
return oops.With("host", c.host).
In("CpdailyCollectionTask.QueryFormByTitle").
Wrap(err)
@@ -196,7 +196,7 @@ func (c *CpdailyCollectionTask) QueryFormByTitle(ctx context.Context, titleGlob
detailDatas := detailJSON["datas"].MapStrVar()
if len(detailDatas) == 0 {
utils.OkOrPanic(errors.NewBusinessError(404, "未找到表单数据"), func(err error) error {
utils.OkOrPanic(errors.NewBusinessError(503, "未找到表单数据"), func(err error) error {
return oops.With("host", c.host).
In("CpdailyCollectionTask.QueryFormByTitle").
With("titleGlob", titleGlob).
@@ -230,7 +230,7 @@ func (c *CpdailyCollectionTask) QueryFormByTitle(ctx context.Context, titleGlob
formDatas := formJSON["datas"].MapStrVar()
if len(formDatas) == 0 {
utils.OkOrPanic(errors.NewBusinessError(404, "未找到表单数据"), func(err error) error {
utils.OkOrPanic(errors.NewBusinessError(503, "未找到表单数据"), func(err error) error {
return oops.With("host", c.host).
In("CpdailyCollectionTask.QueryFormByTitle").
With("titleGlob", titleGlob).
@@ -354,7 +354,7 @@ func (c *CpdailyCollectionTask) ParseFormJson(ctx context.Context, rawJson map[s
return list
}
func (c *CpdailyCollectionTask) FillCustomForm(ctx context.Context, form map[string]*gvar.Var, paramList []*formtype.CpTaskFormItem, deviceId, username string) map[string]*gvar.Var {
func (c *CpdailyCollectionTask) FillCustomForm(ctx context.Context, form map[string]*gvar.Var, paramList []*formtype.CpTaskFormItem, deviceId, imgId string) map[string]*gvar.Var {
taskInfo := form["taskInfo"].MapStrVar()
rows := form["rows"].Array()
filledForm := make([]map[string]*gvar.Var, 0)
@@ -15,4 +15,5 @@ type CpTaskFormItem struct {
Sort uint `json:"sort,omitempty"`
CacheKey string `json:"cacheKey,omitempty"`
Show bool `json:"show,omitempty"`
Tip string `json:"tip,omitempty"`
}
@@ -8,4 +8,5 @@ const (
ItemTypeMulti
ItemTypeImage
ItemTypeAddress
ItemTypeLatAndLon
)
@@ -12,6 +12,6 @@ type CpdailyTask interface {
QueryFormByTitle(ctx context.Context, titleGlob string) map[string]*gvar.Var
QueryFormByYearMonth(ctx context.Context, titleGlob string, year, month string) map[string]*gvar.Var
ParseFormJson(ctx context.Context, rawJson map[string]*gvar.Var) []*formtype.CpTaskFormItem
FillCustomForm(ctx context.Context, form map[string]*gvar.Var, paramList []*formtype.CpTaskFormItem, deviceId, username string) map[string]*gvar.Var
FillCustomForm(ctx context.Context, form map[string]*gvar.Var, paramList []*formtype.CpTaskFormItem, deviceId, imgId string) map[string]*gvar.Var
Submit(ctx context.Context, form map[string]*gvar.Var, username, deviceId string)
}
@@ -0,0 +1,15 @@
package leavetype
type LeaveListOne struct {
Id string `json:"id"`
LeaveType string `json:"leaveType"`
CreateTime string `json:"createTime"`
AceEndTime string `json:"aceEndTime"`
Reason string `json:"reason"`
Status string `json:"status"`
// 销假时填充的位置信息
Longitude string `json:"longitude"` // 经度
Latitude string `json:"latitude"` // 纬度
Address string `json:"address"` // 地址
ChooseAttendance int64 `json:"chooseAttendance"` // 是否需要图片 1需要 0 不需要
}
+53 -57
View File
@@ -12,7 +12,6 @@ import (
"likedream-goframe/internal/application/cpdaily/types"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/utils"
"math/rand/v2"
"slices"
"strings"
"time"
@@ -147,8 +146,8 @@ func (c *CpdailyAutoSignTask) QueryFormByTitle(ctx context.Context, titleGlob st
findMatchTitle := func(jsonArr []any) map[string]*gvar.Var {
for _, rawJson := range jsonArr {
item, ok := rawJson.(map[string]*gvar.Var)
if !ok {
item := gvar.New(rawJson).MapStrVar()
if len(item) == 0 {
continue
}
taskName, exist := item["taskName"]
@@ -178,7 +177,7 @@ func (c *CpdailyAutoSignTask) QueryFormByTitle(ctx context.Context, titleGlob st
if matchTask == nil {
matchTask = findMatchTitle(signedTasks)
if matchTask == nil {
utils.OkOrPanic(errors.NewBusinessError(404, fmt.Sprintf("任务未找到: %s", titleGlob)), func(err error) error {
utils.OkOrPanic(errors.NewBusinessError(503, fmt.Sprintf("任务未找到: %s", titleGlob)), func(err error) error {
return oops.With("host", c.host).
In("CpdailyAutoSignTask.QueryFormByTitle").
Wrap(err)
@@ -210,7 +209,7 @@ func (c *CpdailyAutoSignTask) QueryFormByTitle(ctx context.Context, titleGlob st
formDatas := detailResponseJSON["datas"].MapStrVar()
if len(formDatas) == 0 {
utils.OkOrPanic(errors.NewBusinessError(404, "任务数据为空"), func(err error) error {
utils.OkOrPanic(errors.NewBusinessError(503, "任务数据为空"), func(err error) error {
return oops.With("host", c.host).
In("CpdailyAutoSignTask.QueryFormByTitle").
With("titleGlob", titleGlob).
@@ -293,7 +292,7 @@ func (c *CpdailyAutoSignTask) QueryFormByYearMonth(ctx context.Context, titleGlo
if matchTask == nil {
matchTask = findMatchTitle(signedTasks)
if matchTask == nil {
utils.OkOrPanic(errors.NewBusinessError(404, fmt.Sprintf("任务未找到: %s", titleGlob)), func(err error) error {
utils.OkOrPanic(errors.NewBusinessError(503, fmt.Sprintf("任务未找到: %s", titleGlob)), func(err error) error {
return oops.
With("host", c.host).
With("year", year).
@@ -332,7 +331,7 @@ func (c *CpdailyAutoSignTask) QueryFormByYearMonth(ctx context.Context, titleGlo
formDatas := detailResponseJSON["datas"].MapStrVar()
if len(formDatas) == 0 {
utils.OkOrPanic(errors.NewBusinessError(404, "任务数据为空"), func(err error) error {
utils.OkOrPanic(errors.NewBusinessError(503, "任务数据为空"), func(err error) error {
return oops.
With("host", c.host).
With("year", year).
@@ -360,42 +359,48 @@ func (c *CpdailyAutoSignTask) ParseFormJson(ctx context.Context, rawJson map[str
var list []*formtype.CpTaskFormItem
// 经纬度
//dto := &formtype.CpTaskFormItem{
// Title: "经度",
// ColName: "lon",
// Type: formtype.ItemTypeInput,
// Required: true,
//}
//list = append(list, dto)
//
//dto = &formtype.CpTaskFormItem{
// Title: "纬度",
// ColName: "lat",
// Type: formtype.ItemTypeInput,
// Required: true,
//}
//list = append(list, dto)
dto := &formtype.CpTaskFormItem{
Title: "经度",
ColName: "lon",
Type: formtype.ItemTypeInput,
Required: true,
}
list = append(list, dto)
dto = &formtype.CpTaskFormItem{
Title: "纬度",
ColName: "lat",
Type: formtype.ItemTypeInput,
Title: "经纬度",
ColName: "latAndLon",
Type: formtype.ItemTypeLatAndLon,
Required: true,
Tip: "您可以通过地址搜索签到经纬度,如果觉得不够准确可以自行修改",
}
list = append(list, dto)
dto = &formtype.CpTaskFormItem{
Title: "签到具体地址",
ColName: "address",
Type: formtype.ItemTypeAddress,
Type: formtype.ItemTypeInput,
Required: true,
Tip: "这个就是指签到完毕后查看时最底部那个最详细的地址信息哦",
}
list = append(list, dto)
var current uint
if isPhoto := rows["isPhoto"]; isPhoto != nil && isPhoto.Int() == 1 {
dto = &formtype.CpTaskFormItem{
Title: "签到照片",
ColName: "signImg",
Type: formtype.ItemTypeImage,
Required: true,
Number: current,
Tip: "您可以上传多张图片,会顺序轮换更新的哦~",
}
list = append(list, dto)
current++
}
// 是否范围外签到
@@ -404,7 +409,6 @@ func (c *CpdailyAutoSignTask) ParseFormJson(ctx context.Context, rawJson map[str
ColName: "isMalposition",
Type: formtype.ItemTypeSingle,
Required: true,
Number: current,
Options: []formtype.CpTaskFormItemOption{
{
Content: "是",
@@ -416,9 +420,9 @@ func (c *CpdailyAutoSignTask) ParseFormJson(ctx context.Context, rawJson map[str
IsOtherItems: false,
},
},
Tip: "一般在学校就是选否,如果在外实习、请假之类的才选是",
}
list = append(list, dto)
current++
// 范围外签到原因
dto = &formtype.CpTaskFormItem{
@@ -427,16 +431,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 +448,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(),
@@ -473,7 +477,7 @@ func (c *CpdailyAutoSignTask) ParseFormJson(ctx context.Context, rawJson map[str
return list
}
func (c *CpdailyAutoSignTask) FillCustomForm(ctx context.Context, form map[string]*gvar.Var, paramList []*formtype.CpTaskFormItem, deviceId, username string) map[string]*gvar.Var {
func (c *CpdailyAutoSignTask) FillCustomForm(ctx context.Context, form map[string]*gvar.Var, paramList []*formtype.CpTaskFormItem, deviceId, imgId string) map[string]*gvar.Var {
var resultForm map[string]*gvar.Var
// 填充必要变量
@@ -491,6 +495,11 @@ func (c *CpdailyAutoSignTask) FillCustomForm(ctx context.Context, form map[strin
lon = v.Content
case "address":
address = v.Content
case "latAndLon":
{
res := strings.Split(v.Content, ",")
lat, lon = res[1], res[0]
}
}
}
@@ -514,36 +523,20 @@ func (c *CpdailyAutoSignTask) FillCustomForm(ctx context.Context, form map[strin
utils.OkOrPanic(errors.NewBusinessError(400, "签到需要图片,但是当前图片为空"), oops.WithContext(ctx).Wrap)
}
imageUuidSeq := strings.SplitSeq(targetParam.Content, ",")
var imageUuids []string
for imageUuid := range imageUuidSeq {
imageUuid = strings.TrimSpace(imageUuid)
if imageUuid == "" {
continue
}
imageUuids = append(imageUuids, imageUuid)
}
if len(imageUuids) == 0 {
if imgId == "" {
utils.OkOrPanic(errors.NewBusinessError(400, "签到照片未上传"), oops.WithContext(ctx).Wrap)
}
// 随机选择一张照片
imageUuid := imageUuids[rand.IntN(len(imageUuids))]
imageId, e := uuid.Parse(imageUuid)
imageId, e := uuid.Parse(imgId)
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
previewUrl := imageuploader.UploadImageFromStorage(ctx, c.host, imageId, types.TaskTypeSign, c.client)
resultForm["signImg"] = gvar.New(previewUrl)
resultForm["signPhotoUrl"] = gvar.New(previewUrl)
}
// 处理验证码
submitCaptchaResolver := submitcaptcha.NewRuoliResolver(c.client, c.host, deviceId, types.TaskTypeSign)
submitCaptchaResolver := submitcaptcha.NewNormalResolver(c.client, c.host, deviceId, types.TaskTypeSign)
captchaInfo := submitCaptchaResolver.GetValidationInfo(ctx)
if captchaInfo.Validation {
@@ -554,7 +547,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 +572,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 +582,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 +592,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 +603,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 {
+62 -51
View File
@@ -10,10 +10,9 @@ import (
"likedream-goframe/internal/application/cpdaily/task/formtype"
"likedream-goframe/internal/application/cpdaily/taskhelper"
"likedream-goframe/internal/application/cpdaily/types"
"likedream-goframe/internal/common/cst"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/cst"
"likedream-goframe/internal/utils"
"math/rand/v2"
"strconv"
"strings"
"time"
@@ -436,19 +435,27 @@ func (c *CpdailySleepCheckTask) ParseFormJson(ctx context.Context, rawJson map[s
var list []*formtype.CpTaskFormItem
// 经纬度
//dto := &formtype.CpTaskFormItem{
// Title: "经度",
// ColName: "lon",
// Type: formtype.ItemTypeInput,
// Required: true,
//}
//list = append(list, dto)
//
//dto = &formtype.CpTaskFormItem{
// Title: "纬度",
// ColName: "lat",
// Type: formtype.ItemTypeInput,
// Required: true,
//}
//list = append(list, dto)
dto := &formtype.CpTaskFormItem{
Title: "经度",
ColName: "lon",
Type: formtype.ItemTypeInput,
Required: true,
}
list = append(list, dto)
dto = &formtype.CpTaskFormItem{
Title: "纬度",
ColName: "lat",
Type: formtype.ItemTypeInput,
Title: "经纬度",
ColName: "latAndLon",
Type: formtype.ItemTypeLatAndLon,
Required: true,
Tip: "您可以通过地址搜索签到经纬度,如果觉得不够准确可以自行修改",
}
list = append(list, dto)
dto = &formtype.CpTaskFormItem{
@@ -456,21 +463,20 @@ func (c *CpdailySleepCheckTask) ParseFormJson(ctx context.Context, rawJson map[s
ColName: "address",
Type: formtype.ItemTypeInput,
Required: true,
Tip: "这个就是指签到完毕后查看时最底部那个最详细的地址信息哦",
}
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,
Tip: "您可以上传多张图片,会顺序轮换更新的哦~",
}
list = append(list, dto)
current++
}
// 是否范围外签到
@@ -479,7 +485,6 @@ func (c *CpdailySleepCheckTask) ParseFormJson(ctx context.Context, rawJson map[s
ColName: "isMalposition",
Type: formtype.ItemTypeSingle,
Required: true,
Number: current,
Options: []formtype.CpTaskFormItemOption{
{
Content: "是",
@@ -491,9 +496,9 @@ func (c *CpdailySleepCheckTask) ParseFormJson(ctx context.Context, rawJson map[s
IsOtherItems: false,
},
},
Tip: "一般在学校就是选否,如果在外实习、请假之类的才选是",
}
list = append(list, dto)
current++
for key, item := range list {
list[key].Show = item.LogicWid == ""
@@ -502,7 +507,7 @@ func (c *CpdailySleepCheckTask) ParseFormJson(ctx context.Context, rawJson map[s
return list
}
func (c *CpdailySleepCheckTask) FillCustomForm(ctx context.Context, form map[string]*gvar.Var, paramList []*formtype.CpTaskFormItem, deviceId, username string) map[string]*gvar.Var {
func (c *CpdailySleepCheckTask) FillCustomForm(ctx context.Context, form map[string]*gvar.Var, paramList []*formtype.CpTaskFormItem, deviceId, imgId string) map[string]*gvar.Var {
var resultForm map[string]*gvar.Var
// 填充必要变量
@@ -520,6 +525,12 @@ func (c *CpdailySleepCheckTask) FillCustomForm(ctx context.Context, form map[str
lon = v.Content
case "address":
address = v.Content
case "latAndLon":
{
res := strings.Split(v.Content, ",")
lat, lon = res[1], res[0]
}
}
}
@@ -529,7 +540,8 @@ func (c *CpdailySleepCheckTask) FillCustomForm(ctx context.Context, form map[str
resultForm["uaIsCpadaily"] = gvar.New(true)
// 处理验证码
submitCaptchaResolver := submitcaptcha.NewRuoliResolver(c.client, c.host, deviceId, types.TaskTypeSleepCheck)
submitCaptchaResolver := submitcaptcha.NewNormalResolver(c.client, c.host, deviceId, types.TaskTypeSleepCheck)
//submitCaptchaResolver := submitcaptcha.NewAIResolver(c.client, c.host, deviceId, types.TaskTypeSleepCheck)
captchaInfo := submitCaptchaResolver.GetValidationInfo(ctx)
if captchaInfo.Validation {
@@ -539,9 +551,10 @@ func (c *CpdailySleepCheckTask) FillCustomForm(ctx context.Context, form map[str
return utils.Catch(func() {
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)
}, retry.Delay(10*time.Second), retry.Attempts(5), retry.Context(ctx))
if e != nil {
utils.OkOrPanic(errors.NewBusinessError(503, "提交验证码识别失败").WithInnerError(e), oops.WithContext(ctx).Wrap)
}
resultForm["ticket"] = gvar.New(ticket)
} else {
resultForm["ticket"] = gvar.New("")
@@ -560,32 +573,16 @@ func (c *CpdailySleepCheckTask) FillCustomForm(ctx context.Context, form map[str
utils.OkOrPanic(errors.NewBusinessError(503, "未找到查寝照片参数,请上传照片"), oops.WithContext(ctx).Wrap)
}
imageUuidSeq := strings.SplitSeq(targetParam.Content, ",")
var imageUuids []string
for imageUuid := range imageUuidSeq {
imageUuid = strings.TrimSpace(imageUuid)
if imageUuid == "" {
continue
}
imageUuids = append(imageUuids, imageUuid)
}
if len(imageUuids) == 0 {
if imgId == "" {
utils.OkOrPanic(errors.NewBusinessError(503, "未找到查寝照片参数,请上传照片"), oops.WithContext(ctx).Wrap)
}
// 随机选择一张照片
imageUuid := imageUuids[rand.IntN(len(imageUuids))]
imageId, e := uuid.Parse(imageUuid)
imageId, e := uuid.Parse(imgId)
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
previewUrl := imageuploader.UploadImageFromStorage(ctx, c.host, imageId, types.TaskTypeSleepCheck, c.client)
resultForm["signImg"] = gvar.New(previewUrl)
resultForm["signPhotoUrl"] = gvar.New(previewUrl)
}
// 判断是否存在二维码查寝
@@ -605,9 +602,9 @@ func (c *CpdailySleepCheckTask) FillCustomForm(ctx context.Context, form map[str
} else {
// 否则,填充查寝其他参数
if signInstanceWidVar := rows["signInstanceWid"]; signInstanceWidVar != nil {
resultForm["signInstanceWid"] = gvar.New(signInstanceWidVar.String())
resultForm["SignInstanceWid"] = gvar.New(signInstanceWidVar.String())
} else {
resultForm["signInstanceWid"] = gvar.New("")
resultForm["SignInstanceWid"] = gvar.New("")
}
// 查找 isMalposition 参数
@@ -660,11 +657,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"],
@@ -674,7 +671,6 @@ func (c *CpdailySleepCheckTask) Submit(ctx context.Context, form map[string]*gva
}
sign := taskhelper.GetSubmitSign(submitData)
submitData["sign"] = gvar.New(sign)
response, e := c.client.R().
SetBody(submitData).
@@ -697,3 +693,18 @@ func (c *CpdailySleepCheckTask) Submit(ctx context.Context, form map[string]*gva
utils.OkOrPanic(errors.NewBusinessError(503, responseJSON.Get("message").String()), oops.WithContext(ctx).Wrap)
}
}
func (c *CpdailySleepCheckTask) GetYearAndMonth(ctx context.Context) (string, string) {
// Get current time in Shanghai timezone
timeNow := time.Now().In(cst.TimeZoneShangHai)
// Check if current day is within first 2 days of the month
if timeNow.Day() <= 2 {
// Return previous month
prevTime := timeNow.AddDate(0, -1, 0)
return strconv.Itoa(prevTime.Year()), strconv.Itoa(int(prevTime.Month()))
} else {
// Return current month
return strconv.Itoa(timeNow.Year()), strconv.Itoa(int(timeNow.Month()))
}
}
@@ -1,135 +0,0 @@
package task
import (
"context"
"likedream-goframe/internal/testutil"
"testing"
"github.com/joomcode/errorx"
)
func TestCpdailySleepCheckTask_QueryFormByTitle(t *testing.T) {
loginClient, schoolDetail := testutil.LoginTestUser()
task := NewSleepCheckTask(loginClient, schoolDetail.WecloudUrl)
tasks := task.QueryFormByTitle(context.Background(), "查寝")
if len(tasks) == 0 {
t.Error("未查询到查寝任务")
} else {
t.Logf("查询到查寝任务: %v", tasks)
}
}
func TestCpdailySleepCheckTask_GetRecentTaskTitle(t *testing.T) {
loginClient, schoolDetail := testutil.LoginTestUser()
task := NewSleepCheckTask(loginClient, schoolDetail.WecloudUrl)
titles := task.GetRecentTaskTitle(context.Background())
if len(titles) == 0 {
t.Error("未查询到查寝任务标题")
} else {
t.Logf("查询到查寝任务标题: %v", titles)
}
}
func TestCpdailySleepCheckTask_QueryFormByYearMonth(t *testing.T) {
loginClient, schoolDetail := testutil.LoginTestUser()
task := NewSleepCheckTask(loginClient, schoolDetail.WecloudUrl)
tests := []struct {
name string
titleGlob string
year string
month string
wantErr bool
}{
{
name: "查询当前月份查寝任务",
titleGlob: "查寝",
year: "2025",
month: "6",
wantErr: false,
},
{
name: "查询单数字月份查寝任务",
titleGlob: "查寝",
year: "2025",
month: "6", // 将会被格式化为 "06"
wantErr: false,
},
{
name: "使用通配符模式匹配",
titleGlob: "*查寝*",
year: "2025",
month: "6",
wantErr: false,
},
{
name: "查询不存在的任务",
titleGlob: "不存在的任务",
year: "2025",
month: "6",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
oopsError, _ := errorx.ErrorFromPanic(r)
if !tt.wantErr {
t.Errorf("QueryFormByYearMonth() 意外的panic: %+v", oopsError)
} else {
t.Logf("预期的错误: %+v", oopsError)
}
}
}()
form := task.QueryFormByYearMonth(context.Background(), tt.titleGlob, tt.year, tt.month)
if tt.wantErr {
t.Error("预期应该出现错误,但没有出现")
return
}
if form == nil {
t.Error("未查询到查寝任务表单")
return
}
// 验证返回结果的结构
if realTitle := form["realTitle"].String(); realTitle == "" {
t.Error("返回结果中缺少 realTitle 或 realTitle 为空")
} else {
t.Logf("任务真实标题: %s", realTitle)
}
if taskInfo := form["taskInfo"].Map(); taskInfo == nil {
t.Error("返回结果中缺少 taskInfo 或 taskInfo 为空")
} else {
t.Logf("任务信息包含字段: %d", len(taskInfo))
// 验证关键字段是否存在
requiredFields := []string{"taskName", "signWid", "signInstanceWid"}
for _, field := range requiredFields {
if _, exists := taskInfo[field]; !exists {
t.Errorf("taskInfo 中缺少必要字段: %s", field)
}
}
}
if rows := form["rows"].Map(); rows == nil {
t.Error("返回结果中缺少 rows 或 rows 为空")
} else {
t.Logf("表单数据包含字段: %d", len(rows))
}
t.Logf("查询到查寝任务表单,标题: %v", form["realTitle"])
})
}
}
@@ -0,0 +1,19 @@
package task
import (
"context"
"likedream-goframe/internal/testutil"
"testing"
"github.com/bytedance/sonic"
)
func TestGetHistoryForm(t *testing.T) {
ctx := context.Background()
loginClient, schoolDetail := testutil.LoginTestUser()
task := NewSleepCheckTask(loginClient, schoolDetail.WecloudUrl)
y, m := task.GetYearAndMonth(ctx)
month := task.QueryFormByYearMonth(ctx, "晚查寝", y, m)
marshal, _ := sonic.Marshal(month)
t.Log(string(marshal))
}
@@ -11,7 +11,7 @@ import (
"github.com/gogf/gf/v2/container/gvar"
)
const submitSignSalt = "HashSalt" // 请根据实际情况替换为真实盐值
const submitSignSalt = "hru89rwr87Nthifa" // 请根据实际情况替换为真实盐值
func GetSubmitSign(form map[string]*gvar.Var) string {
// 按照指定顺序构建哈希数据
@@ -6,4 +6,5 @@ const (
TaskTypeCollection = TaskType(iota)
TaskTypeSign
TaskTypeSleepCheck
TaskTypeLeave
)
+2 -1
View File
@@ -2,8 +2,9 @@ package filestorage
import (
"context"
"likedream-goframe/internal/common/cst"
"github.com/gogf/gf/v2/frame/g"
"likedream-goframe/internal/cst"
)
var GlobalS3 *S3
+41 -6
View File
@@ -3,23 +3,58 @@ package httpclient
import (
"crypto/tls"
"likedream-goframe/internal/utils"
"net/http"
"os"
"time"
"resty.dev/v3"
)
var transport = &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS10,
},
TLSHandshakeTimeout: 15 * time.Second,
}
func init() {
_ = os.Setenv("GODEBUG", "tlsrsakex=1")
}
var Cpdaily = resty.New().
SetCookieJar(nil).
SetTimeout(30 * time.Second).
SetDoNotParseResponse(true)
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() {
Cpdaily.SetDebug(false)
} else {
Cpdaily.SetProxy("http://127.0.0.1:9000").
SetTLSClientConfig(&tls.Config{
InsecureSkipVerify: true,
})
Cpdaily.SetProxy("http://127.0.0.1:9000")
}
}
// NewCpdailyClient 创建一个全新的独立 client 实例,避免 Clone 可能的浅拷贝问题
// 每个实例都是完全独立的,适用于并发场景,不会共享状态
func NewCpdailyClient() *resty.Client {
client := resty.New().
SetCookieJar(nil).
SetTimeout(30*time.Second).
SetDoNotParseResponse(true).
SetTransport(transport).
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")
// 根据调试模式设置代理和调试选项
if !utils.IsDebug() {
client.SetDebug(false)
} else {
client.SetProxy("http://127.0.0.1:9000")
}
return client
}
@@ -0,0 +1,29 @@
package httpclient
import (
"crypto/tls"
"likedream-goframe/internal/utils"
"time"
"resty.dev/v3"
)
func NewMogudingClient() *resty.Client {
client := resty.New().
SetCookieJar(nil).
SetTimeout(30*time.Second).
SetDoNotParseResponse(false).
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")
// 根据调试模式设置代理和调试选项
if !utils.IsDebug() {
client.SetDebug(false)
} else {
client.SetProxy("http://127.0.0.1:9000")
}
return client
}
+15
View File
@@ -0,0 +1,15 @@
package httpclient
import (
"crypto/tls"
"time"
"resty.dev/v3"
)
var Music = resty.New().
SetCookieJar(nil).
SetTimeout(3 * time.Minute).
SetTLSClientConfig(&tls.Config{
InsecureSkipVerify: true,
})
@@ -0,0 +1,49 @@
package httpclient
import (
"crypto/tls"
"likedream-goframe/internal/application/cookiejar"
"likedream-goframe/internal/utils"
"time"
"resty.dev/v3"
)
var SmartEdu = resty.New().
SetCookieJar(cookiejar.NewAllSavePersistCookieJar()).
SetRedirectPolicy(resty.FlexibleRedirectPolicy(20)).
SetTimeout(30 * time.Second).
SetTLSClientConfig(&tls.Config{
InsecureSkipVerify: true,
})
func init() {
if !utils.IsDebug() {
Xuexitong.SetDebug(false)
} else {
Xuexitong.SetProxy("http://127.0.0.1:9000")
}
}
// NewSmartEdu 创建一个全新的独立 client 实例,避免 Clone 可能的浅拷贝问题
// 每个实例都是完全独立的,适用于并发场景,不会共享状态
func NewSmartEdu() *resty.Client {
client := resty.New().
SetCookieJar(nil).
SetTimeout(30*time.Second).
SetDoNotParseResponse(false).
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")
// 根据调试模式设置代理和调试选项
if !utils.IsDebug() {
client.SetDebug(false)
} else {
client.SetProxy("http://127.0.0.1:9000")
}
jar := cookiejar.NewAllSavePersistCookieJar()
client.SetCookieJar(jar)
return client
}
@@ -0,0 +1,48 @@
package httpclient
import (
"crypto/tls"
"likedream-goframe/internal/application/cookiejar"
"likedream-goframe/internal/utils"
"time"
"resty.dev/v3"
)
var Xuexitong = resty.New().
SetCookieJar(cookiejar.NewAllSavePersistCookieJar()).
SetRedirectPolicy(resty.FlexibleRedirectPolicy(20)).
SetTimeout(30 * time.Second).
SetTLSClientConfig(&tls.Config{
InsecureSkipVerify: true,
})
func init() {
if !utils.IsDebug() {
Xuexitong.SetDebug(false)
} else {
Xuexitong.SetProxy("http://127.0.0.1:9000")
}
}
// NewXuexitongClient 创建一个全新的独立 client 实例,避免 Clone 可能的浅拷贝问题
// 每个实例都是完全独立的,适用于并发场景,不会共享状态
func NewXuexitongClient() *resty.Client {
client := resty.New().
SetCookieJar(nil).
SetTimeout(30*time.Second).
SetDoNotParseResponse(false).
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")
// 根据调试模式设置代理和调试选项
if !utils.IsDebug() {
client.SetDebug(false)
} else {
client.SetProxy("http://127.0.0.1:9000")
}
return client
}
@@ -0,0 +1,47 @@
package cryptoutil
import (
"crypto/aes"
"crypto/md5"
"encoding/hex"
)
const (
// AES 密钥
Secret = "23DbtQHR2UMbH6mJ"
// MD5 签名后缀
SignSuffix = "3478cbbc33f84bd00d75d7dfa69e0daa"
)
// Encrypt 使用 AES ECB PKCS7 加密并返回十六进制字符串
func Encrypt(plaintext string) (string, error) {
key := []byte(Secret)
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
// PKCS7 填充
plainBytes := []byte(plaintext)
padding := aes.BlockSize - len(plainBytes)%aes.BlockSize
padText := make([]byte, len(plainBytes)+padding)
copy(padText, plainBytes)
for i := len(plainBytes); i < len(padText); i++ {
padText[i] = byte(padding)
}
// ECB 模式加密
encrypted := make([]byte, len(padText))
for i := 0; i < len(padText); i += aes.BlockSize {
block.Encrypt(encrypted[i:i+aes.BlockSize], padText[i:i+aes.BlockSize])
}
return hex.EncodeToString(encrypted), nil
}
// Sign2 MD5 签名
func Sign2(str string) string {
str += SignSuffix
hash := md5.Sum([]byte(str))
return hex.EncodeToString(hash[:])
}
@@ -0,0 +1,68 @@
package entity
// MogudingUser 蘑菇钉用户实体
type MogudingUser struct {
Username string `json:"username"` // 账号
Password string `json:"password"` // 密码
UserId string `json:"userId"`
Token string `json:"token"`
UserType string `json:"userType"`
PlanId string `json:"planId"`
PlanName string `json:"planName"`
}
// LoginDto 登录请求 DTO
type LoginDto struct {
Phone string `json:"phone"`
Password string `json:"password"`
Captcha string `json:"captcha"`
LoginType string `json:"loginType"`
Uuid string `json:"uuid"`
Device string `json:"device"`
Version string `json:"version"`
T string `json:"t"`
}
// LoginResponse 登录响应
type LoginResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
UserId string `json:"userId"`
Token string `json:"token"`
UserType string `json:"userType"`
} `json:"data"`
}
// PlanResponse 获取 PlanId 响应
type PlanResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data []struct {
PlanId string `json:"planId"`
PlanName string `json:"planName"`
} `json:"data"`
}
// SignRequest 签到请求
type SignRequest struct {
Country string `json:"country"`
Address string `json:"address"`
Province string `json:"province"`
City string `json:"city"`
State string `json:"state"`
Area string `json:"area"`
Latitude string `json:"latitude"`
Longitude string `json:"longitude"`
Description string `json:"description"`
PlanId string `json:"planId"`
Type string `json:"type"` // START 或 END
Device string `json:"device"`
}
// SignResponse 签到响应
type SignResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data any `json:"data"`
}
+230
View File
@@ -0,0 +1,230 @@
package moguding
import (
"context"
"encoding/json"
"fmt"
"likedream-goframe/internal/application/httpclient"
"likedream-goframe/internal/application/moguding/cryptoutil"
"likedream-goframe/internal/application/moguding/entity"
"likedream-goframe/internal/framework/logger"
"time"
"go.uber.org/zap"
"resty.dev/v3"
)
const (
// API 基础地址
ApiBase = "https://api.moguding.net:9000"
// 登录接口
ApiLogin = ApiBase + "/session/user/v6/login"
// 获取实习计划接口
ApiGetPlan = ApiBase + "/practice/plan/v3/getPlanByStu"
// 打卡签到接口
ApiSign = ApiBase + "/attendence/clock/v4/save"
)
type MogudingClient struct {
client *resty.Client
user *entity.MogudingUser
}
// NewMogudingClient 创建蘑菇钉客户端
func NewMogudingClient(ctx context.Context) *MogudingClient {
client := httpclient.NewMogudingClient()
return &MogudingClient{
client: client,
user: &entity.MogudingUser{},
}
}
// Login 登录
func (c *MogudingClient) Login(ctx context.Context, username, password string) error {
if username == "" || password == "" {
return fmt.Errorf("账号或密码不能为空")
}
// 加密账号密码
encryptedPhone, err := cryptoutil.Encrypt(username)
if err != nil {
logger.Logger.Error("加密账号失败", zap.Error(err))
return err
}
encryptedPassword, err := cryptoutil.Encrypt(password)
if err != nil {
logger.Logger.Error("加密密码失败", zap.Error(err))
return err
}
// 加密时间戳
timestamp := fmt.Sprintf("%d", time.Now().UnixMilli())
encryptedTime, err := cryptoutil.Encrypt(timestamp)
if err != nil {
logger.Logger.Error("加密时间戳失败", zap.Error(err))
return err
}
// 构造登录请求
loginDto := entity.LoginDto{
Phone: encryptedPhone,
Password: encryptedPassword,
T: encryptedTime,
LoginType: "android",
}
// 发送登录请求
resp, err := c.client.R().
SetBody(loginDto).
Post(ApiLogin)
if err != nil {
logger.Logger.Error("登录请求失败", zap.Error(err))
return err
}
// 解析响应
var loginResp entity.LoginResponse
if err := json.Unmarshal(resp.Bytes(), &loginResp); err != nil {
logger.Logger.Error("解析登录响应失败", zap.Error(err))
return err
}
if loginResp.Code != 200 {
return fmt.Errorf("登录失败:%s", loginResp.Msg)
}
// 保存用户信息
c.user.Username = username
c.user.Password = password
c.user.UserId = loginResp.Data.UserId
c.user.Token = loginResp.Data.Token
c.user.UserType = loginResp.Data.UserType
logger.Logger.Info("登录成功",
zap.String("userId", c.user.UserId),
zap.String("userType", c.user.UserType))
return nil
}
// GetPlanId 获取实习计划 ID
func (c *MogudingClient) GetPlanId(ctx context.Context) error {
if c.user.Token == "" {
return fmt.Errorf("请先登录")
}
// 计算签名
signStr := c.user.UserId + c.user.UserType
sign := cryptoutil.Sign2(signStr)
// 发送请求
resp, err := c.client.R().
SetHeaders(map[string]string{
"roleKey": c.user.UserType,
"sign": sign,
"authorization": c.user.Token,
}).
SetBody(map[string]string{"state": ""}).
Post(ApiGetPlan)
if err != nil {
logger.Logger.Error("获取 PlanId 请求失败", zap.Error(err))
return err
}
// 解析响应
var planResp entity.PlanResponse
if err := json.Unmarshal(resp.Bytes(), &planResp); err != nil {
logger.Logger.Error("解析 PlanId 响应失败", zap.Error(err))
return err
}
if planResp.Code != 200 {
return fmt.Errorf("获取 PlanId 失败:%s", planResp.Msg)
}
if len(planResp.Data) == 0 {
return fmt.Errorf("未找到实习计划")
}
// 保存第一个计划信息
c.user.PlanId = planResp.Data[0].PlanId
c.user.PlanName = planResp.Data[0].PlanName
logger.Logger.Info("获取 PlanId 成功",
zap.String("planId", c.user.PlanId),
zap.String("planName", c.user.PlanName))
return nil
}
// Sign 打卡签到
func (c *MogudingClient) Sign(ctx context.Context, signReq *entity.SignRequest) error {
if c.user.Token == "" {
return fmt.Errorf("请先登录")
}
if c.user.PlanId == "" {
return fmt.Errorf("请先获取实习计划")
}
// 设置默认值
if signReq.PlanId == "" {
signReq.PlanId = c.user.PlanId
}
if signReq.Device == "" {
signReq.Device = "Android"
}
if signReq.Type == "" {
signReq.Type = "START"
}
// 计算签名
signStr := signReq.Device + signReq.Type + signReq.PlanId + c.user.UserId + signReq.Address
sign := cryptoutil.Sign2(signStr)
// 发送请求
resp, err := c.client.R().
SetHeaders(map[string]string{
"roleKey": c.user.UserType,
"sign": sign,
"authorization": c.user.Token,
}).
SetBody(signReq).
Post(ApiSign)
if err != nil {
logger.Logger.Error("签到请求失败", zap.Error(err))
return err
}
// 解析响应
var signResp entity.SignResponse
if err := json.Unmarshal(resp.Bytes(), &signResp); err != nil {
logger.Logger.Error("解析签到响应失败", zap.Error(err))
return err
}
if signResp.Code != 200 {
return fmt.Errorf("签到失败:%s", signResp.Msg)
}
logger.Logger.Info("签到成功",
zap.String("type", signReq.Type),
zap.String("address", signReq.Address))
return nil
}
// GetUser 获取用户信息
func (c *MogudingClient) GetUser() *entity.MogudingUser {
return c.user
}
// SetUser 设置用户信息(用于恢复登录状态)
func (c *MogudingClient) SetUser(user *entity.MogudingUser) {
c.user = user
}
@@ -0,0 +1,157 @@
package moguding
import (
"context"
"likedream-goframe/internal/application/moguding/entity"
"testing"
)
func TestMogudingLogin(t *testing.T) {
ctx := context.Background()
client := NewMogudingClient(ctx)
// 替换为实际的测试账号
username := "13307425047"
password := "wgz2021322L"
err := client.Login(ctx, username, password)
if err != nil {
t.Fatalf("登录失败: %v", err)
}
t.Logf("登录成功,UserId: %s, Token: %s", client.user.UserId, client.user.Token)
}
func TestMogudingGetPlanId(t *testing.T) {
ctx := context.Background()
client := NewMogudingClient(ctx)
// 先登录
username := "your_username"
password := "your_password"
err := client.Login(ctx, username, password)
if err != nil {
t.Fatalf("登录失败: %v", err)
}
// 获取 PlanId
err = client.GetPlanId(ctx)
if err != nil {
t.Fatalf("获取 PlanId 失败: %v", err)
}
t.Logf("获取 PlanId 成功,PlanId: %s, PlanName: %s", client.user.PlanId, client.user.PlanName)
}
func TestMogudingSign(t *testing.T) {
ctx := context.Background()
client := NewMogudingClient(ctx)
// 先登录
username := "your_username"
password := "your_password"
err := client.Login(ctx, username, password)
if err != nil {
t.Fatalf("登录失败: %v", err)
}
// 获取 PlanId
err = client.GetPlanId(ctx)
if err != nil {
t.Fatalf("获取 PlanId 失败: %v", err)
}
// 打卡签到
signReq := &entity.SignRequest{
Country: "中国",
Address: "四川省 · 成都市 · 武侯区 · 咕噜咕噜",
Province: "四川省",
City: "成都市",
State: "1",
Area: "武侯区",
Latitude: "33.100039",
Longitude: "101.987848",
Description: "测试打卡",
Type: "START", // START 表示上班打卡,END 表示下班打卡
Device: "Android",
}
err = client.Sign(ctx, signReq)
if err != nil {
t.Fatalf("打卡失败: %v", err)
}
t.Logf("打卡成功")
}
func TestMogudingFullProcess(t *testing.T) {
ctx := context.Background()
client := NewMogudingClient(ctx)
// 1. 登录
username := "your_username"
password := "your_password"
t.Log("步骤1: 登录")
err := client.Login(ctx, username, password)
if err != nil {
t.Fatalf("登录失败: %v", err)
}
t.Logf("登录成功,UserId: %s", client.user.UserId)
// 2. 获取 PlanId
t.Log("步骤2: 获取实习计划")
err = client.GetPlanId(ctx)
if err != nil {
t.Fatalf("获取 PlanId 失败: %v", err)
}
t.Logf("获取 PlanId 成功,PlanId: %s, PlanName: %s", client.user.PlanId, client.user.PlanName)
// 3. 上班打卡
t.Log("步骤3: 上班打卡")
signInReq := &entity.SignRequest{
Country: "中国",
Address: "四川省 · 成都市 · 武侯区 · 咕噜咕噜",
Province: "四川省",
City: "成都市",
State: "1",
Area: "武侯区",
Latitude: "33.100039",
Longitude: "101.987848",
Description: "上班打卡",
Type: "START",
Device: "Android",
}
err = client.Sign(ctx, signInReq)
if err != nil {
t.Fatalf("上班打卡失败: %v", err)
}
t.Log("上班打卡成功")
// 4. 下班打卡(可选)
t.Log("步骤4: 下班打卡")
signOutReq := &entity.SignRequest{
Country: "中国",
Address: "四川省 · 成都市 · 武侯区 · 咕噜咕噜",
Province: "四川省",
City: "成都市",
State: "1",
Area: "武侯区",
Latitude: "33.100039",
Longitude: "101.987848",
Description: "下班打卡",
Type: "END",
Device: "Android",
}
err = client.Sign(ctx, signOutReq)
if err != nil {
t.Fatalf("下班打卡失败: %v", err)
}
t.Log("下班打卡成功")
t.Log("完整流程测试通过!")
}
+68
View File
@@ -0,0 +1,68 @@
package music
import (
"context"
"likedream-goframe/internal/application/httpclient"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/utils"
"net/url"
"regexp"
"strings"
"github.com/PuerkitoBio/goquery"
"github.com/samber/oops"
"github.com/tidwall/gjson"
)
var MusicDownloader *Downloader
type Downloader struct {
}
func (c *Downloader) Search(ctx context.Context, keyword string) (result []MusicSearchResult) {
client := httpclient.Music.Clone(ctx)
response, err := client.R().Get("https://www.gequbao.net/s/" + url.PathEscape(keyword))
if err != nil {
utils.OkOrPanic(errors.NewBusinessError(503, "搜索出错1"), oops.WithContext(ctx).Wrap)
}
defer response.Body.Close()
reader, err := goquery.NewDocumentFromReader(response.Body)
if err != nil {
utils.OkOrPanic(errors.NewBusinessError(503, "搜索出错2"), oops.WithContext(ctx).Wrap)
}
card := reader.Find(".card")
find := card.Find(".card-text > .row:not(:first-child)")
find.Each(func(i int, item *goquery.Selection) {
selection := item.Find("a")
url, _ := selection.Attr("href")
name := strings.TrimSpace(selection.Find(".text-primary span").Text())
singer := strings.TrimSpace(selection.Find(".text-jade").Text())
result = append(result, MusicSearchResult{
Name: name,
Singer: singer,
Path: url,
})
})
return
}
func (c *Downloader) Download(ctx context.Context, url string) string {
client := httpclient.Music.Clone(ctx)
response, err := client.R().Get("https://www.gequbao.net" + url)
if err != nil {
utils.OkOrPanic(errors.NewBusinessError(503, "下载出错1"), oops.WithContext(ctx).Wrap)
}
html := response.String()
reg := regexp.MustCompile("window\\.play_id = '(.*?)';")
play_id := reg.FindStringSubmatch(html)[1]
post, err := client.R().SetBody(map[string]string{
"id": play_id,
}).Post("https://www.gequbao.net/api/play-url")
if err != nil {
utils.OkOrPanic(errors.NewBusinessError(503, "下载出错2"), oops.WithContext(ctx).Wrap)
}
download_url := gjson.Get(post.String(), "data.url").String()
// 下载音频文件
return download_url
}
+7
View File
@@ -0,0 +1,7 @@
package music
type MusicSearchResult struct {
Name string `json:"name"`
Singer string `json:"singer"`
Path string `json:"url"`
}
@@ -0,0 +1,78 @@
package cryptoutil
import (
"crypto/aes"
"crypto/cipher"
"encoding/base64"
)
const (
scAESKey = "m1xp9slgfa|su@m2"
scAESIV = "pa!st17m@04h5u#0"
)
// EncryptSC AES-CBC加密,使用固定的key和iv
func EncryptSC(plaintext string) string {
key := []byte(scAESKey)
iv := []byte(scAESIV)
block, err := aes.NewCipher(key)
if err != nil {
return ""
}
// PKCS7填充
blockSize := block.BlockSize()
padding := blockSize - len(plaintext)%blockSize
padText := make([]byte, padding)
for i := range padText {
padText[i] = byte(padding)
}
plaintextBytes := append([]byte(plaintext), padText...)
// CBC加密
ciphertext := make([]byte, len(plaintextBytes))
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(ciphertext, plaintextBytes)
// Base64编码
return base64.StdEncoding.EncodeToString(ciphertext)
}
// DecryptSC AES-CBC解密,使用固定的key和iv
func DecryptSC(ciphertext string) string {
key := []byte(scAESKey)
iv := []byte(scAESIV)
// Base64解码
ciphertextBytes, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return ""
}
block, err := aes.NewCipher(key)
if err != nil {
return ""
}
// CBC解密
blockSize := block.BlockSize()
if len(ciphertextBytes)%blockSize != 0 {
return ""
}
plaintext := make([]byte, len(ciphertextBytes))
mode := cipher.NewCBCDecrypter(block, iv)
mode.CryptBlocks(plaintext, ciphertextBytes)
// 去除PKCS7填充
if len(plaintext) == 0 {
return ""
}
padding := int(plaintext[len(plaintext)-1])
if padding <= 0 || padding > blockSize || padding > len(plaintext) {
return ""
}
return string(plaintext[:len(plaintext)-padding])
}
@@ -0,0 +1,322 @@
package cryptoutil
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"math/big"
"net/url"
"strings"
)
// EncData 实现与JavaScript版本相同的加密功能
// clientToken参数用于替代JavaScript中的cookie值
func EncData(data interface{}, clientToken string) map[string]string {
// 检查数据是否为空
if data == nil || data == "" || data == "{}" || data == " " || data == "null" {
return map[string]string{
"ciphertext": "",
}
}
// 获取axes_max
axesMax := len(clientToken)
if axesMax < 4 {
axesMax = 4
}
// 创建axes
axes := creatAxes(axesMax)
// 替换逗号为连字符
aesaxes := strings.ReplaceAll(axes, ",", "-")
// 生成AES密钥
aesKey := encryptKey(axes, clientToken)
// 将数据转换为JSON字符串
var dataStr string
switch v := data.(type) {
case string:
dataStr = v
default:
jsonBytes, err := json.Marshal(data)
if err != nil {
return map[string]string{
"ciphertext": "",
}
}
dataStr = string(jsonBytes)
}
// 加密数据
encdata := Encrypt(dataStr, aesKey)
// 构造密文
ciphertext := encdata + "," + aesaxes
return map[string]string{
"ciphertext": ciphertext,
}
}
// DescData 实现与JavaScript版本相同的解密功能
// clientToken参数用于替代JavaScript中的cookie值
func DescData(JR map[string]interface{}, clientToken string) map[string]interface{} {
result := map[string]interface{}{
"flag": false,
"data": "",
}
if JR == nil {
return result
}
// 获取flag
flag, ok := JR["flag"].(bool)
if !ok {
flag = false
}
result["flag"] = flag
// 如果flag为false,直接返回data
if !flag {
if data, ok := JR["data"]; ok {
result["data"] = data
}
return result
}
// 检查key是否存在
key, keyExists := JR["key"].(string)
if !keyExists || key == "" {
if data, ok := JR["data"]; ok {
result["data"] = data
}
return result
}
// 处理解密
axes := key
aesaxes := strings.ReplaceAll(axes, "-", ",")
aesKey := encryptKey(aesaxes, clientToken)
// 获取加密数据
outdata, ok := JR["data"].(string)
if !ok {
result["data"] = ""
return result
}
// URL解码(与JS版本的decodeURIComponent一致)
decodedData, err := url.QueryUnescape(outdata)
if err != nil {
// 如果URL解码失败,使用原始数据
decodedData = outdata
}
// 解密数据
decryptedData := Decrypt(decodedData, aesKey)
result["data"] = decryptedData
return result
}
// encryptKey 根据axes生成加密密钥
func encryptKey(axes string, clientToken string) string {
aaxes := strings.Split(axes, ",")
key := ""
for _, axis := range aaxes {
axisInt := parseInt(axis)
// 确保axisInt不会导致越界
if axisInt >= 0 && axisInt < len(clientToken) {
// 与JavaScript版本一致:substring(aaxes[i], aaxes[i]*1+4)
end := axisInt + 4
if end <= len(clientToken) {
key += clientToken[axisInt:end]
} else {
// 如果end超出范围,只取到末尾
key += clientToken[axisInt:]
// 填充到4个字符
for len(key)%4 != 0 {
key += "A"
}
}
} else {
// 如果起始位置越界,使用默认值
key += "abcd"
}
}
// 确保密钥长度至少为16字节,如果超过则截断
if len(key) < 16 {
// 用'A'填充到16字节
for len(key) < 16 {
key += "A"
}
} else if len(key) > 16 {
// 截断到16字节
key = key[:16]
}
return key
}
// creatAxes 创建随机axes
func creatAxes(max int) string {
if max < 4 {
max = 4
}
axes := ""
for i := 0; i < 4; i++ {
// 生成0到max-4之间的随机数
n, err := rand.Int(rand.Reader, big.NewInt(int64(max-4)))
if err != nil {
// 出错时使用默认值
axes += "0,"
continue
}
axes += fmt.Sprintf("%d,", n.Int64())
}
// 移除末尾的逗号
if len(axes) > 0 {
axes = axes[:len(axes)-1]
}
return axes
}
// parseInt 简单的字符串转整数函数
func parseInt(s string) int {
var result int
fmt.Sscanf(s, "%d", &result)
return result
}
// Encrypt AES加密函数
func Encrypt(data, key string) string {
// 确保密钥长度为16字节
keyBytes := make([]byte, 16)
if len(key) >= 16 {
copy(keyBytes, []byte(key)[:16])
} else {
copy(keyBytes, []byte(key))
// 如果密钥不足16字节,用'A'填充
for i := len(key); i < 16; i++ {
keyBytes[i] = 'A'
}
}
// 创建AES密码块
block, err := aes.NewCipher(keyBytes)
if err != nil {
return ""
}
// PKCS7填充
blockSize := block.BlockSize()
padding := blockSize - len(data)%blockSize
padText := make([]byte, padding)
for i := range padText {
padText[i] = byte(padding)
}
plaintext := append([]byte(data), padText...)
// CBC模式加密
ciphertext := make([]byte, len(plaintext))
// 使用固定的IV(与JavaScript版本一致,使用相同的key作为IV)
iv := make([]byte, blockSize)
copy(iv, keyBytes[:blockSize])
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(ciphertext, plaintext)
// Base64编码(使用URL编码兼容CryptoJS)
return base64.StdEncoding.EncodeToString(ciphertext)
}
// Decrypt AES解密函数
func Decrypt(data, key string) string {
// Base64解码
ciphertext, err := base64.StdEncoding.DecodeString(data)
if err != nil {
// 尝试URL解码
ciphertext, err = base64.URLEncoding.DecodeString(data)
if err != nil {
return ""
}
}
// 确保密钥长度为16字节
keyBytes := make([]byte, 16)
if len(key) >= 16 {
copy(keyBytes, []byte(key)[:16])
} else {
copy(keyBytes, []byte(key))
// 如果密钥不足16字节,用'A'填充
for i := len(key); i < 16; i++ {
keyBytes[i] = 'A'
}
}
// 创建AES密码块
block, err := aes.NewCipher(keyBytes)
if err != nil {
return ""
}
// CBC模式解密
blockSize := block.BlockSize()
if len(ciphertext)%blockSize != 0 {
return ""
}
// 使用固定的IV(与JavaScript版本一致,使用相同的key作为IV)
iv := make([]byte, blockSize)
copy(iv, keyBytes[:blockSize])
mode := cipher.NewCBCDecrypter(block, iv)
plaintext := make([]byte, len(ciphertext))
mode.CryptBlocks(plaintext, ciphertext)
// 去除PKCS7填充
if len(plaintext) == 0 {
return ""
}
padding := int(plaintext[len(plaintext)-1])
// 更宽松的填充检查
if padding <= 0 || padding > blockSize || padding > len(plaintext) {
// 如果填充不合法,返回原始数据
return string(plaintext)
}
// 验证填充是否正确(更宽松的检查)
validPadding := true
for i := len(plaintext) - padding; i < len(plaintext); i++ {
if plaintext[i] != byte(padding) {
validPadding = false
break
}
}
if !validPadding {
// 填充验证失败,返回尽可能多的有效数据
return string(plaintext)
}
// 正常去除填充
if len(plaintext) <= padding {
return ""
}
return string(plaintext[:len(plaintext)-padding])
}
@@ -0,0 +1,162 @@
var SAFETOCKEN="Safe-Tocken";//for RSA key cookie
var encryptAxes = function(axes){
RSAUtils.setMaxDigits(1024);
var safeTocken = getCookie(SAFETOCKEN);
if(safeTocken=="" || safeTocken ==''){
throw new Error("Cannot read Cookie '"+SAFETOCKEN+"' values,System need run RSAData() again!");
return ;
}
var safe_tocken = safeTocken.split(",");
var pubexp = safe_tocken[1];
var priexp = safe_tocken[0];
var modulus = safe_tocken[2];
var key = RSAUtils.getKeyPair(safe_tocken[1], safe_tocken[0], safe_tocken[2]);
axes = axes.split("").reverse().join("");
return RSAUtils.encryptedString(key,axes);
}
var axesMax = function(){
var client_tocken = getCookie(CLIENTTOCKEN);
if(client_tocken=="" || client_tocken==''){
throw new Error("Cannot read Cookie '"+CLIENTTOCKEN+"' values,System need run RSAData() again!");
return ;
}
return client_tocken.length;
}
/**
* decrypt axes use ras key form http back
*/
var decryptAxes = function(rsakey){
RSAUtils.setMaxDigits(1024);
var safeTocken = getCookie(SAFETOCKEN);
if(safeTocken=="" || safeTocken ==''){
throw new Error("Cannot read Cookie '"+SAFETOCKEN+"' values,System need run RSAData() again!");
return ;
}
var safe_tocken = safeTocken.split(",");
var pubexp = safe_tocken[1];
var priexp = safe_tocken[0];
var modulus = safe_tocken[2];
var key = RSAUtils.getKeyPair(safe_tocken[1], safe_tocken[0], safe_tocken[2]);
return RSAUtils.decryptedString(key,rsakey);
}
var CLIENTTOCKEN = "Client-Tocken";
/**
* Get String key as axes
*/
var encryptKey = function(axes){
//1.get client-tocken
//2. realse this axes get this encryptKey
var key="";
var client_tocken = getCookie(CLIENTTOCKEN);
if(client_tocken=="" || client_tocken==''){
throw new Error("Cannot read Cookie '"+CLIENTTOCKEN+"' values,System need run RSAData() again!");
return ;
}
var aaxes = axes.split(",");
for(var i=0;i<aaxes.length;i++){
key += client_tocken.substring(aaxes[i],aaxes[i]*1+4);
}
return key;
}
var getCookie = function(name){
var arr,reg=new RegExp("(^| )"+name+"=([^;]*)(;|$)");
if(arr=document.cookie.match(reg))
return decodeURIComponent(arr[2]);
else
return "";
}
/**
* get a random axes
*/
var creatAxes = function(max){
var axes = "";
for(var i=0;i<4;i++ ){
axes += parseInt(Math.random()*(max-4),10)+",";
}
axes = axes.substring(0,axes.length-1);
return axes;
}
/**
* just encdata for Ajax
*/
var EncData = function(data){
if(data=="" || data =="{}" || data==" " || data==null || data=="null"){
ciphertext="";
}else{//存在数据
//获取aeskey
var axes_max = axesMax();
var axes = creatAxes(axes_max);
var reg=new RegExp(",","g");
var aesaxes = axes.replace(reg,'-');
var aesKey = encryptKey(axes);
data = JSON.stringify(data);
encdata = Encrypt(data,aesKey);
ciphertext = encdata+","+aesaxes;
}
return {'ciphertext':ciphertext};
}
/**
* get data from back http
*/
var DescData = function(JR){
var _data="";
if(JR){//data is not empty
var flag = JR.flag;
if(flag){//成功
if(JR.key=="" || JR.key==''){
_data = JR.data;
}else{//解密数据
var axes = JR.key;
var reg=new RegExp("-","g");
var aesaxes = axes.replace(reg,',');
var aesKey = encryptKey(aesaxes);
var outdata=decodeURIComponent(JR.data);
_data = Decrypt(outdata,aesKey);
}
}else{
_data = JR.data;
}
}
return {'flag':JR.flag,'data':_data};
}
/**
* post data use as AJAX
*/
var SubData = function(data,URL){
var result="";
var ciphertext = EncData(data);
$.ajax({
type: "POST",
dataType: "json",
cache : false,
async:false,
url: URL,//传入后台的地址/方法
data: ciphertext,//参数,这里是一个json语句
success: function (RS) {
result = DescData(RS);
},
error: function (err) {
throw new Error("Post data was Error!");
return ;
},
fail:function(){
throw new Error("Post data was fail,website need NetWork!");
return ;
}
});
return result;
}
var Encrypt = function(data,key){
var aeskey = CryptoJS.enc.Utf8.parse(key);
var aesiv = CryptoJS.enc.Utf8.parse(key);
var srcs = CryptoJS.enc.Utf8.parse(data);
return CryptoJS.AES.encrypt(srcs, aeskey, { iv: aesiv,mode:CryptoJS.mode.CBC}).toString();
}
function Decrypt(word,key){
var aeskey = CryptoJS.enc.Utf8.parse(key);
var aesiv = CryptoJS.enc.Utf8.parse(key);
var decrypt = CryptoJS.AES.decrypt(word, aeskey, { iv: aesiv,mode:CryptoJS.mode.CBC});
var decryptedStr = decrypt.toString(CryptoJS.enc.Utf8);
return decryptedStr.toString();
}
@@ -0,0 +1,228 @@
package cryptoutil
import (
"fmt"
"strings"
"testing"
)
func TestEncdata(t *testing.T) {
// 使用与JavaScript版本相同的clientToken
clientToken := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
data := map[string]interface{}{
"username": "admin",
"password": "123456",
}
encData := EncData(data, clientToken)
fmt.Println("EncData result:", encData)
// 创建一个模拟的JR对象来测试DescData
jr := map[string]interface{}{
"flag": true,
"key": getKeyFromCiphertext(encData["ciphertext"]),
"data": getDataFromCiphertext(encData["ciphertext"]),
}
descResult := DescData(jr, clientToken)
fmt.Println("DescData result:", descResult)
}
func TestDecdata(t *testing.T) {
// 使用与JavaScript版本相同的clientToken
clientToken := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
data := map[string]interface{}{
"flag": true,
"data": "YN4tHIF4S3OQgfhTICpFqR425z2sHwCEmlsGdVYt5m6ssyIagtqEvf0hEXdzhc0u",
"key": "120-54-61-117",
}
// 调试信息
fmt.Println("Input data:", data)
// 手动测试密钥生成
axes := "120-54-61-117"
aesaxes := strings.ReplaceAll(axes, "-", ",")
aesKey := encryptKey(aesaxes, clientToken)
fmt.Println("Generated key:", aesKey)
fmt.Println("Key length:", len(aesKey))
// 直接测试解密
encryptedData := "YN4tHIF4S3OQgfhTICpFqR425z2sHwCEmlsGdVYt5m6ssyIagtqEvf0hEXdzhc0u"
decrypted := Decrypt(encryptedData, aesKey)
fmt.Println("Direct decrypt result:", decrypted)
fmt.Printf("Direct decrypt result (bytes): %v\n", []byte(decrypted))
result := DescData(data, clientToken)
fmt.Println("TestDecdata result:", result)
// 检查结果
if result["flag"] != true {
t.Errorf("Expected flag to be true, got %v", result["flag"])
}
}
// getKeyFromCiphertext 从ciphertext中提取key部分
func getKeyFromCiphertext(ciphertext string) string {
parts := strings.Split(ciphertext, ",")
if len(parts) > 1 {
return parts[1]
}
return ""
}
// getDataFromCiphertext 从ciphertext中提取data部分
func getDataFromCiphertext(ciphertext string) string {
parts := strings.Split(ciphertext, ",")
if len(parts) > 0 {
return parts[0]
}
return ""
}
// 添加一个简单的加密/解密测试
func TestEncryptDecrypt(t *testing.T) {
originalData := `{"username":"admin","password":"123456"}`
key := "abcdabcdabcdabcd" // 16字节密钥
// 加密
encrypted := Encrypt(originalData, key)
fmt.Println("Encrypted:", encrypted)
// 解密
decrypted := Decrypt(encrypted, key)
fmt.Println("Decrypted:", decrypted)
if decrypted != originalData {
t.Errorf("Decrypt failed. Expected: %s, Got: %s", originalData, decrypted)
}
}
// 测试特定的axes生成密钥
func TestEncryptKey(t *testing.T) {
// 使用与JavaScript版本相同的clientToken
clientToken := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
// 测试提供的axes
axes := "120,54,61,117"
key := encryptKey(axes, clientToken)
fmt.Println("Test axes:", axes)
fmt.Println("Generated key:", key)
fmt.Println("Key length:", len(key))
// 验证每个部分
fmt.Println("Client token length:", len(clientToken))
// 分别计算每个部分
parts := strings.Split(axes, ",")
for i, part := range parts {
axisInt := parseInt(part)
fmt.Printf("Part %d: %s -> %d\n", i, part, axisInt)
if axisInt >= 0 && axisInt < len(clientToken) {
end := axisInt + 4
if end <= len(clientToken) {
fmt.Printf(" Extracted: %s\n", clientToken[axisInt:end])
} else {
fmt.Printf(" Extracted: %s (partial)\n", clientToken[axisInt:])
}
} else {
fmt.Printf(" Out of range, using default\n")
}
}
}
// 测试具体案例
func TestSpecificCase(t *testing.T) {
// 使用与JavaScript版本相同的clientToken
clientToken := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
// 使用你提供的具体数据
axes := "120,54,61,117"
key := encryptKey(axes, clientToken)
fmt.Println("=== Specific Case Test ===")
fmt.Printf("Axes: %s\n", axes)
fmt.Printf("Generated Key: %s\n", key)
fmt.Printf("Key Length: %d\n", len(key))
// 测试加密
testData := "test data"
encrypted := Encrypt(testData, key)
fmt.Printf("Encrypted: %s\n", encrypted)
// 测试解密
decrypted := Decrypt(encrypted, key)
fmt.Printf("Decrypted: %s\n", decrypted)
if testData != decrypted {
fmt.Println("WARNING: Encryption/Decryption mismatch!")
} else {
fmt.Println("SUCCESS: Encryption/Decryption works correctly!")
}
}
// 测试你提供的实际加密数据
func TestActualEncryptedData(t *testing.T) {
// 使用与JavaScript版本相同的clientToken
clientToken := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
// 你提供的实际加密数据
encryptedData := "YN4tHIF4S3OQgfhTICpFqR425z2sHwCEmlsGdVYt5m6ssyIagtqEvf0hEXdzhc0u"
// 根据axes "120,54,61,117" 生成密钥
axes := "120,54,61,117"
key := encryptKey(axes, clientToken)
fmt.Println("=== Actual Encrypted Data Test ===")
fmt.Printf("Encrypted Data: %s\n", encryptedData)
fmt.Printf("Key: %s\n", key)
// 尝试解密
decrypted := Decrypt(encryptedData, key)
fmt.Printf("Decrypted Result: '%s'\n", decrypted)
fmt.Printf("Decrypted Length: %d\n", len(decrypted))
fmt.Printf("Decrypted Bytes: %v\n", []byte(decrypted))
// 检查是否是有效的JSON
if len(decrypted) > 0 {
fmt.Println("Possible JSON data detected")
} else {
fmt.Println("No data returned from decryption")
}
}
// 最终验证测试
func TestFinalVerification(t *testing.T) {
// 使用与JavaScript版本相同的clientToken
clientToken := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
fmt.Println("=== Final Verification Test ===")
// 使用你提供的axes生成密钥
axes := "120,54,61,117"
key := encryptKey(axes, clientToken)
fmt.Printf("Generated key for axes '%s': '%s'\n", axes, key)
// 你提供的加密数据
encryptedData := "YN4tHIF4S3OQgfhTICpFqR425z2sHwCEmlsGdVYt5m6ssyIagtqEvf0hEXdzhc0u"
// 尝试解密
decrypted := Decrypt(encryptedData, key)
fmt.Printf("Decrypted result: '%s'\n", decrypted)
// 如果解密结果为空,尝试不同的方法
if len(decrypted) == 0 {
fmt.Println("Trying alternative decryption approaches...")
// 尝试不同的填充处理
fmt.Println("Testing raw decryption without padding removal...")
// 这里我们可以添加更多的调试信息
// 输出一些调试信息
fmt.Printf("Key bytes: %v\n", []byte(key))
fmt.Printf("Encrypted data length: %d\n", len(encryptedData))
}
}
@@ -0,0 +1,32 @@
package model
type Course struct {
Id string `json:"id"`
CourseName string `json:"courseName"`
Image string `json:"image"`
}
type Chapter struct {
Id string `json:"id"`
ChapterName string `json:"chapterName"`
SubSection []SubSection `json:"studySubsections"`
}
type SubSection struct {
Id string `json:"id"`
SubsectionName string `json:"subsectionName"`
VideoTime string `json:"videoTime"`
StudyFiles StudyFile `json:"studyFiles"`
}
type StudyFile struct {
Id string `json:"id"`
FileName string `json:"fileName"`
FileSize string `json:"fileSize"`
FileTime string `json:"fileTime"`
FileType string `json:"fileType"`
FilePath string `json:"filePath"`
VideoTime string `json:"videoTime"`
StudyTime string `json:"studyTime"`
State string `json:"state"` // 2是学习中 1是未开始 3是已完成
}
+185
View File
@@ -0,0 +1,185 @@
package sc
import (
"context"
"likedream-goframe/internal/application/smartedu/model"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/framework/logger"
"likedream-goframe/internal/utils"
"regexp"
"strconv"
"strings"
"github.com/samber/oops"
"github.com/tidwall/gjson"
"go.uber.org/zap"
"resty.dev/v3"
)
const (
HomeUrl = "https://basic.sc.smartedu.cn/hd/teacherTraining/home"
CourseDetailUrl = "https://basic.sc.smartedu.cn/hd/teacherTraining/api/studyCourse/getCourseDetails"
RecordProcessUrl = "https://basic.sc.smartedu.cn/hd/teacherTraining/api/studyCourseUser/recordProcess"
RefresProcessUrl = "https://basic.sc.smartedu.cn/hd/teacherTraining/api/studyCourseUser/refreshProcess"
ApiCourseFinishedHour = "https://basic.sc.smartedu.cn/hd/teacherTraining/api/studyCourse/getCourseFinishedHour"
)
type Course struct {
Client *resty.Client
Token string
}
func NewCourse(token string, client *resty.Client) *Course {
return &Course{
Token: token,
Client: client,
}
}
func (c *Course) GetCourseList(ctx context.Context) []model.Course {
jsUrl := c.GetJsUrl(ctx, HomeUrl)
get, err := c.Client.R().Get(jsUrl)
utils.OkOrPanic(err, oops.WithContext(ctx).With("jsUrl", jsUrl).Wrap)
jsonString := ParseJsonString(get.String())
returnData := gjson.Get(jsonString, "returnData").Array()
var courseList []model.Course
for _, one := range returnData {
courseList = append(courseList, model.Course{
Id: one.Get("id").String(),
CourseName: one.Get("courseName").String(),
Image: one.Get("studyFiles.filePath").String(),
})
}
return courseList
}
func (c *Course) GetChapterList(ctx context.Context, courseId string) []model.Chapter {
get, err := c.Client.R().SetQueryParam("courseId", courseId).SetHeader("X-Token", c.Token).Get(CourseDetailUrl)
utils.OkOrPanic(err, oops.WithContext(ctx).With("GetChapterList", courseId).Wrap)
chapterJson := gjson.Get(get.String(), "returnData.chapters").Array()
var chapterList []model.Chapter
for _, ch := range chapterJson {
chapter := model.Chapter{
Id: ch.Get("id").String(),
ChapterName: ch.Get("chapterName").String(),
}
subSectionJson := ch.Get("studySubsections").Array()
var subSectionList []model.SubSection
for _, sec := range subSectionJson {
studyFileJson := sec.Get("studyFiles")
studyFile := model.StudyFile{
Id: studyFileJson.Get("id").String(),
FileName: studyFileJson.Get("fileName").String(),
FileSize: studyFileJson.Get("fileSize").String(),
FileTime: studyFileJson.Get("fileTime").String(),
FileType: studyFileJson.Get("fileType").String(),
FilePath: studyFileJson.Get("filePath").String(),
VideoTime: studyFileJson.Get("videoTime").String(),
State: studyFileJson.Get("state").String(),
StudyTime: studyFileJson.Get("studyTime").String(),
}
subSection := model.SubSection{
Id: sec.Get("id").String(),
SubsectionName: sec.Get("subsectionName").String(),
VideoTime: sec.Get("videoTime").String(),
StudyFiles: studyFile,
}
subSectionList = append(subSectionList, subSection)
}
chapter.SubSection = subSectionList
chapterList = append(chapterList, chapter)
}
return chapterList
}
func ParseJsonString(str string) string {
// eval("module.exports = /*#__PURE__*/JSON.parse('{...}');\n\n//# sourceURL=...
re := regexp.MustCompile(`#__PURE__\*/JSON\.parse\('(.+?)'\);`)
matches := re.FindStringSubmatch(str)
if len(matches) < 2 {
return ""
}
// 处理转义的引号
return strings.ReplaceAll(matches[1], `\"`, `"`)
}
func (c *Course) GetJsUrl(ctx context.Context, url string) string {
get, err := c.Client.R().Get(url)
utils.OkOrPanic(err, oops.WithContext(ctx).With("GetJsUrl", url).Wrap)
return ParseJsUrl(get.String())
}
func (c *Course) Study(ctx context.Context, c2 model.Course, ch model.Chapter, sec model.SubSection) {
// 如果已完成
if sec.StudyFiles.State == "3" {
return
}
form := map[string]any{
"chapterId": ch.Id,
"courseId": c2.Id,
"subsectionId": sec.Id,
}
if sec.StudyFiles.State == "1" {
form["state"] = "1"
c.Refresh(ctx, c2, ch, sec)
form["studyTime"] = 5
// 如果state为
post, err := c.Client.R().SetHeader("X-Token", c.Token).SetBody(form).Post(RecordProcessUrl)
utils.OkOrPanic(err, oops.WithContext(ctx).With("Study", form).Wrap)
str := post.String()
if gjson.Get(str, "returnCode").String() != "200" {
utils.OkOrPanic(errors.NewBusinessError(503, "学习失败,"+gjson.Get(str, "returnMessage").String()))
}
logger.Logger.Info("学习成功", zap.String("课程名", c2.CourseName), zap.String("章节名", ch.ChapterName), zap.String("小节名", sec.SubsectionName))
}
c.Refresh(ctx, c2, ch, sec)
atoi, _ := strconv.Atoi(sec.StudyFiles.VideoTime)
form["state"] = "3"
form["studyTime"] = atoi
// 如果state为
post, err := c.Client.R().SetHeader("X-Token", c.Token).SetBody(form).Post(RecordProcessUrl)
utils.OkOrPanic(err, oops.WithContext(ctx).With("Study", form).Wrap)
str := post.String()
if gjson.Get(str, "returnCode").String() != "200" {
utils.OkOrPanic(errors.NewBusinessError(503, "学习失败,"+gjson.Get(str, "returnMessage").String()))
}
logger.Logger.Info("学习成功", zap.String("课程名", c2.CourseName), zap.String("章节名", ch.ChapterName), zap.String("小节名", sec.SubsectionName))
}
func (c *Course) Refresh(ctx context.Context, c2 model.Course, ch model.Chapter, sec model.SubSection) {
form := map[string]any{
"chapterId": ch.Id,
"courseId": c2.Id,
"subsectionId": sec.Id,
"studyTime": 0,
}
post, err := c.Client.R().SetHeader("X-Token", c.Token).SetBody(form).Post(RefresProcessUrl)
utils.OkOrPanic(err, oops.WithContext(ctx).With("Refresh", form).Wrap)
str := post.String()
if gjson.Get(str, "returnCode").String() != "200" {
utils.OkOrPanic(errors.NewBusinessError(503, "刷新嫉妒失败,"+gjson.Get(str, "returnMessage").String()))
}
logger.Logger.Info("刷新成功", zap.String("课程名", c2.CourseName), zap.String("章节名", ch.ChapterName), zap.String("小节名", sec.SubsectionName))
}
func ParseJsUrl(str string) string {
// <script defer src="https://cdnbasic.scbdc.edu.cn/%E7%9C%81%E7%BA%A7%E8%B5%84%E6%BA%90/hd/chunk-vendors.5e79f5ce155849.js">
re := regexp.MustCompile(`https://[^"'\s]+chunk-vendors[^"'\s]+\.js`)
match := re.FindString(str)
if match == "" {
return ""
}
return strings.Replace(match, "chunk-vendors", "src_views_home_index_vue", 1)
}
func (c *Course) GetCourseProcees(ctx context.Context, courseId string) (finished string, total string) {
post, err := c.Client.R().SetHeader("X-Token", c.Token).SetBody(map[string]any{
"courseIds": []string{courseId},
}).Post(ApiCourseFinishedHour)
utils.OkOrPanic(err, oops.WithContext(ctx).With("GetCourseProcees", courseId).Wrap)
str := gjson.Get(post.String(), "returnData.finishedHours").String()
split := strings.Split(str, ",")
return split[0], split[1]
}
+95
View File
@@ -0,0 +1,95 @@
package sc
import (
"context"
"likedream-goframe/internal/application/cookiejar"
"likedream-goframe/internal/application/httpclient"
"likedream-goframe/internal/application/smartedu/cryptoutil"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/utils"
"strings"
"github.com/PuerkitoBio/goquery"
"github.com/gogf/gf/v2/container/gvar"
"github.com/samber/oops"
"resty.dev/v3"
)
const (
LoginPageUrl = "https://basic.sc.smartedu.cn/ThirdPortalService/user/otherlogin!login.ac?appkey=C56DA16ECBC56FBEEC908DA09E45C72C917A80118F057FA1F0B5BAE41CC9CC9DECD5BDB7133FE17C328C5D37B37CA8E7&pkey=5D79CA42E45C5273DF8532D09E1F158B15E25919CDB958940F84D5E63F5F53A1ECD5BDB7133FE17C328C5D37B37CA8E7&params=718F83A5347CBFDB7D1A9065FA090FE949D92330BB9A3351FE0715C5B8A3E86F37916C1004E835C7C7F964E3F301477F7D37F04485FA8707845DAAA23356236ED1D326CF5A5E3C263470516EE9B4A2ED"
LoginActionUrl = "https://basic.sc.smartedu.cn/ThirdPortalService/login!login.ac"
LoginTicketUrl = "https://basic.sc.smartedu.cn/ThirdPortalService/login!getTicket.ac"
LoginOOSUrl = "https://basic.sc.smartedu.cn/hd/teacherTraining/api/sso/oauth/token"
)
type Login struct {
Username string `json:"username"`
Password string `json:"password"`
Client *resty.Client
}
func NewLogin(username, password string) *Login {
return &Login{
Username: username,
Password: password,
Client: httpclient.NewSmartEdu(),
}
}
func (l *Login) GetCookieValue(key string) string {
cookies := l.Client.CookieJar().(*cookiejar.AllSavePersistCookieJar)
for _, cookie := range cookies.Cookies(nil) {
if cookie.Name == key {
return cookie.Value
}
}
return ""
}
func (l *Login) GetClient() *resty.Client {
return l.Client
}
func (l *Login) Login(ctx context.Context) string {
form := l.getLoginInput(ctx)
form["loginName"] = gvar.New(l.Username)
form["password"] = gvar.New(l.Password)
token := l.GetCookieValue("Client-Tocken")
data := cryptoutil.EncData(form, token)
post, err := l.Client.R().SetFormData(data).Post(LoginActionUrl)
utils.OkOrPanic(err, oops.WithContext(ctx).With("LoginActionUrl", LoginActionUrl).Wrap)
descData := cryptoutil.DescData(utils.ReadJson[map[string]interface{}](post.Body), token)
result := descData["data"].(string)
if !strings.Contains(result, `"Flag":0`) {
utils.OkOrPanic(errors.NewBusinessError(503, "登录失败"))
}
get, err := l.Client.R().Get(LoginTicketUrl)
utils.OkOrPanic(err, oops.WithContext(ctx).With("LoginTicketUrl", LoginTicketUrl).Wrap)
return get.String()
}
func (l *Login) OOSLogin(ctx context.Context, ticket string) string {
post, err := l.Client.R().SetQueryParam("ticket", ticket).Post(LoginOOSUrl)
utils.OkOrPanic(err, oops.WithContext(ctx).With("LoginOOSUrl", LoginOOSUrl).Wrap)
defer post.Body.Close()
json := utils.ReadJson[map[string]string](post.Body)
if json["returnCode"] != "200" {
utils.OkOrPanic(errors.NewBusinessError(503, json["returnMessage"]))
}
return json["returnData"]
}
func (l *Login) getLoginInput(ctx context.Context) map[string]*gvar.Var {
get, err := l.Client.R().WithContext(ctx).Get(LoginPageUrl)
utils.OkOrPanic(err, oops.WithContext(ctx).With("LoginPageUrl", LoginPageUrl).Wrap)
defer get.Body.Close()
reader, err := goquery.NewDocumentFromReader(get.Body)
utils.OkOrPanic(err, oops.WithContext(ctx).With("LoginPageUrl", LoginPageUrl).Wrap)
find := reader.Find("#loginContent input")
var inputMap = make(map[string]*gvar.Var)
find.Each(func(i int, input *goquery.Selection) {
key, exists := input.Attr("name")
if exists {
val, _ := input.Attr("value")
inputMap[key] = gvar.New(val)
}
})
return inputMap
}
@@ -0,0 +1,43 @@
package sc
import (
"context"
"testing"
)
func TestLogin(t *testing.T) {
login := NewLogin("13551503012", "XIAOzhi520")
login.Login(context.Background())
login.GetClient()
}
func TestStudy(t *testing.T) {
login := NewLogin("13551503012", "XIAOzhi520")
//ticket := login.Login(context.Background())
//token := login.OOSLogin(context.Background(), ticket)
client := login.GetClient()
course := NewCourse("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJkZXZpY2VUeXBlIjoid2ViIiwibG9naW5UeXBlIjoidXNlcnJlYWxtIiwiZXhwIjoxNzY1MjU3NDI1LCJ1c2VySWQiOiIxOTk4MDI1NjQ1NzU5ODM2MTYwIn0.PL5Roy0t9v0mK-ucK73IZg2qTZfw0YhW1sVxG1SYetU", client)
courseList := course.GetCourseList(context.Background())
for _, c := range courseList {
chapterList := course.GetChapterList(context.Background(), c.Id)
for _, ch := range chapterList {
for _, sec := range ch.SubSection {
course.Study(context.Background(), c, ch, sec)
}
}
}
}
func TestGetProcess(t *testing.T) {
login := NewLogin("13551503012", "XIAOzhi520")
//ticket := login.Login(context.Background())
//token := login.OOSLogin(context.Background(), ticket)
client := login.GetClient()
course := NewCourse("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJkZXZpY2VUeXBlIjoid2ViIiwibG9naW5UeXBlIjoidXNlcnJlYWxtIiwiZXhwIjoxNzY1MjU3NDI1LCJ1c2VySWQiOiIxOTk4MDI1NjQ1NzU5ODM2MTYwIn0.PL5Roy0t9v0mK-ucK73IZg2qTZfw0YhW1sVxG1SYetU", client)
courseList := course.GetCourseList(context.Background())
for _, c := range courseList {
finished, total := course.GetCourseProcees(context.Background(), c.Id)
t.Log(finished, total)
}
}
@@ -0,0 +1,37 @@
package cryptoutil
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"likedream-goframe/internal/framework/logger"
"go.uber.org/zap"
)
const (
KEY = "u2oh6Vu^HWe4_AES"
)
func LoginEncrypt(str string) (string, error) {
key := []byte(KEY)
block, err := aes.NewCipher(key)
if err != nil {
logger.Logger.Error("aes.NewCipher err:", zap.Error(err))
return "", err
}
paddedData := pad([]byte(str), block.BlockSize())
ciphertext := make([]byte, len(paddedData))
mode := cipher.NewCBCEncrypter(block, key)
mode.CryptBlocks(ciphertext, paddedData)
data := base64.StdEncoding.EncodeToString(ciphertext)
return data, nil
}
// pad 确保数据长度是块大小的整数倍,以便符合块加密算法的要求
func pad(src []byte, blockSize int) []byte {
padding := blockSize - len(src)%blockSize
padText := bytes.Repeat([]byte{byte(padding)}, padding)
return append(src, padText...)
}
@@ -0,0 +1,147 @@
package entity
// XueXiTCourse 课程所有信息
type XueXiTCourseJson struct {
Result int `json:"result"`
Msg string `json:"msg"`
ChannelList []ChannelItem `json:"channelList"`
Mcode string `json:"mcode"`
Createcourse int `json:"createcoursed"`
TeacherEndCourse int `json:"teacherEndCourse"`
ShowEndCourse int `json:"showEndCourse"`
HasMore bool `json:"hasMore"`
StuEndCourse int `json:"stuEndCourse"`
}
// ChannelItem 课程列表
type ChannelItem struct {
Cfid int `json:"cfid"`
Norder int `json:"norder"`
CataName string `json:"cataName"`
Cataid string `json:"cataid"`
Id int `json:"id"`
Cpi int `json:"cpi"`
Key any `json:"key"`
Content struct {
Studentcount int `json:"studentcount"`
Chatid string `json:"chatid"`
IsFiled int `json:"isFiled"`
Isthirdaq int `json:"isthirdaq"`
Isstart bool `json:"isstart"`
Isretire int `json:"isretire"`
Name string `json:"name"`
Course struct {
Data []struct {
BelongSchoolId string `json:"belongSchoolId"`
Coursestate int `json:"coursestate"`
Teacherfactor string `json:"teacherfactor"`
IsCourseSquare int `json:"isCourseSquare"`
Schools string `json:"schools"`
CourseSquareUrl string `json:"courseSquareUrl"`
Imageurl string `json:"imageurl"`
AppInfo string `json:"appInfo"`
Name string `json:"name"`
DefaultShowCatalog int `json:"defaultShowCatalog"`
Id int `json:"id"`
AppData int `json:"appData"`
} `json:"data"`
} `json:"course"`
Roletype int `json:"roletype"`
Id int `json:"id"`
State int `json:"state"`
Cpi int `json:"cpi"`
Bbsid string `json:"bbsid"`
IsSquare int `json:"isSquare"`
} `json:"content"`
Topsign int `json:"topsign"`
}
type XueXiTCourse struct {
Cpi int `json:"cpi"` // 用户唯一标识
Key string `json:"key"` // classID 在课程API中为key
CourseID string `json:"courseId"` // 课程ID
ChatID string `json:"chatId"`
CourseTeacher string `json:"courseTeacher"` // 课程老师
CourseName string `json:"courseName"` // 课程名
CourseImage string `json:"courseImage"`
// 两个标识 暂时不知道有什么用
CourseDataID int `json:"courseDataId"`
ContentID int `json:"ContentID"`
IsStart bool `json:"isstart"` // 是否开课了,开课了为true,没开课为false
State int `json:"state"` // 课程状态,0为正常,1为课程已经结束
}
// XueXiTCourseChapter 课程章节信息
type XueXiTCourseChapter struct {
ID int `json:"id"` // 章节ID
Name string `json:"name"` // 章节名称
ParentNodeID int `json:"parentnodeid"` // 父节点ID
Layer int `json:"layer"` // 层级
Label string `json:"label"` // 标签
IndexOrder int `json:"indexOrder"` // 排序索引
JobCount int `json:"jobcount"` // 任务点数量
Status string `json:"status"` // 状态
IsReview int `json:"isReview"` // 是否复习
BeginTime int64 `json:"begintime"` // 开始时间
EndTime int64 `json:"endtime"` // 结束时间
PointTotal int `json:"pointTotal"` // 总任务点
PointFinish int `json:"pointFinish"` // 已完成任务点
Knowledge []XueXiTCourseChapter `json:"knowledge"` // 子章节列表
}
// ChapterData 章节数据响应
type ChapterData struct {
Data []struct {
Hideclazz int `json:"hideclazz"`
Allowdownload int `json:"allowdownload"`
Chatid string `json:"chatid"`
Isfiled int `json:"isfiled"`
Forbidintoclazz int `json:"forbidintoclazz"`
Coursesetting struct {
Data []struct {
Coursefacecheck int `json:"coursefacecheck"`
Hiddencoursecover int `json:"hiddencoursecover"`
ID int `json:"id"`
Courseid int `json:"courseid"`
} `json:"data"`
} `json:"coursesetting"`
Isstart bool `json:"isstart"`
Visiblescore int `json:"visiblescore"`
Name string `json:"name"`
Course struct {
Data []struct {
App int `json:"app"`
Teacherfactor string `json:"teacherfactor"`
Jobcount int `json:"jobcount"`
Infocontent string `json:"infocontent"`
Bulletformat string `json:"bulletformat"`
Mappingcourseid int `json:"mappingcourseid"`
Imageurl string `json:"imageurl"`
Name string `json:"name"`
ID int `json:"id"`
Belongschoolid string `json:"belongschoolid"`
Objectid string `json:"objectid"`
Knowledge struct {
Data []KnowledgeData `json:"data"`
} `json:"knowledge"`
} `json:"data"`
} `json:"course"`
ID int `json:"id"`
State int `json:"state"`
Bbsid string `json:"bbsid"`
Classscore int `json:"classscore"`
} `json:"data"`
}
// KnowledgeData 章节知识点数据
type KnowledgeData struct {
Jobcount int `json:"jobcount"`
Isreview int `json:"isreview"`
Indexorder int `json:"indexorder"`
Name string `json:"name"`
ID int `json:"id"`
Label string `json:"label"`
Layer int `json:"layer"`
Parentnodeid int `json:"parentnodeid"`
Status string `json:"status"`
}
@@ -0,0 +1,292 @@
package entity
// PointType 任务点类型
type PointType string
const (
PointTypeVideo PointType = "insertvideo"
PointTypeDocument PointType = "insertdoc"
PointTypeDocument2 PointType = "document"
PointTypeBook PointType = "insertbook"
PointTypeWork PointType = "insertwork"
PointTypeHyperlink PointType = "insertlink"
PointTypeLive PointType = "insertlive"
)
// PointVideoDto 视频任务点
type PointVideoDto struct {
CardIndex int
CourseID string
ClassID string
KnowledgeID int
Cpi string
ObjectID string
// 从SSR视图中获取
IsPassed bool
FID int
DToken string
PlayTime int
Duration int
JobID string
OtherInfo string
Title string
RT float64
VideoFaceCaptureEnc string
RandomCaptureTime string
AttDurationEnc string
Enc string
Mid string
}
// PointDocumentDto 文档查看任务点
type PointDocumentDto struct {
CardIndex int
CourseID string
ClassID string
KnowledgeID int
Cpi string
ObjectID string
Title string
JobID string
Jtoken string
IsJob bool // 是否是任务点
Type string // document / insertbook
}
// PointWorkDto 测验/作业任务点
type PointWorkDto struct {
CardIndex int
CourseID string
ClassID string
KnowledgeID int
Cpi string
WorkID string
SchoolID string
JobID string
PUID string
KToken string
Enc string
Title string
IsPassed bool
}
// PointHyperlinkDto 外链任务点
type PointHyperlinkDto struct {
CardIndex int
CourseID string
ClassID string
KnowledgeID int
Cpi string
ObjectID string
Title string
JobID string
Jtoken string
LinkType int // 外链类型
IsJob bool // 是否是任务点
}
// PointLiveDto 直播任务点
type PointLiveDto struct {
CardIndex int
CourseID string
ClassID string
KnowledgeID int
Cpi string
UserId string // 用户ID
Live bool
LiveId string // 直播ID
Vdoid string
Mid string
Title string // 直播标题
JobID string
StreamName string // 直播流名称
LiveStatusStr string // 直播状态文字
LiveStatusCode int // 直播状态码 0=未开始
VideoDuration int // 视频时长
Aid string
Module string // 类型
IsJob bool // 是否为任务点
AuthEnc string
LiveDragEnc string
LiveSetEnc string
OtherInfo string
Enc string
LiveSwDsEnc string
VideoCompletePercent float64 // 观看进度
}
// Card 任务点卡片信息
type Card struct {
ID int `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
CardOrder int `json:"cardorder"`
KnowledgeID int `json:"knowledgeid"`
}
// CardAPIResponse 卡片API响应
type CardAPIResponse struct {
Data []CardDataItem `json:"data"`
}
// CardDataItem 卡片数据项
type CardDataItem struct {
ClickCount int `json:"clickcount"`
Createtime int64 `json:"createtime"`
OpenLock int `json:"openlock"`
IndexOrder int `json:"indexorder"`
Name string `json:"name"`
LastModifyTime int64 `json:"lastmodifytime"`
ID int `json:"id"`
Label string `json:"label"`
Layer int `json:"layer"`
Card struct {
Data []Card `json:"data"`
} `json:"card"`
ParentNodeID int `json:"parentnodeid"`
Status string `json:"status"`
}
// IframeAttributes iframe 的属性
type IframeAttributes struct {
Data map[string]interface{} `json:"data"`
Other map[string]string
HasData bool
}
// ChapterPointStatus 章节任务点状态
type ChapterPointStatus map[string]struct {
ClickCount int `json:"clickcount"`
FinishCount int `json:"finishcount"`
TotalCount int `json:"totalcount"`
OpenLock int `json:"openlock"`
UnFinishCount int `json:"unfinishcount"`
}
// AttachmentSetting 附件设置(从页面JS中解析)
type AttachmentSetting struct {
Attachments []map[string]interface{} `json:"attachments"`
Defaults map[string]interface{} `json:"defaults"`
}
// ChapterPoints 章节所有任务点集合(旧版,保留兼容)
type ChapterPoints struct {
Videos []PointVideoDto
Documents []PointDocumentDto
Works []PointWorkDto
Hyperlinks []PointHyperlinkDto
Lives []PointLiveDto
}
// ChapterPointType 任务点类型枚举
type ChapterPointType int
const (
ChapterPointTypeVideo ChapterPointType = 1 // 视频
ChapterPointTypeDocument ChapterPointType = 2 // 文档
ChapterPointTypeWork ChapterPointType = 3 // 作业
ChapterPointTypeHyperlink ChapterPointType = 4 // 外链
ChapterPointTypeLive ChapterPointType = 5 // 直播
)
// ChapterPoint 统一的章节任务点结构(与Card一一对应)
type ChapterPoint struct {
CardIndex int // 对应的卡片索引
Type ChapterPointType // 任务点类型
KnowledgeID int // 知识点ID
CourseID string // 课程ID
ClassID string // 班级ID
Cpi string // cpi
JobID string // 任务ID
ObjectID string // 对象ID
Title string // 标题
IsJob bool // 是否是任务点
IsPassed bool // 是否已完成
// 视频相关字段
FID int
DToken string
PlayTime int
Duration int
OtherInfo string
RT float64
VideoFaceCaptureEnc string
RandomCaptureTime string
AttDurationEnc string
Enc string
Mid string
// 文档相关字段
Jtoken string
DocumentType string // document / insertbook
// 作业相关字段
WorkID string
SchoolID string
PUID string
KToken string
// 外链相关字段
LinkType int
// 直播相关字段
UserId string
Live bool
LiveId string
Vdoid string
StreamName string
LiveStatusStr string
LiveStatusCode int
VideoDuration int
Aid string
Module string
AuthEnc string
LiveDragEnc string
LiveSetEnc string
LiveSwDsEnc string
VideoCompletePercent float64
}
// QuestionType 题目类型
type QuestionType int
const (
QuestionTypeSingleChoice QuestionType = 0 // 单选题
QuestionTypeMultipleChoice QuestionType = 1 // 多选题
QuestionTypeFillBlank QuestionType = 2 // 填空题
QuestionTypeTrueFalse QuestionType = 3 // 判断题
QuestionTypeShortAnswer QuestionType = 4 // 简答题
)
// WorkQuestion 作业题目
type WorkQuestion struct {
Qid string `json:"qid"` // 题目ID
Type QuestionType `json:"type"` // 题目类型
Text string `json:"text"` // 题目内容
Options map[string]string `json:"options"` // 选项 (A: xxx, B: xxx)
Answers []string `json:"answers"` // 答案
BlankCount int `json:"blankCount"` // 填空题空格数量
BlankAnswer map[string]string `json:"blankAnswer"` // 填空题答案 (第1空: xxx)
}
// WorkDto 作业详情
type WorkDto struct {
Title string `json:"title"` // 作业标题
Cpi string `json:"cpi"` // cpi
JobId string `json:"jobId"` // 任务ID
WorkId string `json:"workId"` // 作业ID
ClassId string `json:"classId"` // 班级ID
CourseId string `json:"courseId"` // 课程ID
KnowledgeId string `json:"knowledgeId"` // 知识点ID
UserId string `json:"userId"` // 用户ID
FullScore string `json:"fullScore"` // 满分
OldWorkId string `json:"oldWorkId"` // 原始作业ID
OldSchoolId string `json:"oldSchoolId"` // 原始学校ID
Api string `json:"api"` // API值
WorkRelationId string `json:"workRelationId"` // 作业关联ID
EncWork string `json:"encWork"` // 加密参数
WorkAnswerId string `json:"workAnswerId"` // 作业答案ID
AnswerId string `json:"answerId"` // 答案ID
TotalQuestionNum string `json:"totalQuestionNum"` // 题目总数
Questions []WorkQuestion `json:"questions"` // 题目列表
}
@@ -0,0 +1,83 @@
package xuexitong
import (
"context"
"likedream-goframe/internal/application/cookiejar"
"likedream-goframe/internal/application/httpclient"
"likedream-goframe/internal/application/xuexitong/cryptoutil"
"likedream-goframe/internal/framework/errors"
"likedream-goframe/internal/framework/logger"
"net/http"
"strings"
"time"
"github.com/tidwall/gjson"
"go.uber.org/zap"
"resty.dev/v3"
)
const (
ApiLoginWeb = "https://passport2.chaoxing.com/fanyalogin"
)
type XuexitongLogin struct {
client *resty.Client
}
func NewXuexitongLogin(ctx context.Context) *XuexitongLogin {
return &XuexitongLogin{
client: httpclient.NewXuexitongClient(),
}
}
func (c *XuexitongLogin) Login(ctx context.Context, username, password string, retry int) (err error) {
jar := cookiejar.NewAllSavePersistCookieJar()
c.client.SetCookieJar(jar)
usernameEncrypted, err := cryptoutil.LoginEncrypt(username)
if err != nil {
logger.Logger.Error("LoginEncrypt err:", zap.Error(err))
return err
}
passwordEncrypted, err := cryptoutil.LoginEncrypt(password)
if err != nil {
logger.Logger.Error("LoginEncrypt err:", zap.Error(err))
return err
}
resp, err := c.client.R().SetFormData(map[string]string{
"fid": "-1",
"uname": usernameEncrypted,
"password": passwordEncrypted,
"refer": "http%3A%2F%2Fi.mooc.chaoxing.com",
"t": "true",
"forbidotherlogin": "0",
"validate": "",
"doubleFactorLogin": "0",
"independentId": "0",
"independentNameId": "0",
}).Post(ApiLoginWeb)
if err != nil {
logger.Logger.Error("LoginPost err:", zap.Error(err))
return err
}
body := resp.String()
//触发风控直接重新登录
if strings.Contains(body, "很抱歉,您所浏览的页面暂时不能访问!") && retry > 0 {
time.Sleep(time.Duration(retry*5) * time.Second)
return c.Login(ctx, username, password, retry-1)
}
if !gjson.Get(body, "status").Bool() {
return errors.NewBusinessErrorWithDetail(401, "登录失败", gjson.Get(body, "msg2").String())
}
return nil
}
func (c *XuexitongLogin) GetCookieJar() http.CookieJar {
return c.client.CookieJar()
}
func (c *XuexitongLogin) GetCookieString() string {
cookieJar := c.GetCookieJar()
serializableJar := cookieJar.(*cookiejar.PersistCookieJar)
return serializableJar.Serialize()
}
func (c *XuexitongLogin) GetClient() *resty.Client {
return c.client.SetRedirectPolicy(resty.NoRedirectPolicy())
}
@@ -0,0 +1,53 @@
package xuexitong
import (
"fmt"
"io"
"strings"
"time"
"resty.dev/v3"
)
// ApiVerifyCodeBytes 获取学习通验证码图片
func ApiVerifyCodeBytes(client *resty.Client) ([]byte, error) {
urlStr := "https://mooc1-api.chaoxing.com/processVerifyPng.ac?t=" + fmt.Sprintf("%d", time.Now().UnixMilli())
req := client.R()
req.Header.Add("User-Agent", GetUA("mobile"))
req.Header.Add("Accept", "*/*")
req.Header.Add("Host", "mooc1-api.chaoxing.com")
req.Header.Add("Connection", "keep-alive")
res, err := req.Get(urlStr)
if err != nil {
return nil, err
}
defer res.Body.Close()
all, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
return all, nil
}
// ApiPassVerifyCode 提交学习通验证码
func ApiPassVerifyCode(client *resty.Client, code string, retryCount int) bool {
if retryCount < 0 {
return false
}
urlStr := "https://mooc1-api.chaoxing.com/html/processVerify.ac"
client.SetRedirectPolicy(resty.NoRedirectPolicy())
req := client.R()
req.SetMultipartField("ucode", "", "", strings.NewReader(code))
req.SetMultipartField("app", "", "", strings.NewReader("0"))
req.Header.Add("User-Agent", GetUA("mobile"))
req.Header.Add("Accept", "*/*")
req.Header.Add("Host", "mooc1-api.chaoxing.com")
req.Header.Add("Connection", "keep-alive")
res, err := req.Post(urlStr)
if err != nil {
return ApiPassVerifyCode(client, code, retryCount-1)
}
client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
return res.StatusCode() == 302
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,143 @@
package xuexitong
import (
"context"
"likedream-goframe/internal/framework/errors"
"likedream-goframe/internal/utils"
"testing"
)
func TestSchedule(t *testing.T) {
ctx := context.Background()
xuexitongLogin := NewXuexitongLogin(ctx)
err := xuexitongLogin.Login(context.Background(), "13151832241", "yyz@083422", 5)
utils.OkOrPanic(err)
client := xuexitongLogin.GetClient()
courseService := NewXuexitongCourse(client)
// 人人必修的人工智能课
err = courseService.AutoStudyByCourseID(ctx, "258109701")
if err != nil {
utils.OkOrPanic(errors.NewBusinessErrorWithDetail(503, "刷课失败", err))
}
}
func TestXuexitongLogin(t *testing.T) {
xuexitongLogin := NewXuexitongLogin(context.Background())
err := xuexitongLogin.Login(context.Background(), "13551503012", "xiaozhi520", 5)
utils.OkOrPanic(err)
}
func TestPullCourse(t *testing.T) {
xuexitongLogin := NewXuexitongLogin(context.Background())
err := xuexitongLogin.Login(context.Background(), "13551503012", "xiaozhi520", 5)
utils.OkOrPanic(err)
client := xuexitongLogin.GetClient()
courseAction := NewXuexitongCourse(client)
list := courseAction.PullCourseList(context.Background())
utils.OkOrPanic(err)
t.Log(list)
}
func TestPullChapter(t *testing.T) {
xuexitongLogin := NewXuexitongLogin(context.Background())
err := xuexitongLogin.Login(context.Background(), "13551503012", "xiaozhi520", 5)
utils.OkOrPanic(err)
client := xuexitongLogin.GetClient()
courseAction := NewXuexitongCourse(client)
// 先获取课程列表
courseList := courseAction.PullCourseList(context.Background())
if len(courseList) == 0 {
t.Fatal("没有找到课程")
}
// 获取第一个课程的章节
chapters := courseAction.PullCourseChapterList(context.Background(), courseList[0])
t.Logf("课程 %s 的章节数量: %d", courseList[0].CourseName, len(chapters))
for _, chapter := range chapters {
t.Logf("章节: %s, 任务点数量: %d", chapter.Name, chapter.JobCount)
}
}
func TestFetchChapterCards(t *testing.T) {
xuexitongLogin := NewXuexitongLogin(context.Background())
err := xuexitongLogin.Login(context.Background(), "13551503012", "xiaozhi520", 5)
utils.OkOrPanic(err)
client := xuexitongLogin.GetClient()
courseAction := NewXuexitongCourse(client)
// 获取课程列表
courseList := courseAction.PullCourseList(context.Background())
if len(courseList) == 0 {
t.Fatal("没有找到课程")
}
course := courseList[0]
// 获取章节列表
chapters := courseAction.PullCourseChapterList(context.Background(), course)
if len(chapters) == 0 {
t.Fatal("没有找到章节")
}
// 找一个有任务点的章节
var targetChapterID int
for _, ch := range chapters {
if ch.JobCount > 0 {
targetChapterID = ch.ID
break
}
}
if targetChapterID == 0 {
t.Fatal("没有找到有任务点的章节")
}
}
// TestAutoStudyByCourseID 测试通过课程ID刷课(定时任务使用)
func TestAutoStudyByCourseID(t *testing.T) {
// 登录
xuexitongLogin := NewXuexitongLogin(context.Background())
err := xuexitongLogin.Login(context.Background(), "13551503012", "xiaozhi520", 5)
utils.OkOrPanic(err)
// 创建课程实例
courseAction := NewXuexitongCourse(xuexitongLogin.GetClient())
// 先获取课程列表,拿到第一个课程的ID
courseList := courseAction.PullCourseList(context.Background())
if len(courseList) == 0 {
t.Fatal("没有找到课程")
}
courseID := courseList[0].CourseID
t.Logf("准备刷课程ID: %s", courseID)
// 一键刷指定课程的所有任务(定时任务就调用这一个方法)
err = courseAction.AutoStudyByCourseID(context.Background(), courseID)
utils.OkOrPanic(err)
t.Log("指定课程刷课完成")
}
// TestAutoStudyAllCourses 测试自动刷所有课程
func TestAutoStudyAllCourses(t *testing.T) {
// 登录
xuexitongLogin := NewXuexitongLogin(context.Background())
err := xuexitongLogin.Login(context.Background(), "13551503012", "xiaozhi520", 5)
utils.OkOrPanic(err)
// 创建课程实例
courseAction := NewXuexitongCourse(xuexitongLogin.GetClient())
// 一键刷所有课程
err = courseAction.AutoStudyAllCourses(context.Background())
utils.OkOrPanic(err)
t.Log("所有课程刷课完成")
}
@@ -0,0 +1,76 @@
package xuexitong
import (
"crypto/md5"
crand "crypto/rand"
"encoding/hex"
"fmt"
"strings"
)
const (
APP_VERSION = "6.6.4"
DEVICE_VENDOR = "MI10"
BUILD = "10831_263"
//BUILD = "10903_285"
ANDROID_VERSION = "Android 11"
)
var IMEI = TokenHex(16)
// GetUA 构建并获取 UA
func GetUA(uaType string) string {
switch uaType {
case "mobile":
//return strings.Join([]string{
// fmt.Sprintf("Dalvik/2.1.0 (Linux; U; %s; %s Build/SKQ1.210216.001)", ANDROID_VERSION, DEVICE_VENDOR),
// fmt.Sprintf("(schild:%s)", MobileUASign(DEVICE_VENDOR, "zh_CN", APP_VERSION, BUILD, IMEI)),
// fmt.Sprintf("(device:%s)", DEVICE_VENDOR),
// "Language/zh_CN",
// fmt.Sprintf("com.chaoxing.mobile/ChaoXingStudy_3_%s_android_phone_%s", APP_VERSION, BUILD),
// //APP_VERSION,
// fmt.Sprintf("(@Kalimdor)_%s", IMEI),
//}, " ")
return strings.Join([]string{
fmt.Sprintf("Mozilla/5.0 (Linux; %s; %s Build/OPM1.171019.019; wv)", ANDROID_VERSION, DEVICE_VENDOR),
"AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/71.0.3578.99 Mobile Safari/537.36",
fmt.Sprintf("(schild:%s)", MobileUASign(DEVICE_VENDOR, "zh_CN", APP_VERSION, BUILD, IMEI)),
fmt.Sprintf("(device:%s)", DEVICE_VENDOR),
"Language/zh_CN",
fmt.Sprintf("com.chaoxing.mobile/ChaoXingStudy_3_%s_android_phone_%s", APP_VERSION, BUILD),
fmt.Sprintf("(@Kalimdor)_%s", IMEI),
}, " ")
case "web":
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.35"
default:
return ""
}
}
// TokenHex 用于生成IMEI
func TokenHex(length int) string {
b := make([]byte, length)
_, err := crand.Read(b)
if err != nil {
panic(err)
}
return hex.EncodeToString(b)
}
func MobileUASign(model, locale, version, build, imei string) string {
// 拼接字符串,和 Python 的 " ".join 相同
str := strings.Join([]string{
"(schild:ipL$TkeiEmfy1gTXb2XHrdLN0a@7c^vu)",
fmt.Sprintf("(device:%s)", model),
fmt.Sprintf("Language/%s", locale),
fmt.Sprintf("com.chaoxing.mobile/ChaoXingStudy_3_%s_android_phone_%s", version, build),
fmt.Sprintf("(@Kalimdor)_%s", imei),
}, " ")
// 计算 md5
hash := md5.Sum([]byte(str))
// 转换为小写 hex 字符串
return fmt.Sprintf("%x", hash)
}

Some files were not shown because too many files have changed in this diff Show More