Compare commits

..

58 Commits

Author SHA1 Message Date
becomer 7d6dcd198c fix: woodpecker when 条件简化,只用 event: tag
ci/woodpecker/tag/build Pipeline was successful
2026-08-09 22:03:38 +08:00
becomer 27d83f193f fix: 智慧教育网站更新后 ParseJsUrl 无法匹配新的 chunk URL 结构
ci/woodpecker/tag/build Pipeline was successful
网站将 chunk-vendors 改为了 chunk-libs/chunk-elementUI,
且首页组件被合并到多模块组合 chunk 中。
更新正则:匹配 chunk-libs 或 chunk-elementUI 的 URL,
提取 base 路径和 hash,构造新的组合 chunk 文件 URL。
2026-08-09 21:56:58 +08:00
becomer f4c2b8de4c fix: 提交验证码识别失败 — 调用分类器时缺少 X-Api-Key 请求头
ci/woodpecker/tag/build Pipeline was successful
NormalSubmitCaptchaResolver.getResultFromItStar 调用验证码分类服务
/classifier 时没有传 X-Api-Key 请求头,而分类服务已启用 ApiKeyAuth
中间件,导致返回 401,最终报"提交验证码识别失败"。
2026-07-08 23:02:48 +08:00
becomer d454ecc7cf fix: 无历史时保底使用管理员配置的签到范围信息
ci/woodpecker/tag/build Pipeline was successful
- 有历史签到数据 → 优先使用历史中的真实地址和经纬度
- 无历史签到数据 → 保底从 signPlaceSelected 提取管理员配置的范围信息
2026-07-04 01:04:10 +08:00
becomer 7ede964c4c fix: 未签到任务表单中地址和经纬度仍错误使用管理员配置数据
ci/woodpecker/tag/build Pipeline was successful
- ParseFormJson: 完全移除 signPlaceSelected 提取(始终是管理员范围数据)
- ParseFormJson: 将 longitude/latitude/position/signAddress 提取全部包进 if hasHistory
- QueryFormByTitle: 已签到(相同 instanceWid)时将自身 rows 设为 historyRows
- 确保无历史数据的未签到任务不会错误显示管理员范围名称(如"7")
2026-07-04 00:59:33 +08:00
becomer 4b50af3ae0 fix: woodpecker 只应在推送 tag 时触发,移除 push event 2026-07-04 00:51:27 +08:00
becomer 65293c4f81 fix: 编辑表单时签到地址错误显示为管理员范围名称的问题
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/tag/build Pipeline was successful
当任务没有历史已签到数据时,ParseFormJson 中 histRows 回退到 rows
(当前任务定义),此时 signPlaceSelected 包含的是管理员后台圈定范围的
数据(范围名称如"7"),而不是用户实际签到地址。

修复:
1. ParseFormJson: 只有有实际历史数据(historyRows 存在)时才从
   signPlaceSelected 提取地址/经纬度,否则跳过
2. QueryFormByTitle: 当前任务自身已签到时,不再将自身 rows 作为
   historyRows,让逻辑正常从历史月份查找其他已签到实例
2026-07-04 00:48:58 +08:00
becomer a41341e732 fix: 修复历史任务经纬度/地址获取到的是教师边界坐标而非用户历史提交数据的问题
ci/woodpecker/tag/build Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
- sign.go: 移除 signInstanceWid 比较限制,当已签到任务与当前任务是同一实例时,
  直接复用已有详情数据作为历史数据,确保 ParseFormJson 能从 signPlaceSelected 提取用户真实坐标
- sleepcheck.go: 移除 if !isFromSigned 守卫条件,无论当前任务是否已签到都尝试获取历史数据
2026-07-02 00:36:42 +08:00
becomer ecfa50359b fix: add X-Api-Key header for captcha OCR requests
ci/woodpecker/push/build Pipeline was canceled
ci/woodpecker/tag/build Pipeline was successful
The captcha OCR service now requires X-Api-Key authentication.
Add captcha.apiKey config option and pass it as header in all
OCR-related API calls (ocr, semiOcr, calc).
2026-07-01 20:22:03 +08:00
becomer 2879336ce4 feat: make PostgreSQL connection optional
ci/woodpecker/push/build Pipeline was successful
Remove hard requirement on PG database (likedream-pg container).
PG is now only initialized when dsn2 is configured; skip gracefully when empty.
Add nil-safety checks on GlobalGorm references in balance migration logic.
Remove dsn2 from default config.
2026-07-01 14:08:36 +08:00
becomer f706968618 fix: force-remove temp container in cleanup step
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/tag/build Pipeline was successful
2026-07-01 12:49:10 +08:00
becomer 69e198f745 fix: use wget instead of curl for health check
ci/woodpecker/push/build Pipeline was successful
The 'docker' CLI image does not include curl binary,
causing the health check to always fail. Switch to wget.
2026-07-01 12:47:05 +08:00
becomer 70f907d909 fix: replace shell ${VAR} with $VAR to avoid Woodpecker pre-processing
ci/woodpecker/push/build Pipeline failed
Woodpecker interprets ${...} as its own template syntax and strips
unrecognized variables. Using $VAR (no braces) passes through to bash.
2026-07-01 12:43:56 +08:00
becomer 3a3d17e9b4 feat: blue-green zero-downtime deploy via 1Panel
ci/woodpecker/push/build Pipeline failed
- Set up 1Panel website tianshen.eu.cc (HTTP 80, ready for HTTPS)
- /api/* → likedream-go with prefix stripping
- /* → likedream-web (Caddy, port 3080)
- Blue-green deployment: start on standby port → switch proxy → stop old → switch back
- Nginx reload is hot (zero connection drops)
- Detects current active port from proxy config automatically
2026-07-01 12:40:01 +08:00
becomer d560c9f678 fix: override clone remote to local Gitea URL
ci/woodpecker/push/build Pipeline is running
ci/woodpecker/tag/build Pipeline was successful
Add clone.git.settings.remote in .woodpecker/build.yml to use
fast local clone (http://10.10.10.128:3000) instead of external
Cloudflare-proxied URL (https://git.xsvb.cn) for tag builds.
2026-07-01 01:09:16 +08:00
becomer 7a9b49794c chore: retry with correct clone URL
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/tag/build Pipeline is running
2026-07-01 00:56:01 +08:00
becomer e3f8dc8aac fix: set GOPROXY to goproxy.cn for Go module downloads
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/tag/build Pipeline is running
proxy.golang.org is not reachable from this server; use goproxy.cn mirror.
2026-07-01 00:39:04 +08:00
becomer 1228c30a75 chore: trigger ci with new forge url config
ci/woodpecker/push/build Pipeline failed
2026-07-01 00:30:25 +08:00
becomer 4762e71308 fix: replace tags filter with branch filter in when clause
ci/woodpecker/push/build Pipeline is running
ci/woodpecker/tag/build Pipeline was canceled
Woodpecker v3 does not support `tags` in the when clause; use `branch`
with glob patterns instead for matching tag events.
2026-07-01 00:02:44 +08:00
becomer 2082ee3e68 ci: support tag-triggered pipelines with versioned Docker images
- Allow triggering on v* tags in addition to pushes to main/master
- Tag Docker images with CI_COMMIT_TAG when triggered by a tag
2026-07-01 00:01:56 +08:00
becomer 10cb7d7ac6 ci: 添加 Woodpecker CI/CD 流水线
- 构建 Docker 镜像并推送
- 自动部署到本地 Docker
- 配置文件和网络管理
2026-06-30 23:51:01 +08:00
sugarBecomer 261153067c ✨ 后端支持统计返回任务ID + 列表按ID筛选
1. TaskStatisticsV2Item 新增 successTaskIds/failTaskIds/pendingTaskIds 字段
2. GetTaskStatisticsV2 返回每日对应的任务ID列表
3. CpTaskListReq 新增 ids 参数,List 方法支持按ID列表筛选
2026-04-24 12:32:54 +08:00
sugarBecomer 19a13a0ad2 ✨ 后端新增修改任务标题接口
新增 PUT /task/title/{id} 接口,支持修改今日校园任务标题
2026-04-23 10:41:40 +08:00
sugarBecomer 5d7f79a619 🐛 修改域名 2026-04-07 14:37:12 +08:00
sugarBecomer 0934994f51 🐛 修复邮件问题 2026-03-27 16:12:35 +08:00
sugarBecomer ef61d405ba 🐛 更新golang版本 2026-03-27 15:32:24 +08:00
sugarBecomer 60165e4851 🐛 请假问题 2026-03-27 00:38:15 +08:00
sugarBecomer 39cb44bc22 ✨ graph api发送邮件 2026-03-26 15:19:51 +08:00
sugarBecomer 9fa83657e3 ✨ graph api发送邮件 2026-03-26 15:19:43 +08:00
sugarBecomer 5a244c329e ✨ smtp服务 2026-03-26 11:37:38 +08:00
sugarBecomer 93f696d4df ✨ 新增是否必填邀请码 2026-03-25 14:49:11 +08:00
sugarBecomer 988cc7be6f ✨ 请假功能 2026-03-25 13:57:15 +08:00
sugarBecomer b890929d0b ✨ 请假功能 2026-03-25 13:47:11 +08:00
sugarBecomer adbf885d95 🐛 调整两个版本邮件格式 2026-03-24 22:27:32 +08:00
sugarBecomer 99c58a3d5f 🐛 调整两个版本邮件格式 2026-03-24 22:13:02 +08:00
sugarBecomer c89f7e9bbb 🐛 调整dockerfile问题 2026-03-24 19:43:57 +08:00
sugarBecomer a6babba00d 🐛 适配新版ui 2026-03-24 19:36:16 +08:00
sugarBecomer 4c7bc950c7 🐛 调整定时任务时间 2026-03-16 13:29:39 +08:00
sugarBecomer b857d55e25 🐛 漏掉的文件 2026-03-16 12:14:05 +08:00
sugarBecomer ca20be8b7e 🐛 学校信息缓存 2026-03-16 11:20:19 +08:00
sugarBecomer ce98ce4afe 🐛 授权方式修复 2026-03-13 21:37:35 +08:00
sugarBecomer c65318c545 🐛 修复短信登录 2026-03-13 21:21:09 +08:00
sugarBecomer 63b91629fa :bug 修复问题 2026-03-13 18:04:15 +08:00
sugarBecomer 658a9c2b3e 🐛 图片预览 2026-03-12 08:07:33 +08:00
sugarBecomer 60789ded9d 调整golang版本 2026-03-12 00:01:13 +08:00
sugarBecomer 7d8998d83f 🐛 修复淮南联合大学登录功能 2026-03-11 21:11:50 +08:00
sugarBecomer 8974bd26a5 🐛 修复淮南联合大学登录功能 2025-12-27 14:36:33 +08:00
sugarBecomer 6f25652183 🐛 修复淮南联合大学登录功能 2025-12-27 14:36:16 +08:00
sugarBecomer a8f4a2afa6 🐛 修复学习通刷课问题 2025-12-22 17:25:51 +08:00
sugarBecomer d14ddc212d 🐛 暂时还是单线程 没有解决好验证码问题 2025-12-19 23:20:13 +08:00
sugarBecomer 55fc94c37f 🐛 文档修复 2025-12-19 23:08:16 +08:00
sugarBecomer a590f58c6c 🐛 学习通刷课 2025-12-19 22:19:03 +08:00
sugarBecomer 0959ca117c 🐛 学习通刷课 2025-12-19 15:47:51 +08:00
sugarBecomer 3641fb1782 🐛 学习通答题 2025-12-12 11:49:57 +08:00
sugarBecomer d0e7652bf6 🐛 学习通答题 2025-12-12 11:37:33 +08:00
sugarBecomer 07e2438f93 🐛 取消部分日志 2025-12-11 16:50:31 +08:00
sugarBecomer d1b954ab8e 🐛 学习通答题 2025-12-11 16:49:02 +08:00
sugarBecomer 24da8bde3a 🐛 学习通日志 2025-12-10 19:56:33 +08:00
124 changed files with 8891 additions and 3245 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ permissions:
on: [release]
env:
IMAGE: RecordingTheSmile/likedream-go
REGISTRY: git.likedream.xyz
REGISTRY: mycoder.cc.cd
jobs:
Build-And-Upload:
runs-on: ubuntu-latest
+3 -1
View File
@@ -4,4 +4,6 @@
node_modules
.claude
*.exe
.serena
.serena
device_code_cfworker
+127
View File
@@ -0,0 +1,127 @@
when:
event: tag
clone:
git:
image: woodpeckerci/plugin-git
settings:
remote: http://10.10.10.128:3000/becomer/likedream.git
steps:
build-image:
image: docker
commands:
- docker build -t likedream-go:latest .
- docker tag likedream-go:latest likedream-go:${CI_COMMIT_TAG:-${CI_COMMIT_SHA:0:8}}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
deploy:
image: docker
commands: |
# ============================================================
# 蓝绿部署:零宕机切换 likedream-go 容器
# 判断当前代理使用的端口,另一个作为备用
# ============================================================
set -e
IMAGE_TAG="$CI_COMMIT_TAG"
if [ -z "$IMAGE_TAG" ]; then IMAGE_TAG="latest"; fi
# 确保网络存在
docker network create likedream-net 2>/dev/null || true
# 读取 1Panel 当前代理端口
CURRENT_PORT=$(docker exec 1Panel-openresty-ESmn \
grep -o 'proxy_pass http://10.10.10.128:[0-9]\+' \
/www/sites/tianshen/proxy/api.conf | grep -o '[0-9]\+$')
echo "=== 当前 1Panel 反代端口: $CURRENT_PORT ==="
if [ "$CURRENT_PORT" = "8091" ]; then
PORT_ACTIVE=8091
PORT_STANDBY=8092
elif [ "$CURRENT_PORT" = "8092" ]; then
PORT_ACTIVE=8092
PORT_STANDBY=8091
else
echo "未知当前端口 $CURRENT_PORT,默认使用 8091/8092"
PORT_ACTIVE=8091
PORT_STANDBY=8092
fi
echo " 活跃: $PORT_ACTIVE | 备用: $PORT_STANDBY"
# 清理之前可能残留的临时容器
docker rm -f likedream-go-new 2>/dev/null || true
echo "=== 蓝绿部署: 启动新容器 on :$PORT_STANDBY (image: $IMAGE_TAG) ==="
docker run -d --name likedream-go-new \
--restart unless-stopped \
--network likedream-net \
-p $PORT_STANDBY:8091 \
-v /opt/likedream-go/config.toml:/app/config.toml:ro \
likedream-go:$IMAGE_TAG
# 健康检查:等待新容器就绪
HEALTHY=false
for i in $(seq 1 15); do
if wget -q --timeout=3 -O /dev/null http://10.10.10.128:$PORT_STANDBY/health 2>/dev/null; then
HEALTHY=true
break
fi
echo " 等待新容器就绪... ($i/15)"
sleep 2
done
if [ "$HEALTHY" = false ]; then
echo "ERROR: 新容器未通过健康检查,回滚"
docker stop likedream-go-new
docker rm likedream-go-new
exit 1
fi
echo " 新容器已就绪 ✓"
# 切换 1Panel 代理到备用端口
echo "=== 切换 1Panel 反代 :$PORT_ACTIVE → :$PORT_STANDBY ==="
docker exec 1Panel-openresty-ESmn \
sed -i "s|:$PORT_ACTIVE|:$PORT_STANDBY|g" \
/www/sites/tianshen/proxy/api.conf
docker exec 1Panel-openresty-ESmn nginx -s reload
echo " Nginx 热加载完成(零中断)✓"
# 停止旧容器
echo "=== 清理旧容器 ==="
docker stop likedream-go 2>/dev/null || true
docker rm likedream-go 2>/dev/null || true
# 在活跃端口启动正式容器
echo "=== 启动正式容器 on :$PORT_ACTIVE ==="
docker run -d --name likedream-go \
--restart unless-stopped \
--network likedream-net \
-p $PORT_ACTIVE:8091 \
-v /opt/likedream-go/config.toml:/app/config.toml:ro \
likedream-go:$IMAGE_TAG
# 连接到验证码服务网络
docker network connect ocr-go_default likedream-go 2>/dev/null || true
# 切回 1Panel 代理到活跃端口
echo "=== 切回 1Panel 反代 :$PORT_STANDBY → :$PORT_ACTIVE ==="
docker exec 1Panel-openresty-ESmn \
sed -i "s|:$PORT_STANDBY|:$PORT_ACTIVE|g" \
/www/sites/tianshen/proxy/api.conf
docker exec 1Panel-openresty-ESmn nginx -s reload
echo " Nginx 热加载完成(零中断)✓"
# 清理临时容器和旧镜像
echo "=== 清理 ==="
docker rm -f likedream-go-new 2>/dev/null || true
# 保留 latest 和最近 3 个 tag 镜像
docker images likedream-go --format '{{.Tag}}' | grep -v latest | head -n -3 | while read tag; do
docker rmi likedream-go:$tag 2>/dev/null || true
done
echo "=== 部署完成 ✓ ==="
volumes:
- /var/run/docker.sock:/var/run/docker.sock
+2 -1
View File
@@ -1,5 +1,6 @@
FROM golang:alpine AS backend-builder
FROM golang:1.25-alpine AS backend-builder
WORKDIR /build
ENV GOPROXY=https://goproxy.cn,direct
COPY . .
RUN go build -ldflags="-s -w" -o bin/server
+2 -2
View File
@@ -50,8 +50,8 @@ func Run() {
g.Log().Info(ctx, "server is going to shut down...")
api.StopServer()
g.Log().Info(ctx, "api server stopped")
//schedule.Stop()
//g.Log().Info(ctx, "schedule stopped")
schedule.Stop()
g.Log().Info(ctx, "schedule stopped")
redis.Shutdown()
g.Log().Info(ctx, "redis client closed")
os.Exit(0)
+23 -5
View File
@@ -1,13 +1,30 @@
[ocr]
url = "https://devocr.likedream.xyz"
# ===== 应用品牌配置 =====
# brand: "default"(老系统/若梦系统/MD3风格邮件) 或 "new"(新系统/天神系统/shadcn风格邮件)
# 未设置时默认使用 "default"
[app]
brand = "default"
name = "若梦系统"
copyright = "© 2025 若梦系统. 保留所有权利."
# 邮件中的链接地址(会覆盖 server 段中的同名配置)
# emailConfirmBaseUrl = "https://ujob.us.ci/confirm/"
# emailResetPasswordUrl = "https://ujob.us.ci/reset-password/"
# balanceMigrateUrl = "https://ujob.us.ci/balance-migrate/"
[quesbank]
url = "http://tk.swk.tw/api/search.php"
key = "sk-acacac439b346036df45e90c85c177c375568ef72c469e49"
[database]
type = "mysql"
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
dsn2 = "host=pg.out user=dev password=JPmKjmpcaG3EYAJZ dbname=dev port=5432 sslmode=disable TimeZone=Asia/Shanghai"
#type = "postgres"
#dsn = "host=pg.out user=dev password=JPmKjmpcaG3EYAJZ dbname=dev port=5432 sslmode=disable TimeZone=Asia/Shanghai"
[server]
port = 8090
port = 8091
url = "https://devocr.likedream.xyz"
openapiPath= "/api.json"
swaggerPath= "/swagger"
@@ -19,8 +36,9 @@ balanceMigrateUrl = "http://localhost:3200/balance-migrate/"
mode = "dev"
[captcha]
baseUrl = "http://captcha-resolver.out"
#baseUrl = "http://localhost:8890"
#baseUrl = "http://captcha-resolver.out"
baseUrl = "http://likedream-go-captcha:8890"
apiKey = "baca122..."
[redis]
url = "redis://:jhkdjhkjdhsIUTYURTU_pEGmFP@database.out:56379"
+18
View File
@@ -0,0 +1,18 @@
services:
likedream-go:
image: likedream-go:latest
container_name: likedream-go
restart: unless-stopped
ports:
- "8091:8091"
volumes:
- /opt/likedream-go/config.toml:/app/config.toml:ro
networks:
- likedream-net
- ocr-go_default
networks:
likedream-net:
external: true
ocr-go_default:
external: true
+289
View File
@@ -0,0 +1,289 @@
# AI 答题配置接口文档
## 概述
AI 答题功能用于在刷课过程中自动完成作业任务点。系统支持多种 AI 服务商,管理员可以通过配置接口设置 AI 参数。
核心代码位于 `internal/application/quesbank` 包,提供通用的题库答题能力,可被不同平台(学习通、智慧教育等)复用。
## 接口列表
### 1. 获取支持的 AI 类型列表
获取系统支持的所有 AI 类型及其说明。
**请求**
```
GET /v1/system/ai-config/types
```
**响应**
```json
{
"code": 0,
"message": "success",
"data": {
"types": [
{
"type": "chatglm",
"name": "智谱AI (ChatGLM)",
"description": "智谱AI大模型,支持glm-4等模型",
"needUrl": false
},
{
"type": "xinghuo",
"name": "讯飞星火",
"description": "讯飞星火认知大模型",
"needUrl": false
},
{
"type": "tongyi",
"name": "通义千问",
"description": "阿里云通义千问大模型",
"needUrl": false
},
{
"type": "doubao",
"name": "豆包",
"description": "字节跳动豆包大模型",
"needUrl": false
},
{
"type": "openai",
"name": "OpenAI",
"description": "OpenAI GPT系列模型",
"needUrl": false
},
{
"type": "other",
"name": "其他 (兼容OpenAI格式)",
"description": "支持任何兼容OpenAI API格式的服务",
"needUrl": true
}
]
}
}
```
---
### 2. 获取当前 AI 配置
获取当前系统配置的 AI 参数(API密钥会脱敏显示)。
**请求**
```
GET /v1/system/ai-config
```
**响应**
```json
{
"code": 0,
"message": "success",
"data": {
"type": "chatglm",
"url": "",
"model": "glm-4",
"apiKey": "sk-1****************************xyz"
}
}
```
**字段说明**
| 字段 | 类型 | 说明 |
|------|------|------|
| type | string | AI类型,可选值见类型列表 |
| url | string | API地址,仅 `other` 类型需要 |
| model | string | 模型名称 |
| apiKey | string | API密钥(脱敏显示) |
---
### 3. 更新 AI 配置
更新系统的 AI 配置参数。
**请求**
```
PUT /v1/system/ai-config
```
**权限**
需要 `sys:ai:edit` 权限
**请求体**
```json
{
"type": "chatglm",
"url": "",
"model": "glm-4",
"apiKey": "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
```
**参数说明**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| type | string | 是 | AI类型,可选值:`chatglm`, `xinghuo`, `tongyi`, `doubao`, `openai`, `other` |
| url | string | 否 | API地址,当 type 为 `other` 时必填 |
| model | string | 否 | 模型名称,不填则使用默认模型 |
| apiKey | string | 是 | API密钥 |
**各 AI 类型默认模型**
| AI类型 | 默认模型 |
|--------|----------|
| chatglm | glm-4 |
| xinghuo | generalv3.5 |
| tongyi | qwen-plus |
| doubao | 需指定 |
| openai | gpt-3.5-turbo |
| other | 需指定 |
**响应**
```json
{
"code": 0,
"message": "AI配置更新成功"
}
```
---
### 4. 测试 AI 配置
测试 AI 配置是否可用,发送测试消息验证连接。
**请求**
```
POST /v1/system/ai-config/test
```
**权限**
需要 `sys:ai:edit` 权限
**请求体**
```json
{
"type": "chatglm",
"url": "",
"model": "glm-4",
"apiKey": "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
```
**响应**
成功:
```json
{
"code": 0,
"message": "success",
"data": {
"success": true,
"message": "AI连接测试成功,响应: 测试成功..."
}
}
```
失败:
```json
{
"code": 0,
"message": "success",
"data": {
"success": false,
"message": "AI连接测试失败: failed to execute request: ..."
}
}
```
---
## AI 服务商配置指南
### 智谱AI (ChatGLM)
1. 访问 [智谱AI开放平台](https://open.bigmodel.cn/)
2. 注册并创建 API Key
3. 配置参数:
- type: `chatglm`
- model: `glm-4` 或 `glm-3-turbo`
- apiKey: 你的 API Key
### 讯飞星火
1. 访问 [讯飞开放平台](https://www.xfyun.cn/)
2. 创建应用并获取 API Key
3. 配置参数:
- type: `xinghuo`
- model: `generalv3.5` 或其他版本
- apiKey: 你的 API Key
### 通义千问
1. 访问 [阿里云百炼](https://dashscope.aliyun.com/)
2. 开通服务并获取 API Key
3. 配置参数:
- type: `tongyi`
- model: `qwen-plus` 或 `qwen-turbo`
- apiKey: 你的 API Key
### 豆包
1. 访问 [火山引擎](https://www.volcengine.com/)
2. 开通豆包大模型服务
3. 配置参数:
- type: `doubao`
- model: 你的模型端点 ID
- apiKey: 你的 API Key
### OpenAI
1. 访问 [OpenAI Platform](https://platform.openai.com/)
2. 创建 API Key
3. 配置参数:
- type: `openai`
- model: `gpt-3.5-turbo` 或 `gpt-4`
- apiKey: 你的 API Key
### 其他兼容服务
支持任何兼容 OpenAI API 格式的服务(如 DeepSeek、Moonshot 等)。
配置参数:
- type: `other`
- url: API 地址(如 `https://api.deepseek.com/v1/chat/completions`)
- model: 模型名称
- apiKey: 你的 API Key
---
## 使用说明
1. 配置 AI 后,系统在执行刷课任务时会自动使用 AI 完成作业任务点
2. 作业答案默认仅保存不提交,需要用户手动确认后提交
3. 如果未配置 AI,作业任务点将被跳过
4. 建议先使用测试接口验证配置是否正确
## 错误码
| 错误码 | 说明 |
|--------|------|
| 400 | 参数错误或配置不完整 |
| 403 | 无权限操作 |
| 500 | 服务器内部错误 |
+9 -2
View File
@@ -1,6 +1,6 @@
module likedream-goframe
go 1.24.0
go 1.25.0
require (
entgo.io/ent v0.14.5
@@ -28,9 +28,12 @@ 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/xuri/excelize/v2 v2.10.0
go.uber.org/automaxprocs v1.6.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.43.0
golang.org/x/net v0.46.0
golang.org/x/oauth2 v0.36.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
resty.dev/v3 v3.0.0-beta.3
@@ -86,15 +89,20 @@ require (
github.com/oklog/ulid/v2 v2.1.1 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.4 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/samber/lo v1.51.0 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/spf13/cast v1.9.2 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tiendc/go-deepcopy v1.7.1 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zclconf/go-cty v1.14.4 // indirect
github.com/zclconf/go-cty-yaml v1.1.0 // indirect
@@ -106,7 +114,6 @@ require (
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
golang.org/x/image v0.29.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
+19 -4
View File
@@ -163,6 +163,11 @@ github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs=
github.com/redis/go-redis/v9 v9.11.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -195,14 +200,16 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4=
github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
@@ -215,6 +222,12 @@ 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/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4=
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
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=
@@ -268,8 +281,10 @@ 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.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
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=
+16 -17
View File
@@ -2,6 +2,7 @@ package api
import (
"context"
"likedream-goframe/internal/common/cst"
"likedream-goframe/internal/controller/agent"
"likedream-goframe/internal/controller/balance"
@@ -26,55 +27,53 @@ import (
func StartServer(ctx context.Context) {
s := g.Server()
// 设置端口
port, err := g.Cfg().Get(ctx, cst.ConfigServerPort)
utils.OkOrPanic(err)
s.SetNameToUriType(3)
// 设置中间件
s.Use(
middleware.ExceptionHandler,
)
s.Use(middleware.ExceptionHandler)
s.BindStatusHandler(404, common.NewBaseV1().NotFound)
// 业务路由
s.Group("/v1", func(group *ghttp.RouterGroup) {
group.Bind(user_login.NewUserLoginV1())
// 系统设置路由
group.Bind(config.NewMailOAuthCtrlV1())
systemGroup := group.Group("/system").Middleware(middleware.AuthHandler, middleware.PermissionHandler)
systemGroup.Bind(role.NewV1())
systemGroup.Bind(menu.NewV1())
systemGroup.Bind(user.NewUserCtrlV1())
systemGroup.Bind(config.NewConfigCtrlV1())
//systemGroup.Bind(agent.NewV1())
// 用户信息路由
systemGroup.Bind(config.NewAIConfigCtrlV1())
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())
agentGroup.Bind(card.NewCardCtrlV1())
// 今日校园
cpdailyGroup := group.Group("/cpdaily").Middleware(middleware.AuthHandler, middleware.PermissionHandler)
cpdailyGroup.Bind(cpdaily.NewCpUserCtrlV1())
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())
s.GetOpenApi().Config.IgnorePkgPath = true
// 设置全局异常捕获
e := s.Start()
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
err = s.Start()
utils.OkOrPanic(err, oops.WithContext(ctx).Wrap)
}
func StopServer() {
+60
View File
@@ -0,0 +1,60 @@
package v1
import (
"github.com/gogf/gf/v2/frame/g"
)
// AIConfigGetReq 获取AI配置请求
type AIConfigGetReq struct {
g.Meta `path:"/ai-config" method:"get" tags:"AIConfig" summary:"获取AI配置"`
}
// AIConfigGetResp AI配置响应
type AIConfigGetResp struct {
Type string `json:"type"` // AI类型: chatglm, xinghuo, tongyi, doubao, openai, other
URL string `json:"url"` // API地址(other类型必填)
Model string `json:"model"` // 模型名称
APIKey string `json:"apiKey"` // API密钥(返回时脱敏)
}
// AIConfigUpdateReq 更新AI配置请求
type AIConfigUpdateReq struct {
g.Meta `path:"/ai-config" method:"put" tags:"AIConfig" summary:"更新AI配置" permission:"sys:ai:edit"`
Type string `json:"type" v:"required|in:chatglm,xinghuo,tongyi,doubao,openai,other#AI类型不能为空|AI类型不合法"`
URL string `json:"url"` // API地址(other类型必填)
Model string `json:"model"` // 模型名称
APIKey string `json:"apiKey" v:"required#API密钥不能为空"` // API密钥
}
// AIConfigTestReq 测试AI配置请求
type AIConfigTestReq struct {
g.Meta `path:"/ai-config/test" method:"post" tags:"AIConfig" summary:"测试AI配置" permission:"sys:ai:edit"`
Type string `json:"type" v:"required|in:chatglm,xinghuo,tongyi,doubao,openai,other#AI类型不能为空|AI类型不合法"`
URL string `json:"url"`
Model string `json:"model"`
APIKey string `json:"apiKey" v:"required#API密钥不能为空"`
}
// AIConfigTestResp 测试AI配置响应
type AIConfigTestResp struct {
Success bool `json:"success"` // 是否成功
Message string `json:"message"` // 测试结果消息
}
// AITypeListReq 获取支持的AI类型列表请求
type AITypeListReq struct {
g.Meta `path:"/ai-config/types" method:"get" tags:"AIConfig" summary:"获取支持的AI类型列表"`
}
// AITypeItem AI类型项
type AITypeItem struct {
Type string `json:"type"` // 类型标识
Name string `json:"name"` // 显示名称
Description string `json:"description"` // 描述
NeedURL bool `json:"needUrl"` // 是否需要自定义URL
}
// AITypeListResp AI类型列表响应
type AITypeListResp struct {
Types []AITypeItem `json:"types"`
}
+57 -1
View File
@@ -24,6 +24,62 @@ type ConfigListReq struct {
}
type ConfigPublicGetReq struct {
g.Meta `path:"/config/public/{key}" method:"get" tags:"Config" summary:"获取公开配置(无需认证)" auth:"false"`
g.Meta `path:"/config/public/{key}" method:"get" tags:"Config" summary:"获取公开配置" auth:"false"`
Key string `json:"key" v:"required#配置键不能为空"`
}
type SmtpConfigGetReq struct {
g.Meta `path:"/config/smtp" method:"get" tags:"Config" summary:"获取邮件配置" permission:"sys:config:edit"`
}
type SmtpConfigUpdateReq struct {
g.Meta `path:"/config/smtp" method:"put" tags:"Config" summary:"更新邮件配置" permission:"sys:config:edit"`
AuthMode string `json:"authMode" v:"required#请选择邮件认证模式"`
SmtpHost string `json:"smtpHost"`
SmtpPort int `json:"smtpPort"`
SmtpUsername string `json:"smtpUsername"`
SmtpPassword string `json:"smtpPassword"`
FromEmail string `json:"fromEmail" v:"required|email#发件人邮箱不能为空|请输入正确的发件人邮箱"`
FromName string `json:"fromName" v:"required#发件人名称不能为空"`
OAuthTenant string `json:"oauthTenant"`
OAuthClientID string `json:"oauthClientId"`
OAuthClientSecret string `json:"oauthClientSecret"`
OAuthRedirectURL string `json:"oauthRedirectUrl"`
OAuthRefreshToken string `json:"oauthRefreshToken"`
OAuthScope string `json:"oauthScope"`
}
type SmtpTestReq struct {
g.Meta `path:"/config/smtp/test" method:"post" tags:"Config" summary:"发送测试邮件" permission:"sys:config:edit"`
ToEmail string `json:"toEmail" v:"required|email#收件人邮箱不能为空|请输入正确的收件人邮箱"`
}
type OutlookOAuthAuthorizeURLReq struct {
g.Meta `path:"/config/smtp/oauth/outlook/authorize-url" method:"post" tags:"Config" summary:"获取 Outlook OAuth2 授权地址" permission:"sys:config:edit"`
OAuthTenant string `json:"oauthTenant"`
OAuthClientID string `json:"oauthClientId" v:"required#Client ID 不能为空"`
OAuthClientSecret string `json:"oauthClientSecret" v:"required#Client Secret 不能为空"`
OAuthScope string `json:"oauthScope"`
FromEmail string `json:"fromEmail" v:"required|email#发件人邮箱不能为空|请输入正确的发件人邮箱"`
FrontendOrigin string `json:"frontendOrigin" v:"required#前端来源地址不能为空"`
CallbackBaseURL string `json:"callbackBaseUrl" v:"required#API 基础地址不能为空"`
}
type OutlookOAuthAuthorizeURLResp struct {
AuthURL string `json:"authUrl"`
CallbackURL string `json:"callbackUrl"`
State string `json:"state"`
}
type OutlookOAuthCallbackReq struct {
g.Meta `path:"/mail/oauth/outlook/callback" method:"get" tags:"MailOAuth" summary:"Outlook OAuth2 回调" auth:"false"`
Code string `json:"code"`
State string `json:"state"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
+23 -1
View File
@@ -1,8 +1,30 @@
package v1
// TaskStatisticsItem 每日任务统计项
// TaskStatisticsItem 每日任务统计项(旧版)
type TaskStatisticsItem struct {
Date string `json:"date"` // 日期 格式:2006-01-02
PendingTask int `json:"pendingTask"` // 需要完成的任务数
SuccessTask int `json:"successTask"` // 成功执行的任务数
}
// TaskStatisticsV2Item 每日任务统计项(新版:从日志表按任务id去重)
type TaskStatisticsV2Item struct {
Date string `json:"date"` // 日期 格式:2006-01-02
SuccessTask int `json:"successTask"` // 成功任务数(任务id去重)
FailTask int `json:"failTask"` // 失败任务数(任务id去重)
PendingTask int `json:"pendingTask"` // 待执行任务数(仅当天)
SuccessTaskIds []int64 `json:"successTaskIds,omitempty"` // 成功任务ID列表
FailTaskIds []int64 `json:"failTaskIds,omitempty"` // 失败任务ID列表
PendingTaskIds []int64 `json:"pendingTaskIds,omitempty"` // 待执行任务ID列表(仅当天)
}
// DashboardSummary 首页概览数据(新版)
type DashboardSummary struct {
Balance string `json:"balance"` // 当前余额
TodayCost string `json:"todayCost"` // 今日消费
YesterdayCost string `json:"yesterdayCost"` // 昨日消费
Week1Cost string `json:"week1Cost"` // 近7日消费
Week2Cost string `json:"week2Cost"` // 上7日消费
TodayTaskCount int `json:"todayTaskCount"` // 今日任务数(成功+失败,任务id去重)
YdayTaskCount int `json:"ydayTaskCount"` // 昨日任务数(成功+失败,任务id去重)
}
+20 -3
View File
@@ -1,16 +1,33 @@
package v1
import (
"likedream-goframe/internal/ent/cptask"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
type UploadImageReq struct {
g.Meta `path:"/image/upload" method:"post" tags:"Image" summary:"上传图片"`
File *ghttp.UploadFile `json:"file" v:"required#请上传图片"`
g.Meta `path:"/image/upload" method:"post" tags:"Image" summary:"上传图片"`
File *ghttp.UploadFile `json:"file" v:"required#请上传图片"`
CpUserId int64 `json:"cpUserId" v:"required#请指定今日校园用户"`
TaskType cptask.TaskType `json:"taskType" v:"required#请指定任务类型"`
}
type GetUploadPolicyReq struct {
g.Meta `path:"/image/policy" method:"post" tags:"Image" summary:"获取图片上传策略"`
CpUserId int64 `json:"cpUserId" v:"required#请指定今日校园用户"`
TaskType cptask.TaskType `json:"taskType" v:"required#请指定任务类型"`
}
type GetPreviewUrlReq struct {
g.Meta `path:"/image/previewUrl" method:"post" tags:"Image" summary:"获取图片预览地址"`
CpUserId int64 `json:"cpUserId" v:"required#请指定今日校园用户"`
TaskType cptask.TaskType `json:"taskType" v:"required#请指定任务类型"`
FileKey string `json:"fileKey" v:"required#请指定文件key"`
}
type PreviewImageReq struct {
g.Meta `path:"/image/preview" method:"get" tags:"Image" summary:"预览图片"`
Key string `json:"key" v:"required#请输入图片key"`
Key string `json:"key" v:"required#请指定图片key" in:"query"`
}
+13 -2
View File
@@ -1,6 +1,17 @@
package v1
type UploadImageRes struct {
FileKey string `json:"key"`
FileSize *int64 `json:"size"`
PreviewUrl string `json:"previewUrl"`
}
type UploadPolicyRes struct {
Host string `json:"host"`
AccessId string `json:"accessId"`
FileName string `json:"fileName"`
Policy string `json:"policy"`
Signature string `json:"signature"`
}
type PreviewUrlRes struct {
PreviewUrl string `json:"previewUrl"`
}
+51 -7
View File
@@ -6,7 +6,7 @@ import (
)
type CpLeaveListReq struct {
g.Meta `path:"/leave" method:"get" tags:"CpLeave" summary:"获取销假记录列表"`
g.Meta `path:"/leave" method:"get" tags:"CpLeave" summary:"Get leave records"`
CpUserId int64 `json:"cpUserId"`
Username string `json:"username"`
Status string `json:"status"`
@@ -15,23 +15,67 @@ type CpLeaveListReq struct {
}
type GetLeaveListFromSystemReq struct {
g.Meta `path:"/leave/system/{cpUserId}" method:"get" tags:"CpLeave" summary:"获取今日校园系统中的请假列表"`
g.Meta `path:"/leave/system/{cpUserId}" method:"get" tags:"CpLeave" summary:"Get system leave list"`
CpUserId int64 `json:"cpUserId" v:"required#今日校园账号ID不能为空"`
}
type CpLeaveReportReq struct {
g.Meta `path:"/leave/report" method:"post" tags:"CpLeave" summary:"执行销假"`
g.Meta `path:"/leave/report" method:"post" tags:"CpLeave" summary:"Submit leave report"`
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"` // 地址(可选,不传则使用系统默认)
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:"上传销假图片"`
g.Meta `path:"/leave/upload" method:"post" tags:"CpLeave" summary:"Upload leave image"`
File *ghttp.UploadFile `json:"file"`
CpUserId int64 `json:"cpUserId"`
}
type CpLeaveCheckReq struct {
g.Meta `path:"/leave/check/{cpUserId}" method:"get" tags:"CpLeave" summary:"Check leave permission"`
CpUserId int64 `json:"cpUserId" v:"required#今日校园账号ID不能为空"`
}
type CpLeaveTypesReq struct {
g.Meta `path:"/leave/types/{cpUserId}" method:"get" tags:"CpLeave" summary:"Get leave types"`
CpUserId int64 `json:"cpUserId" v:"required#今日校园账号ID不能为空"`
}
type CpLeaveTypeCfgReq struct {
g.Meta `path:"/leave/typeCfg" method:"post" tags:"CpLeave" summary:"Get leave type config"`
CpUserId int64 `json:"cpUserId" v:"required#今日校园账号ID不能为空"`
Code *int `json:"code"`
}
type CpLeaveApproveFlowReq struct {
g.Meta `path:"/leave/approveFlow" method:"post" tags:"CpLeave" summary:"Get approve flow"`
CpUserId int64 `json:"cpUserId" v:"required#今日校园账号ID不能为空"`
LeaveType *int `json:"leaveType"`
StartTime string `json:"startTime" v:"required#开始时间不能为空"`
EndTime string `json:"endTime" v:"required#结束时间不能为空"`
}
type CpLeaveApplyReq struct {
g.Meta `path:"/leave/apply" method:"post" tags:"CpLeave" summary:"Apply leave"`
CpUserId int64 `json:"cpUserId" v:"required#今日校园账号ID不能为空"`
LeaveType *int `json:"leaveType"`
StartTime string `json:"startTime" v:"required#开始时间不能为空"`
EndTime string `json:"endTime" v:"required#结束时间不能为空"`
UrgencyMobile string `json:"urgencyMobile"`
LeaveReason string `json:"leaveReason" v:"required#请假原因不能为空"`
OutStatus bool `json:"outStatus"`
Address string `json:"address"`
DetailAddress string `json:"detailAddress"`
Longitude string `json:"longitude" v:"required#经度不能为空"`
Latitude string `json:"latitude" v:"required#纬度不能为空"`
LocationAddr string `json:"locationAddr" v:"required#定位地址不能为空"`
ImageUrl string `json:"imageUrl"`
ApproverWid string `json:"approverWid"`
ApproverId string `json:"approverId"`
ApproverName string `json:"approverName"`
}
+16
View File
@@ -24,6 +24,7 @@ type CpTaskListReq struct {
TaskType cptask.TaskType `json:"type"`
Username string `json:"username"`
TaskStatus cptask.TaskStatus `json:"task_status"`
Ids string `json:"ids"`
Page int `json:"page" d:"1"`
Size int `json:"size" d:"10"`
}
@@ -51,6 +52,13 @@ type PostCpTaskExecuteReq struct {
NotificationType cptask.NotificationType `json:"notification_type"`
ExecuteHour int `json:"execute_hour"`
ExecuteMinute int `json:"execute_minute"`
ExecuteDays []int `json:"execute_days"`
}
type UpdateTaskTitleReq struct {
g.Meta `path:"/task/title/{id}" method:"put" tags:"CpTask" summary:"修改任务标题"`
Id int64 `json:"id"`
Title string `json:"title" v:"required#标题不能为空"`
}
type DeleteTaskReq struct {
@@ -76,3 +84,11 @@ type FindLocationReq struct {
type GetTaskStatisticsReq struct {
g.Meta `path:"/task/statistics" method:"get" tags:"CpTask" summary:"获取近七日任务统计数据"`
}
type GetTaskStatisticsV2Req struct {
g.Meta `path:"/task/statisticsV2" method:"get" tags:"CpTask" summary:"获取近七日任务统计数据V2"`
}
type GetDashboardSummaryReq struct {
g.Meta `path:"/task/dashboard" method:"get" tags:"CpTask" summary:"获取首页概览数据"`
}
+16 -3
View File
@@ -85,7 +85,20 @@ type CpUserAuthorizeConfirmReq struct {
DataKey string `json:"dataKey"`
}
type CpUserAuthorizeRandomReq struct {
g.Meta `path:"/user/authorizeRandom" method:"post" tags:"CpUser" summary:"随机设备码"`
Id int64 `json:"id"`
type CpUserCustomDeviceReq struct {
g.Meta `path:"/user/customDevice" method:"post" tags:"CpUser" summary:"自定义设备码"`
Id int64 `json:"id" v:"required#请指定用户"`
MobileCode string `json:"mobileCode" v:"required#请输入设备码"`
MobileType string `json:"mobileType" v:"required|in:random,manual#请指定设备码类型|设备码类型无效"`
}
// ---- 二维码获取设备码流程 ----
type CpUserGenerateQrReq struct {
g.Meta `path:"/user/generateQr" method:"post" tags:"CpUser" summary:"生成设备码二维码"`
}
type CpUserCheckQrReq struct {
g.Meta `path:"/user/checkQr" method:"get" tags:"CpUser" summary:"校验二维码UUID是否有效" auth:"false"`
Uuid string `json:"uuid" v:"required#请提供二维码UUID"`
}
+4
View File
@@ -4,6 +4,10 @@ import (
"github.com/gogf/gf/v2/frame/g"
)
type RefreshTokenReq struct {
g.Meta `path:"/refreshToken" method:"GET" tags:"UserInfo" summary:"刷新Token"`
}
type ResetPasswordReq struct {
g.Meta `path:"/resetPassword" method:"POST" tags:"UserInfo" summary:"重置密码"`
OldPassword string `json:"OldPassword"`
+23 -2
View File
@@ -16,13 +16,14 @@ func ApiSemiOCRVerification(bytes []byte, shape1, shape2 int64) string {
data := Bytes2Base64(bytes)
ctx := gctx.GetInitCtx()
baseUrl := g.Config().MustGetWithEnv(ctx, cst.ConfigCaptchaBaseUrl).String() + "/semiOcr"
apiKey := g.Config().MustGetWithEnv(ctx, cst.ConfigCaptchaApiKey).String()
client := httpclient.Cpdaily.Clone(ctx)
client.SetDoNotParseResponse(false)
post, err := client.R().SetBody(map[string]any{
"base64": data,
"shape1": shape1,
"shape2": shape2,
}).Post(baseUrl)
}).SetHeader("X-Api-Key", apiKey).Post(baseUrl)
utils.OkOrPanic(err, func(err error) error {
return oops.
With("SemiOCRVerification", baseUrl).
@@ -37,11 +38,31 @@ func ApiAutoOCRVerification(bytes []byte) string {
data := Bytes2Base64(bytes)
ctx := gctx.GetInitCtx()
baseUrl := g.Config().MustGetWithEnv(ctx, cst.ConfigCaptchaBaseUrl).String() + "/ocr"
apiKey := g.Config().MustGetWithEnv(ctx, cst.ConfigCaptchaApiKey).String()
client := httpclient.Cpdaily.Clone(ctx)
client.SetDoNotParseResponse(false)
post, err := client.R().SetBody(map[string]any{
"base64": data,
}).Post(baseUrl)
}).SetHeader("X-Api-Key", apiKey).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 ApiAutoCalcVerification(b64 string) string {
ctx := gctx.GetInitCtx()
baseUrl := g.Config().MustGetWithEnv(ctx, cst.ConfigCaptchaBaseUrl).String() + "/calc"
apiKey := g.Config().MustGetWithEnv(ctx, cst.ConfigCaptchaApiKey).String()
client := httpclient.Cpdaily.Clone(ctx)
client.SetDoNotParseResponse(false)
post, err := client.R().SetBody(map[string]any{
"base64": b64,
}).SetHeader("X-Api-Key", apiKey).Post(baseUrl)
utils.OkOrPanic(err, func(err error) error {
return oops.
With("AutoOCRVerification", baseUrl).
+2 -16
View File
@@ -48,22 +48,8 @@ func (p *PersistCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
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)
}
// 同一 host 下同名 cookie 直接覆盖,避免多个同名 cookie 导致旧值被选中
p.hostMap[host][cookie.Name] = []*http.Cookie{cookie}
}
}
+167
View File
@@ -0,0 +1,167 @@
package leave
import (
"context"
"fmt"
v1 "likedream-goframe/internal/api/v1"
"likedream-goframe/internal/application/cpdaily"
"likedream-goframe/internal/application/cpdaily/leave/model"
"likedream-goframe/internal/application/cpdaily/taskhelper"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/utils"
"github.com/gogf/gf/v2/container/gvar"
"github.com/tidwall/gjson"
)
// CheckOnceLeave 检查是否允许请假
func (c *CpdailyReportApp) CheckOnceLeave() error {
post, err := c.client.R().SetBody(map[string]any{}).
Post(fmt.Sprintf("%s/wec-counselor-leave-apps/leave/stu/checkOnceLeave", c.host))
if err != nil {
return err
}
result := utils.ReadGjsonResult(post.Body)
if result.Get("code").String() != "0" {
return errors.NewBusinessError(400, result.Get("message").String())
}
return nil
}
// QueryLeaveTypes 获取请假类型列表
func (c *CpdailyReportApp) QueryLeaveTypes() gjson.Result {
post, err := c.client.R().SetBody(map[string]any{}).
Post(fmt.Sprintf("%s/wec-counselor-leave-apps/leave/queryLeaveTypes", c.host))
utils.OkOrPanic(err)
result := utils.ReadGjsonResult(post.Body)
if result.Get("code").String() != "0" {
utils.OkOrPanic(errors.NewBusinessError(503, result.Get("message").String()))
}
return result.Get("datas.leaveType")
}
// QueryLeaveTypeCfg 查询请假类型配置
func (c *CpdailyReportApp) QueryLeaveTypeCfg(code int) gjson.Result {
post, err := c.client.R().SetBody(map[string]any{"code": code}).
Post(fmt.Sprintf("%s/wec-counselor-leave-apps/leave/stu/queryLeaveTypeCfg", c.host))
utils.OkOrPanic(err)
result := utils.ReadGjsonResult(post.Body)
if result.Get("code").String() != "0" {
utils.OkOrPanic(errors.NewBusinessError(503, result.Get("message").String()))
}
return result.Get("datas")
}
// ObtainApproveFlow 获取审批人列表
func (c *CpdailyReportApp) ObtainApproveFlow(leaveType int, startTime, endTime string) gjson.Result {
post, err := c.client.R().SetBody(map[string]any{
"leaveType": leaveType,
"startTime": startTime,
"endTime": endTime,
}).Post(fmt.Sprintf("%s/wec-counselor-leave-apps/leave/stu/obtainApproveFlow", c.host))
utils.OkOrPanic(err)
result := utils.ReadGjsonResult(post.Body)
if result.Get("code").String() != "0" {
utils.OkOrPanic(errors.NewBusinessError(503, result.Get("message").String()))
}
return result.Get("datas")
}
// SubmitLeaveApply 提交请假申请
func (c *CpdailyReportApp) SubmitLeaveApply(ctx context.Context, req *v1.CpLeaveApplyReq, username, deviceId string) {
cpdailyInfo := cpdaily.GetInfo(ctx)
leaveType := 0
if req.LeaveType != nil {
leaveType = *req.LeaveType
}
urgencyMobile := req.UrgencyMobile
address := req.Address
detailAddress := req.DetailAddress
destination := ""
if req.OutStatus {
destination = address
if detailAddress != "" {
if destination != "" {
destination += "/"
}
destination += detailAddress
}
} else {
urgencyMobile = ""
address = ""
detailAddress = ""
}
// 构造请假申请体
applyBody := map[string]any{
"leaveType": leaveType,
"startTime": req.StartTime,
"endTime": req.EndTime,
"urgencyMobile": urgencyMobile,
"leaveReason": req.LeaveReason,
"isAttendance": nil,
"outStatus": req.OutStatus,
"address": address,
"detailAddress": detailAddress,
"destination": destination,
"holidayAutoDefineVos": []any{},
"appVersion": cpdailyInfo.AppVersion,
"deviceId": deviceId,
"ccInfo": map[string]any{"teachers": []any{}, "students": []any{}},
"cheatType": nil,
"source": 1,
"location": map[string]any{
"deviceId": deviceId,
"systemName": "Android",
"systemVersion": cpdailyInfo.SystemVersion,
"model": "Redmi Note 8 Pro",
"address": req.LocationAddr,
"latitude": req.Latitude,
"longitude": req.Longitude,
},
"locationType": 0,
"disclaimers": "本人承诺填写的信息真实有效,并对本次提交请假申请的信息真实性负责。",
"uaIsCpadaily": "true",
"leaveApplyExplain": nil,
}
// 审批人信息
if req.ApproverWid != "" {
applyBody["approverWid"] = req.ApproverWid
applyBody["approverId"] = req.ApproverId
applyBody["approverName"] = req.ApproverName
}
// 图片附件
if req.ImageUrl != "" {
applyBody["applyAttach"] = []model.Attach{{Type: 1, Url: req.ImageUrl}}
} else {
applyBody["applyAttach"] = []any{}
}
// 构造加密扩展头
extensionForm := map[string]*gvar.Var{
"lon": gvar.New(req.Longitude),
"lat": gvar.New(req.Latitude),
"model": gvar.New("Redmi Note 8 Pro"),
"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)
// 提交请假
response, e := c.client.R().
SetBody(applyBody).
WithContext(ctx).
SetHeader("Cpdaily-Extension", extensionString).
Post(fmt.Sprintf("%s/wec-counselor-leave-apps/leave/apply", c.host))
utils.OkOrPanic(e)
responseJSON := utils.ReadGjsonResult(response.Body)
if responseJSON.Get("code").String() != "0" {
utils.OkOrPanic(errors.NewBusinessError(503, responseJSON.Get("message").String()))
}
}
@@ -22,6 +22,8 @@ func GetCpdailyLoginBySchoolInfo(ctx context.Context, info *school.SchoolDetailI
// 河北石油职业技术大学
case "01e270d1-00e2-4ce9-b0af-5d5fbd462b8a":
return NewIap(ctx)
case "676c9395-a29c-4068-97a8-b48e05dae499":
return NewHnuu(ctx)
default:
if info.IsIap() {
return NewIap(ctx)
+65 -3
View File
@@ -4,13 +4,16 @@ import (
"context"
"fmt"
"likedream-goframe/internal/application/cookiejar"
"likedream-goframe/internal/application/cpdaily/school"
"likedream-goframe/internal/application/httpclient"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/ent/cpuser"
"likedream-goframe/internal/framework/logger"
"likedream-goframe/internal/utils"
"strings"
"github.com/samber/oops"
"go.uber.org/zap"
"resty.dev/v3"
)
@@ -97,12 +100,71 @@ func CookieEnabled(ctx context.Context, host string, client *resty.Client, login
utils.OkOrPanic(err)
needLogin := sessionResponse.StatusCode() == 302
if needLogin && loginType == cpuser.LoginTypePhone {
utils.OkOrPanic(errors.NewBusinessError(500, "登录信息失效,请重新通过短信验证码登录"))
}
return !needLogin
}
// RefreshCookieParams 刷新 cookie 所需的用户参数
type RefreshCookieParams struct {
ID int64
Cookie string
Tgc string
LoginType cpuser.LoginType
Username string
Password string
SchoolID string
}
// RefreshCookieResult 刷新 cookie 的结果
type RefreshCookieResult struct {
Client *resty.Client
Cookie string
Tgc string
}
// EnsureCookieValid 检测 cookie 是否过期,过期则自动刷新。
// phone 类型用 TGC 刷新,account 类型用账号密码重新登录。
func EnsureCookieValid(ctx context.Context, params *RefreshCookieParams, schoolDetail *school.SchoolDetailInfo) *RefreshCookieResult {
client := GetClient(ctx, params.Cookie)
if CookieEnabled(ctx, schoolDetail.WecloudUrl, client, params.LoginType) {
logger.Logger.Info("[TGC] cookie有效,无需刷新",
zap.Int64("userId", params.ID),
zap.String("loginType", string(params.LoginType)),
)
return &RefreshCookieResult{Client: client, Cookie: params.Cookie, Tgc: params.Tgc}
}
logger.Logger.Info("[TGC] cookie已失效,开始刷新",
zap.Int64("userId", params.ID),
zap.String("loginType", string(params.LoginType)),
zap.Bool("tgcEmpty", params.Tgc == ""),
)
var newCookie, newTgc string
if params.LoginType == cpuser.LoginTypePhone {
if params.Tgc == "" {
utils.OkOrPanic(errors.NewBusinessError(500, "TGC为空,请重新通过短信验证码登录"))
}
smsHandler := NewSmsWithoutDeviceID()
newCookie, newTgc = smsHandler.RefreshByTgc(ctx, params.Tgc, params.SchoolID)
logger.Logger.Info("[TGC] RefreshByTgc完成",
zap.Int64("userId", params.ID),
zap.Bool("cookieChanged", newCookie != params.Cookie),
)
} else {
loginExecutor := GetCpdailyLoginBySchoolInfo(ctx, schoolDetail)
loginExecutor.Login(ctx, schoolDetail, params.Username, params.Password)
newCookie = loginExecutor.GetCookieString()
newTgc = params.Tgc
}
return &RefreshCookieResult{
Client: GetClient(ctx, newCookie),
Cookie: newCookie,
Tgc: newTgc,
}
}
func GetClient(ctx context.Context, cookie string) *resty.Client {
cookieJar := cookiejar.NewPersistCookieJar()
cookieJar.Deserialize(cookie)
+180
View File
@@ -0,0 +1,180 @@
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"
)
var RSA_KEY_MAP = map[string]string{
"public_exponent": "010001",
"private_exponent": "413798867d69babed22e0dd3d4031c635f3e9dbca0fa50a32974a0e230787b7f7ba78caefbee828a051c690357a8cc31dba8efc738b4db22e887571ef1ec5a5a55b6d866f6a67527f6a7d78a127c9f687008bb540228b50aa2d1ca5a4ff71107234f936b611ac46432a26da9c302eaa7180820df70593353b3f8c0247fe97a45",
"modulus": "00b5eeb166e069920e80bebd1fea4829d3d1f3216f2aabe79b6c47a3c18dcee5fd22c2e7ac519cab59198ece036dcf289ea8201e2a0b9ded307f8fb704136eaeb670286f5ad44e691005ba9ea5af04ada5367cd724b5a26fdb5120cc95b6431604bd219c6b7d83a6f8f24b43918ea988a76f93c333aa5a20991493d4eb1117e7b1",
}
// 淮南联合大学登录实现 RSA加密 & 验证码必出(加法验证码)
type CpdailyHnuuLogin struct {
client *resty.Client
}
func (c CpdailyHnuuLogin) 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
// 解析登录页面
loginPage, err := goquery.NewDocumentFromReader(bytes.NewReader(loginPageBytes))
utils.OkOrPanic(err)
// 判断登录类型
var (
allInputs = map[string]string{}
formNode = loginPage.Find(".ant-form").First()
)
inputs := formNode.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
pair := cryptoutil.NewNutRSAKeyPair(RSA_KEY_MAP["public_exponent"], "", RSA_KEY_MAP["modulus"])
// 处理密码
allInputs["password"] = pair.EncryptedString(password)
// 解决验证码
// 解决常规图片验证码
captchaUrl := getHnuuCaptchaUrl(loginResponseUrl)
code, uid := ResolveCalcCaptcha(ctx, captchaUrl, c.client)
allInputs["code"] = code
allInputs["id"] = uid
jar = cookiejar.NewPersistCookieJar()
c.client.SetCookieJar(jar)
// 登录提交
loginPostResponse, err := c.client.R().
SetFormData(allInputs).
WithContext(ctx).
Post(getHnuuLoginPostUrl(loginResponseUrl))
utils.OkOrPanic(err, func(err error) error {
return oops.
WithContext(ctx).
With("loginResponseUrl", loginResponseUrl).
With("allInputs", allInputs).
Wrap(err)
})
defer loginPostResponse.Body.Close()
type LoginResponse struct {
Meta struct {
Success bool `json:"success"`
StatusCode int `json:"statusCode"`
Message string `json:"message"`
} `json:"meta"`
Data struct {
Code string `json:"code"`
} `json:"data"`
Tgt string `json:"tgt"`
Ticket string `json:"ticket"`
}
json := utils.ReadJson[LoginResponse](loginPostResponse.Body)
if json.Tgt != "" {
handleHnuuLoginSuccess(ctx, c.client, loginResponseUrl.Query().Get("service"), json.Ticket, json.Tgt, schoolDetail.WecloudUrl)
} else {
errMsg := ""
if json.Data.Code == "CODEFALSE" {
errMsg = "验证码错误"
} else if json.Data.Code == "FALSE" {
errMsg = "请检查账号密码是否正确"
} else if json.Data.Code != "PASSERROR" {
errMsg = "密码错误"
} else if json.Data.Code != "NOUSER" {
errMsg = "账号不存在"
} else if json.Data.Code != "USERLOCK" {
errMsg = "账号锁定"
} else {
errMsg = "未知错误" + json.Data.Code
}
utils.OkOrPanic(errors.NewBusinessError(500, errMsg))
}
}
func handleHnuuLoginSuccess(ctx context.Context, client *resty.Client, url, ticket string, tgt string, host string) {
redirectUrl := fmt.Sprintf("%s&ticket=%s", url, ticket)
client.R().SetHeader("Referer", "https://cas.hnuu.edu.cn/").Get(redirectUrl)
response, err2 := client.R().SetHeader("Referer", "https://cas.hnuu.edu.cn/").Get("https://hnuu.campusphere.net/iap/login")
utils.OkOrPanic(err2)
newUrl := response.RawResponse.Request.URL
ticketUrl := fmt.Sprintf("https://cas.hnuu.edu.cn/lyuapServer/v1/tickets/%s", tgt)
get, err := client.R().SetFormData(map[string]string{
"service": url,
"loginToken": "loginToken",
}).Post(ticketUrl)
utils.OkOrPanic(err)
defer get.Body.Close()
ticket = utils.ReadString(get.Body)
redirectUrl = fmt.Sprintf("%s&ticket=%s", newUrl.Query().Get("service"), ticket)
client.R().SetHeader("Referer", "https://cas.hnuu.edu.cn/").Get(redirectUrl)
ActivateSession(ctx, host, client)
}
func getHnuuLoginPostUrl(url *url.URL) string {
// https://cas.hnuu.edu.cn/lyuapServer/v1/tickets
return fmt.Sprintf("%s://%s/lyuapServer/v1/tickets", url.Scheme, url.Host)
}
func getHnuuCaptchaUrl(url *url.URL) string {
// https://cas.hnuu.edu.cn/lyuapServer/kaptcha?_t=1766803822&uid=
return fmt.Sprintf("%s://%s/lyuapServer/kaptcha?_t=%d&uid=", url.Scheme, url.Host, utils.GetCurrentTimeMillis())
}
func (c CpdailyHnuuLogin) GetCookieJar() http.CookieJar {
return c.client.CookieJar()
}
func (c CpdailyHnuuLogin) GetCookieString() string {
cookieJar := c.GetCookieJar()
serializableJar := cookieJar.(*cookiejar.PersistCookieJar)
return serializableJar.Serialize()
}
func (c CpdailyHnuuLogin) GetClient() *resty.Client {
return c.client
}
func NewHnuu(ctx context.Context) CpdailyLogin {
return &CpdailyHnuuLogin{
client: httpclient.NewCpdailyClient(),
}
}
+26
View File
@@ -15,6 +15,32 @@ func ResolveOcrCaptcha(ctx context.Context, url string, client *resty.Client) st
return captcha.ApiAutoOCRVerification(byteData)
}
func ResolveCalcCaptcha(ctx context.Context, url string, client *resty.Client) (string, string) {
b64, uid := DownBase64(ctx, url, client)
return captcha.ApiAutoCalcVerification(b64), uid
}
func DownBase64(ctx context.Context, url string, client *resty.Client) (string, string) {
res, err := client.R().
WithContext(ctx).
Get(url)
utils.OkOrPanic(err, func(err error) error {
return oops.
With("captchaUrl", url).
WithContext(ctx).
Wrap(err)
})
defer res.Body.Close()
type CaptchResponse struct {
KaptchaType string `json:"kaptchaType"`
UID string `json:"uid"`
Content string `json:"content"`
Timeout int `json:"timeout"`
}
json := utils.ReadJson[CaptchResponse](res.Body)
return json.Content, json.UID
}
func DownCode2Byte(ctx context.Context, url string, client *resty.Client) []byte {
res, err := client.R().
WithContext(ctx).
+94
View File
@@ -11,12 +11,15 @@ import (
"likedream-goframe/internal/application/httpclient"
"likedream-goframe/internal/common/cst"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/framework/logger"
"likedream-goframe/internal/utils"
"net/http"
"net/url"
"strings"
"github.com/bytedance/sonic"
"github.com/samber/oops"
"go.uber.org/zap"
"resty.dev/v3"
)
@@ -199,6 +202,7 @@ func (s *SmsLoginHandler) SetLoginCookie(ctx context.Context, jar http.CookieJar
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).
@@ -278,3 +282,93 @@ func (s *SmsLoginHandler) GetCookieString() string {
serializableJar := cookieJar.(*cookiejar.PersistCookieJar)
return serializableJar.Serialize()
}
// RefreshByTgc 使用保存的完整 cookie(包含 CASTGC 等长期凭证)重新去学校换取 CAS cookie。
// tgc 参数是 SetLoginCookie 之后序列化的完整 cookie jar,包含 CASTGC(有效期10年)、
// sessionToken 等认证信息。流程:先用 sessionToken 调用 getImUserToken 换取新的 CAS cookie,
// 再激活 session。
func (s *SmsLoginHandler) RefreshByTgc(ctx context.Context, tgc string, schoolId string) (cookie string, newTgc string) {
jar := cookiejar.NewPersistCookieJar()
jar.Deserialize(tgc)
// 合并所有 host 的 cookie 到 global,消除因多 host 存储导致的重复 cookie
jar.RemoveCookieInfo()
s.client = httpclient.NewCpdailyClient()
s.client.SetCookieJar(jar)
detail := school.GetSchoolDetail(ctx, schoolId)
logger.Logger.Info("[TGC] RefreshByTgc 开始用TGC换取CAS cookie",
zap.String("schoolId", schoolId),
zap.String("wecloudUrl", detail.WecloudUrl),
zap.Int("tgcLen", len(tgc)),
)
// 从 cookie jar 中提取 sessionToken,用于换取新的 CAS cookie
schoolUrl, _ := url.Parse(detail.WecloudUrl)
sessionToken := ""
for _, c := range jar.Cookies(schoolUrl) {
if c.Name == "sessionToken" {
sessionToken = c.Value
break
}
}
if sessionToken != "" {
// 换取新的 CAS cookie(与 SetLoginCookie 相同的流程)
getCookieUrl := fmt.Sprintf("%s%s", detail.WecloudUrl, "/wec-im-group/group/imUserToken/getImUserToken")
ampCookieMap := map[string]any{
"AMP2": []map[string]any{
{"path": "/", "value": sessionToken, "domain": "", "name": "sessionToken"},
},
"AMP1": []map[string]any{
{"path": "/", "value": sessionToken, "domain": "", "name": "sessionToken"},
},
}
ampTokenString, e := sonic.Marshal(ampCookieMap)
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
ampTokenEncrypted := cryptoutil.DesEncrypt(ampTokenString, cryptoutil.Normal)
ampToken := base64.StdEncoding.EncodeToString(ampTokenEncrypted)
ctx = utils.NewCpdailySmsContextBuilder().
WithDeviceID(s.deviceID).
WithTenantID(detail.Id).
WithSessionToken(sessionToken).
Build(ctx)
// 使用不复制 Cookie 头的重定向策略,避免旧 Cookie 覆盖 jar 中更新的值
s.client.SetRedirectPolicy(resty.RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error {
if len(via) >= 50 {
return fmt.Errorf("resty: stopped after 50 redirects")
}
// 只复制非 Cookie 头,让 jar 自动管理 Cookie
for key, val := range via[0].Header {
if !strings.EqualFold(key, "Cookie") {
req.Header[key] = val
}
}
return nil
}))
req := s.client.R().
SetHeader("AmpCookies", ampToken).
SetContext(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()
logger.Logger.Info("[TGC] RefreshByTgc 换取CAS cookie完成")
} else {
logger.Logger.Warn("[TGC] RefreshByTgc 未找到sessionToken,跳过换取CAS cookie步骤")
}
newTgc = tgc // TGC 保持不变(CASTGC 有效期10年)
cookie = s.GetCookieString()
return
}
@@ -146,8 +146,10 @@ func (r *NormalSubmitCaptchaResolver) getResultFromItStar(rawResponse json.RawMe
utils.OkOrPanic(err)
url := r.getITStarUrl()
apiKey := g.Config().MustGetWithEnv(gctx.GetInitCtx(), cst.ConfigCaptchaApiKey).String()
resp, err := r.client.R().
SetHeader("X-Api-Key", apiKey).
SetBody(responseMap).
Post(url)
utils.OkOrPanic(err)
@@ -5,10 +5,8 @@ import (
"fmt"
"likedream-goframe/internal/application/cpdaily"
"likedream-goframe/internal/application/cpdaily/cryptoutil"
"likedream-goframe/internal/application/cpdaily/imageuploader"
"likedream-goframe/internal/application/cpdaily/task/formtype"
"likedream-goframe/internal/application/cpdaily/taskhelper"
"likedream-goframe/internal/application/cpdaily/types"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/utils"
"maps"
@@ -454,31 +452,26 @@ func (c *CpdailyCollectionTask) FillCustomForm(ctx context.Context, form map[str
newRowObj["value"] = gvar.New(strings.Join(values, ","))
}
} else if c.isPicType(fieldType) {
// 图片类型
// 图片类型 - content 中存储的是今日校园的 previewUrl,逗号分隔
if param.Content == "" {
utils.OkOrPanic(errors.NewBusinessError(400, "查寝需要图片,但是当前图片为空"), oops.WithContext(ctx).Wrap)
}
imageUuidSeq := strings.Split(param.Content, ",")
var imageUuids []string
for _, imageUuid := range imageUuidSeq {
imageUuid = strings.TrimSpace(imageUuid)
if imageUuid == "" {
continue
previewUrls := strings.Split(param.Content, ",")
var validUrls []string
for _, url := range previewUrls {
url = strings.TrimSpace(url)
if url != "" {
validUrls = append(validUrls, url)
}
imageUuids = append(imageUuids, imageUuid)
}
if len(imageUuids) == 0 {
if len(validUrls) == 0 {
utils.OkOrPanic(errors.NewBusinessError(400, "查寝照片未上传"), oops.WithContext(ctx).Wrap)
}
// 随机选择一张照片
imageUuid := imageUuids[rand.IntN(len(imageUuids))]
imageId, e := uuid.Parse(imageUuid)
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
previewUrl := imageuploader.UploadImageFromStorage(ctx, c.host, imageId, types.TaskTypeCollection, c.client)
previewUrl := validUrls[rand.IntN(len(validUrls))]
newRowObj["value"] = gvar.New(previewUrl)
} else {
utils.OkOrPanic(errors.NewBusinessError(500, "出现未知类型表单,请反馈表单类型:"+fieldType), oops.WithContext(ctx).Wrap)
+165 -28
View File
@@ -5,14 +5,15 @@ import (
"fmt"
"likedream-goframe/internal/application/cpdaily"
"likedream-goframe/internal/application/cpdaily/cryptoutil"
"likedream-goframe/internal/application/cpdaily/imageuploader"
"likedream-goframe/internal/application/cpdaily/submitcaptcha"
"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/utils"
"slices"
"strconv"
"strings"
"time"
@@ -20,6 +21,7 @@ import (
"github.com/gogf/gf/v2/container/gvar"
"github.com/google/uuid"
"github.com/samber/oops"
"github.com/tidwall/gjson"
"github.com/tidwall/match"
"resty.dev/v3"
)
@@ -221,6 +223,88 @@ func (c *CpdailyAutoSignTask) QueryFormByTitle(ctx context.Context, titleGlob st
result["realTitle"] = gvar.New(realTitle)
result["taskInfo"] = gvar.New(matchTask)
result["rows"] = gvar.New(formDatas)
// 尝试从已签到的历史任务中获取历史数据用于预填充
signedMatch := findMatchTitle(signedTasks)
if signedMatch != nil {
if matchTask != nil && signedMatch["signInstanceWid"] != nil && matchTask["signInstanceWid"] != nil &&
signedMatch["signInstanceWid"].String() == matchTask["signInstanceWid"].String() {
// 任务已签到(同一个 instanceWid),自身 rows 中的 longitude/latitude/position 就是用户自己提交的数据
result["historyRows"] = gvar.New(formDatas)
} else {
histResp, histErr := c.client.R().
SetBody(map[string]*gvar.Var{
"taskName": gvar.New(signedMatch["taskName"].String()),
"signWid": signedMatch["signWid"],
"signInstanceWid": signedMatch["signInstanceWid"],
}).
WithContext(ctx).
Post(c.getReqUrl("detailSignInstance"))
if histErr == nil {
histJSON := utils.ReadJson[map[string]*gvar.Var](histResp.Body)
histDatas := histJSON["datas"].MapStrVar()
if len(histDatas) > 0 {
result["historyRows"] = gvar.New(histDatas)
}
}
}
}
// 今天没有已签到的同名任务,从历史月份中只查 signedTasks
if result["historyRows"] == nil {
func() {
defer func() { recover() }()
now := time.Now().In(cst.TimeZoneShangHai)
year := strconv.Itoa(now.Year())
month := strconv.Itoa(int(now.Month()))
if len(month) == 1 {
month = "0" + month
}
resp, e := c.client.R().
SetBody(map[string]any{
"statisticYearMonth": fmt.Sprintf("%s-%s", year, month),
}).
WithContext(ctx).
Post(c.getReqUrl("getStuSignInfosByWeekMonth"))
if e != nil {
return
}
respJSON := utils.ReadJson[map[string]*gvar.Var](resp.Body)
monthDatas := respJSON["datas"].MapStrVar()
monthRows := monthDatas["rows"].Vars()
for _, row := range monthRows {
rowMap := row.MapStrVar()
signed := rowMap["signedTasks"].Vars()
for _, st := range signed {
item := st.MapStrVar()
tn, exist := item["taskName"]
if !exist || tn.String() == "" {
continue
}
if match.Match(tn.String(), titleGlob) {
dr, de := c.client.R().
SetBody(map[string]*gvar.Var{
"signInstanceWid": item["signInstanceWid"],
"signWid": item["signWid"],
"taskName": gvar.New(tn.String()),
}).
WithContext(ctx).
Post(c.getReqUrl("detailSignInstance"))
if de != nil {
continue
}
dj := utils.ReadJson[map[string]*gvar.Var](dr.Body)
dd := dj["datas"].MapStrVar()
if len(dd) > 0 {
result["historyRows"] = gvar.New(dd)
return
}
}
}
}
}()
}
return result
}
@@ -356,30 +440,81 @@ func (c *CpdailyAutoSignTask) ParseFormJson(ctx context.Context, rawJson map[str
utils.OkOrPanic(errors.NewBusinessError(503, "任务表单为空"), oops.WithContext(ctx).Wrap)
}
// 优先从 historyRows(已签到的历史任务详情)中提取历史值,否则从 rows 中提取
histRows := rows
hasHistory := false
if hr := rawJson["historyRows"]; hr != nil {
if hrMap := hr.MapStrVar(); len(hrMap) > 0 {
histRows = hrMap
hasHistory = true
}
}
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)
// 从任务详情中提取历史值用于预填充
histLatAndLon := ""
histAddress := ""
histSignPhotoUrl := ""
histIsMalposition := ""
// 优先使用历史签到数据(真实地址),无历史时保底使用管理员配置的范围信息
if hasHistory {
// 从历史签到数据中提取用户真实的地址和经纬度
if lon := histRows["longitude"]; lon != nil && lon.String() != "" {
if lat := histRows["latitude"]; lat != nil && lat.String() != "" {
histLatAndLon = lon.String() + "," + lat.String()
}
}
if histAddress == "" {
if pos := histRows["position"]; pos != nil && pos.String() != "" {
histAddress = pos.String()
}
}
if histAddress == "" {
if addr := histRows["signAddress"]; addr != nil && addr.String() != "" {
histAddress = addr.String()
}
}
if photo := histRows["signPhotoUrl"]; photo != nil && photo.String() != "" {
histSignPhotoUrl = photo.String()
}
if mal := histRows["isMalposition"]; mal != nil {
if mal.Int() == 1 {
histIsMalposition = "是"
} else {
histIsMalposition = "否"
}
}
}
// 无历史签到数据时,保底使用管理员配置的范围信息
if histLatAndLon == "" && histAddress == "" {
if sps := rows["signPlaceSelected"]; sps != nil && sps.String() != "" && sps.String() != "[]" {
placeArr := gjson.Parse(sps.String()).Array()
if len(placeArr) > 0 {
place := placeArr[0]
lng := place.Get("longitude").String()
lat := place.Get("latitude").String()
addr := place.Get("address").String()
if lng != "" && lat != "" {
histLatAndLon = lng + "," + lat
}
if addr != "" {
histAddress = addr
}
}
}
}
dto := &formtype.CpTaskFormItem{
Title: "经纬度",
ColName: "latAndLon",
Type: formtype.ItemTypeLatAndLon,
Required: true,
Tip: "您可以通过地址搜索签到经纬度,如果觉得不够准确可以自行修改",
Title: "经纬度",
ColName: "latAndLon",
Type: formtype.ItemTypeLatAndLon,
Required: true,
Content: histLatAndLon,
Default: histLatAndLon,
OtherValue: histAddress,
Tip: "您可以通过地址搜索签到经纬度,如果觉得不够准确可以自行修改",
}
list = append(list, dto)
@@ -388,6 +523,8 @@ func (c *CpdailyAutoSignTask) ParseFormJson(ctx context.Context, rawJson map[str
ColName: "address",
Type: formtype.ItemTypeInput,
Required: true,
Content: histAddress,
Default: histAddress,
Tip: "这个就是指签到完毕后查看时最底部那个最详细的地址信息哦",
}
list = append(list, dto)
@@ -398,6 +535,8 @@ func (c *CpdailyAutoSignTask) ParseFormJson(ctx context.Context, rawJson map[str
ColName: "signImg",
Type: formtype.ItemTypeImage,
Required: true,
Content: histSignPhotoUrl,
Default: histSignPhotoUrl,
Tip: "您可以上传多张图片,会顺序轮换更新的哦~",
}
list = append(list, dto)
@@ -409,6 +548,8 @@ func (c *CpdailyAutoSignTask) ParseFormJson(ctx context.Context, rawJson map[str
ColName: "isMalposition",
Type: formtype.ItemTypeSingle,
Required: true,
Content: histIsMalposition,
Default: histIsMalposition,
Options: []formtype.CpTaskFormItemOption{
{
Content: "是",
@@ -527,12 +668,8 @@ func (c *CpdailyAutoSignTask) FillCustomForm(ctx context.Context, form map[strin
utils.OkOrPanic(errors.NewBusinessError(400, "签到照片未上传"), oops.WithContext(ctx).Wrap)
}
imageId, e := uuid.Parse(imgId)
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
previewUrl := imageuploader.UploadImageFromStorage(ctx, c.host, imageId, types.TaskTypeSign, c.client)
resultForm["signPhotoUrl"] = gvar.New(previewUrl)
// imgId 现在直接就是今日校园的 previewUrl,无需再上传
resultForm["signPhotoUrl"] = gvar.New(imgId)
}
// 处理验证码
+166 -28
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"likedream-goframe/internal/application/cpdaily"
"likedream-goframe/internal/application/cpdaily/cryptoutil"
"likedream-goframe/internal/application/cpdaily/imageuploader"
"likedream-goframe/internal/application/cpdaily/submitcaptcha"
"likedream-goframe/internal/application/cpdaily/task/formtype"
"likedream-goframe/internal/application/cpdaily/taskhelper"
@@ -277,6 +276,91 @@ func (c *CpdailySleepCheckTask) QueryFormByTitle(ctx context.Context, titleGlob
result["realTitle"] = gvar.New(realTitle)
result["taskInfo"] = gvar.New(matchTask)
result["rows"] = gvar.New(datas)
// 尝试从已签到的历史任务中获取历史数据用于预填充
// 无论当前任务是否已签到,都尝试获取历史数据
signedMatch := findMatchTitle(signedTasks)
if signedMatch != nil {
if matchTask != nil && signedMatch["signInstanceWid"] != nil && matchTask["signInstanceWid"] != nil &&
signedMatch["signInstanceWid"].String() == matchTask["signInstanceWid"].String() {
// 任务已签到(同一个 instanceWid),自身 rows 中的 longitude/latitude/position 就是用户自己提交的数据
result["historyRows"] = gvar.New(datas)
} else {
histResp, histErr := c.client.R().
SetBody(map[string]*gvar.Var{
"taskName": gvar.New(signedMatch["taskName"].String()),
"signWid": signedMatch["signWid"],
"signInstanceWid": signedMatch["signInstanceWid"],
}).
WithContext(ctx).
Post(c.getReqUrl("detailSignInstance"))
if histErr == nil {
histJSON := utils.ReadJson[map[string]*gvar.Var](histResp.Body)
histDatas := histJSON["datas"].MapStrVar()
if len(histDatas) > 0 {
result["historyRows"] = gvar.New(histDatas)
}
}
}
}
// 今天没有已签到的同名任务,从历史月份中只查 signedTasks
if result["historyRows"] == nil {
func() {
defer func() { recover() }()
now := time.Now().In(cst.TimeZoneShangHai)
year := strconv.Itoa(now.Year())
month := strconv.Itoa(int(now.Month()))
if len(month) == 1 {
month = "0" + month
}
resp, e := c.client.R().
SetBody(map[string]any{
"statisticYearMonth": fmt.Sprintf("%s-%s", year, month),
}).
WithContext(ctx).
Post(c.getReqUrl("getStuSignInfosByWeekMonth"))
if e != nil {
return
}
respJSON := utils.ReadJson[map[string]*gvar.Var](resp.Body)
monthDatas := respJSON["datas"].MapStrVar()
monthRows := monthDatas["rows"].Vars()
// 只从 signedTasks 中查找匹配的已签到任务
for _, row := range monthRows {
rowMap := row.MapStrVar()
signed := rowMap["signedTasks"].Vars()
for _, st := range signed {
item := st.MapStrVar()
tn, exist := item["taskName"]
if !exist || tn.String() == "" {
continue
}
if match.Match(tn.String(), titleGlob) {
// 找到已签到的历史任务,获取详情
dr, de := c.client.R().
SetBody(map[string]*gvar.Var{
"signInstanceWid": item["signInstanceWid"],
"signWid": item["signWid"],
"taskName": gvar.New(tn.String()),
}).
WithContext(ctx).
Post(c.getReqUrl("detailSignInstance"))
if de != nil {
continue
}
dj := utils.ReadJson[map[string]*gvar.Var](dr.Body)
dd := dj["datas"].MapStrVar()
if len(dd) > 0 {
result["historyRows"] = gvar.New(dd)
return
}
}
}
}
}()
}
return result
}
@@ -432,30 +516,82 @@ func (c *CpdailySleepCheckTask) ParseFormJson(ctx context.Context, rawJson map[s
utils.OkOrPanic(errors.NewBusinessError(503, "任务表单为空"), oops.WithContext(ctx).Wrap)
}
// 优先从 historyRows(已签到的历史任务详情)中提取历史值,否则从 rows 中提取
histRows := rows
hasHistory := false
if hr := rawJson["historyRows"]; hr != nil {
if hrMap := hr.MapStrVar(); len(hrMap) > 0 {
histRows = hrMap
hasHistory = true
}
}
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)
// 从任务详情中提取历史值用于预填充
// signPlaceSelected 包含签到地点信息(经纬度、地址)
histLatAndLon := ""
histAddress := ""
histSignPhotoUrl := ""
histIsMalposition := ""
// 优先使用历史签到数据(真实地址),无历史时保底使用管理员配置的范围信息
if hasHistory {
// 从历史签到数据中提取用户真实的地址和经纬度
if lon := histRows["longitude"]; lon != nil && lon.String() != "" {
if lat := histRows["latitude"]; lat != nil && lat.String() != "" {
histLatAndLon = lon.String() + "," + lat.String()
}
}
if histAddress == "" {
if pos := histRows["position"]; pos != nil && pos.String() != "" {
histAddress = pos.String()
}
}
if histAddress == "" {
if addr := histRows["signAddress"]; addr != nil && addr.String() != "" {
histAddress = addr.String()
}
}
if photo := histRows["signPhotoUrl"]; photo != nil && photo.String() != "" {
histSignPhotoUrl = photo.String()
}
if mal := histRows["isMalposition"]; mal != nil {
if mal.Int() == 1 {
histIsMalposition = "是"
} else {
histIsMalposition = "否"
}
}
}
// 无历史签到数据时,保底使用管理员配置的范围信息
if histLatAndLon == "" && histAddress == "" {
if sps := rows["signPlaceSelected"]; sps != nil && sps.String() != "" && sps.String() != "[]" {
placeArr := gjson.Parse(sps.String()).Array()
if len(placeArr) > 0 {
place := placeArr[0]
lng := place.Get("longitude").String()
lat := place.Get("latitude").String()
addr := place.Get("address").String()
if lng != "" && lat != "" {
histLatAndLon = lng + "," + lat
}
if addr != "" {
histAddress = addr
}
}
}
}
dto := &formtype.CpTaskFormItem{
Title: "经纬度",
ColName: "latAndLon",
Type: formtype.ItemTypeLatAndLon,
Required: true,
Tip: "您可以通过地址搜索签到经纬度,如果觉得不够准确可以自行修改",
Title: "经纬度",
ColName: "latAndLon",
Type: formtype.ItemTypeLatAndLon,
Required: true,
Content: histLatAndLon,
Default: histLatAndLon,
OtherValue: histAddress,
Tip: "您可以通过地址搜索签到经纬度,如果觉得不够准确可以自行修改",
}
list = append(list, dto)
dto = &formtype.CpTaskFormItem{
@@ -463,6 +599,8 @@ func (c *CpdailySleepCheckTask) ParseFormJson(ctx context.Context, rawJson map[s
ColName: "address",
Type: formtype.ItemTypeInput,
Required: true,
Content: histAddress,
Default: histAddress,
Tip: "这个就是指签到完毕后查看时最底部那个最详细的地址信息哦",
}
list = append(list, dto)
@@ -473,6 +611,8 @@ func (c *CpdailySleepCheckTask) ParseFormJson(ctx context.Context, rawJson map[s
ColName: "signImg",
Type: formtype.ItemTypeImage,
Required: true,
Content: histSignPhotoUrl,
Default: histSignPhotoUrl,
Tip: "您可以上传多张图片,会顺序轮换更新的哦~",
}
list = append(list, dto)
@@ -485,6 +625,8 @@ func (c *CpdailySleepCheckTask) ParseFormJson(ctx context.Context, rawJson map[s
ColName: "isMalposition",
Type: formtype.ItemTypeSingle,
Required: true,
Content: histIsMalposition,
Default: histIsMalposition,
Options: []formtype.CpTaskFormItemOption{
{
Content: "是",
@@ -577,12 +719,8 @@ func (c *CpdailySleepCheckTask) FillCustomForm(ctx context.Context, form map[str
utils.OkOrPanic(errors.NewBusinessError(503, "未找到查寝照片参数,请上传照片"), oops.WithContext(ctx).Wrap)
}
imageId, e := uuid.Parse(imgId)
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
previewUrl := imageuploader.UploadImageFromStorage(ctx, c.host, imageId, types.TaskTypeSleepCheck, c.client)
resultForm["signPhotoUrl"] = gvar.New(previewUrl)
// imgId 现在直接就是今日校园的 previewUrl,无需再上传
resultForm["signPhotoUrl"] = gvar.New(imgId)
}
// 判断是否存在二维码查寝
+11 -1
View File
@@ -1,8 +1,10 @@
package httpclient
import (
"resty.dev/v3"
"likedream-goframe/internal/utils"
"time"
"resty.dev/v3"
)
var Common = resty.New().
@@ -10,3 +12,11 @@ var Common = resty.New().
SetRetryCount(5).
SetTimeout(30*time.Second).
SetHeader("User-Agent", "likedream")
func init() {
if !utils.IsDebug() {
Common.SetDebug(false)
} else {
Common.SetProxy("http://127.0.0.1:9000")
}
}
+11
View File
@@ -0,0 +1,11 @@
package music
import (
"testing"
)
func Test_GetMusic(t *testing.T) {
ctx := t.Context()
result := MusicDownloader.Search(ctx, "天外来物")
t.Log(result)
}
@@ -0,0 +1,32 @@
package quesbank
// AIType AI类型
type AIType string
const (
AITypeChatGLM AIType = "chatglm" // 智谱AI
AITypeXingHuo AIType = "xinghuo" // 讯飞星火
AITypeTongYi AIType = "tongyi" // 通义千问
AITypeDouBao AIType = "doubao" // 豆包
AITypeOpenAI AIType = "openai" // OpenAI
AITypeOther AIType = "other" // 其他兼容OpenAI格式的API
)
// AIConfig AI配置
type AIConfig struct {
Type AIType `json:"type"` // AI类型
URL string `json:"url"` // API地址(Other类型必填)
Model string `json:"model"` // 模型名称
APIKey string `json:"apiKey"` // API密钥
}
// AIChatMessage 聊天消息
type AIChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
// AIChatMessages 聊天消息列表
type AIChatMessages struct {
Messages []AIChatMessage `json:"messages"`
}
+173
View File
@@ -0,0 +1,173 @@
package quesbank
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sync"
"time"
)
// AIAnswerService AI答题服务
type AIAnswerService struct {
config AIConfig
mu sync.Mutex
}
// NewAIAnswerService 创建AI答题服务
func NewAIAnswerService(config AIConfig) *AIAnswerService {
return &AIAnswerService{
config: config,
}
}
// GetAnswer 获取AI答案
func (s *AIAnswerService) GetAnswer(ctx context.Context, messages AIChatMessages) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
switch s.config.Type {
case AITypeChatGLM:
return s.chatGLMRequest(ctx, messages)
case AITypeXingHuo:
return s.xingHuoRequest(ctx, messages)
case AITypeTongYi:
return s.tongYiRequest(ctx, messages)
case AITypeDouBao:
return s.douBaoRequest(ctx, messages)
case AITypeOpenAI:
return s.openAIRequest(ctx, messages)
case AITypeOther:
return s.otherRequest(ctx, messages)
default:
return "", fmt.Errorf("unsupported AI type: %s", s.config.Type)
}
}
// chatGLMRequest 智谱AI请求
func (s *AIAnswerService) chatGLMRequest(ctx context.Context, messages AIChatMessages) (string, error) {
url := "https://open.bigmodel.cn/api/paas/v4/chat/completions"
model := s.config.Model
if model == "" {
model = "glm-4"
}
return s.doOpenAIStyleRequest(ctx, url, model, messages)
}
// xingHuoRequest 讯飞星火请求
func (s *AIAnswerService) xingHuoRequest(ctx context.Context, messages AIChatMessages) (string, error) {
url := "https://spark-api-open.xf-yun.com/v1/chat/completions"
model := s.config.Model
if model == "" {
model = "generalv3.5"
}
return s.doOpenAIStyleRequest(ctx, url, model, messages)
}
// tongYiRequest 通义千问请求
func (s *AIAnswerService) tongYiRequest(ctx context.Context, messages AIChatMessages) (string, error) {
url := "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"
model := s.config.Model
if model == "" {
model = "qwen-plus"
}
return s.doOpenAIStyleRequest(ctx, url, model, messages)
}
// douBaoRequest 豆包请求
func (s *AIAnswerService) douBaoRequest(ctx context.Context, messages AIChatMessages) (string, error) {
url := "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
return s.doOpenAIStyleRequest(ctx, url, s.config.Model, messages)
}
// openAIRequest OpenAI请求
func (s *AIAnswerService) openAIRequest(ctx context.Context, messages AIChatMessages) (string, error) {
url := "https://api.openai.com/v1/chat/completions"
model := s.config.Model
if model == "" {
model = "gpt-3.5-turbo"
}
return s.doOpenAIStyleRequest(ctx, url, model, messages)
}
// otherRequest 其他兼容OpenAI格式的API请求
func (s *AIAnswerService) otherRequest(ctx context.Context, messages AIChatMessages) (string, error) {
if s.config.URL == "" {
return "", errors.New("API URL is required for 'other' AI type")
}
return s.doOpenAIStyleRequest(ctx, s.config.URL, s.config.Model, messages)
}
// doOpenAIStyleRequest 执行OpenAI风格的API请求
func (s *AIAnswerService) doOpenAIStyleRequest(ctx context.Context, url, model string, messages AIChatMessages) (string, error) {
requestBody := map[string]interface{}{
"model": model,
"messages": messages.Messages,
}
jsonData, err := json.Marshal(requestBody)
if err != nil {
return "", fmt.Errorf("failed to marshal request: %v", err)
}
var lastErr error
for retry := 0; retry < 3; retry++ {
result, err := s.doHTTPRequest(ctx, url, jsonData)
if err == nil {
return result, nil
}
lastErr = err
time.Sleep(100 * time.Millisecond)
}
return "", lastErr
}
// doHTTPRequest 执行HTTP请求
func (s *AIAnswerService) doHTTPRequest(ctx context.Context, url string, jsonData []byte) (string, error) {
client := &http.Client{Timeout: 60 * time.Second}
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("failed to create request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+s.config.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to execute request: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %v", err)
}
var responseMap map[string]interface{}
if err := json.Unmarshal(body, &responseMap); err != nil {
return "", fmt.Errorf("failed to parse response: %v, body: %s", err, string(body))
}
choices, ok := responseMap["choices"].([]interface{})
if !ok || len(choices) == 0 {
return "", fmt.Errorf("AI response not found, response: %s", string(body))
}
message, ok := choices[0].(map[string]interface{})["message"].(map[string]interface{})
if !ok {
return "", errors.New("failed to parse message from response")
}
content, ok := message["content"].(string)
if !ok {
return "", errors.New("content field missing in response")
}
return content, nil
}
@@ -0,0 +1,97 @@
package quesbank
import (
"encoding/json"
"strings"
)
// ParseAIAnswer 解析AI答案
func ParseAIAnswer(aiResponse string) ([]string, error) {
aiResponse = strings.TrimSpace(aiResponse)
// 尝试直接解析JSON数组
var answers []string
if err := json.Unmarshal([]byte(aiResponse), &answers); err == nil {
return answers, nil
}
// 尝试从响应中提取JSON数组
start := strings.Index(aiResponse, "[")
end := strings.LastIndex(aiResponse, "]")
if start != -1 && end != -1 && end > start {
jsonStr := aiResponse[start : end+1]
if err := json.Unmarshal([]byte(jsonStr), &answers); err == nil {
return answers, nil
}
}
// 如果都失败,返回原始响应作为单个答案
return []string{aiResponse}, nil
}
// MatchAnswerToOption 将AI答案匹配到选项
func MatchAnswerToOption(answer string, options map[string]string) string {
// 精确匹配
for key, val := range options {
if val == answer || strings.TrimSpace(val) == strings.TrimSpace(answer) {
return key
}
}
// 模糊匹配(包含关系)
for key, val := range options {
if strings.Contains(val, answer) || strings.Contains(answer, val) {
return key
}
}
// 相似度匹配
bestKey := ""
bestScore := 0.0
for key, val := range options {
score := similarity(answer, val)
if score > bestScore {
bestScore = score
bestKey = key
}
}
if bestScore >= 0.5 {
return bestKey
}
return ""
}
// similarity 计算字符串相似度(简单实现)
func similarity(s1, s2 string) float64 {
if s1 == s2 {
return 1.0
}
if len(s1) == 0 || len(s2) == 0 {
return 0.0
}
// 使用最长公共子序列计算相似度
lcs := longestCommonSubsequence(s1, s2)
return float64(lcs) / float64(max(len(s1), len(s2)))
}
// longestCommonSubsequence 计算最长公共子序列长度
func longestCommonSubsequence(s1, s2 string) int {
m, n := len(s1), len(s2)
dp := make([][]int, m+1)
for i := range dp {
dp[i] = make([]int, n+1)
}
for i := 1; i <= m; i++ {
for j := 1; j <= n; j++ {
if s1[i-1] == s2[j-1] {
dp[i][j] = dp[i-1][j-1] + 1
} else {
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
}
}
}
return dp[m][n]
}
+151
View File
@@ -0,0 +1,151 @@
package quesbank
import (
"fmt"
"strings"
)
// BuildQuestionPrompt 构建题目提示词
func BuildQuestionPrompt(question Question) AIChatMessages {
var messages []AIChatMessage
switch question.Type {
case QuestionTypeSingleChoice:
messages = buildSingleChoicePrompt(question)
case QuestionTypeMultipleChoice:
messages = buildMultipleChoicePrompt(question)
case QuestionTypeTrueFalse:
messages = buildTrueFalsePrompt(question)
case QuestionTypeFillBlank:
messages = buildFillBlankPrompt(question)
case QuestionTypeShortAnswer:
messages = buildShortAnswerPrompt(question)
default:
messages = buildDefaultPrompt(question)
}
return AIChatMessages{Messages: messages}
}
// buildSingleChoicePrompt 构建单选题提示词
func buildSingleChoicePrompt(q Question) []AIChatMessage {
context := buildQuestionContext(q)
return []AIChatMessage{
{Role: "user", Content: "接下来你只需要回答选项对应内容即可...格式:[\"选项1\"]"},
{Role: "user", Content: "就算你不知道选什么也随机选...无需回答任何解释!!!"},
{Role: "user", Content: singleChoiceExample()},
{Role: "user", Content: fmt.Sprintf("题目类型:单选题\n题目内容:%s", context)},
}
}
// buildMultipleChoicePrompt 构建多选题提示词
func buildMultipleChoicePrompt(q Question) []AIChatMessage {
context := buildQuestionContext(q)
return []AIChatMessage{
{Role: "user", Content: "接下来你只需要回答选项对应内容即可...格式:[\"选项1\",\"选项2\"]"},
{Role: "user", Content: "就算你不知道选什么也随机选...无需回答任何解释!!!"},
{Role: "user", Content: multipleChoiceExample()},
{Role: "user", Content: fmt.Sprintf("题目类型:多选题\n题目内容:%s", context)},
}
}
// buildTrueFalsePrompt 构建判断题提示词
func buildTrueFalsePrompt(q Question) []AIChatMessage {
context := buildQuestionContext(q)
return []AIChatMessage{
{Role: "user", Content: "接下来你只需要回答\"正确\"或者\"错误\"即可...格式:[\"正确\"]"},
{Role: "user", Content: "就算你不知道选什么也随机选...无需回答任何解释!!!"},
{Role: "user", Content: trueFalseExample()},
{Role: "user", Content: fmt.Sprintf("题目类型:判断题\n题目内容:%s", context)},
}
}
// buildFillBlankPrompt 构建填空题提示词
func buildFillBlankPrompt(q Question) []AIChatMessage {
return []AIChatMessage{
{Role: "user", Content: "其中,\"( )\"相关字样的地方是你需要填写答案的地方...格式:[\"答案1\",\"答案2\"]"},
{Role: "user", Content: "就算你不知道填什么也随机填...无需回答任何解释!!!"},
{Role: "user", Content: fillBlankExample()},
{Role: "user", Content: fmt.Sprintf("题目类型:填空题\n题目内容:%s", q.Text)},
}
}
// buildShortAnswerPrompt 构建简答题提示词
func buildShortAnswerPrompt(q Question) []AIChatMessage {
return []AIChatMessage{
{Role: "user", Content: "这是一个简答题...格式:[\"答案\"],注意不要拆分答案!!!"},
{Role: "user", Content: "就算你不知道答什么也随机答...无需回答任何解释!!!"},
{Role: "user", Content: shortAnswerExample()},
{Role: "user", Content: fmt.Sprintf("题目类型:简答题\n题目内容:%s", q.Text)},
}
}
// buildDefaultPrompt 构建默认提示词
func buildDefaultPrompt(q Question) []AIChatMessage {
context := buildQuestionContext(q)
return []AIChatMessage{
{Role: "user", Content: "请回答以下题目,格式:[\"答案\"]"},
{Role: "user", Content: context},
}
}
// buildQuestionContext 构建题目上下文
func buildQuestionContext(q Question) string {
var sb strings.Builder
sb.WriteString(q.Text)
sb.WriteString("\n")
for key, val := range q.Options {
sb.WriteString(fmt.Sprintf("%s. %s\n", key, val))
}
return sb.String()
}
func singleChoiceExample() string {
return `比如:
题目类型:单选题
题目内容:新中国是什么时候成立的
A. 1949年10月5日
B. 1949年10月1日
C. 1949年09月1日
D. 2002年10月1日
那么你应该回答选项B的内容:"["1949年10月1日"]"`
}
func multipleChoiceExample() string {
return `比如:
题目类型:多选题
题目内容:马克思关于资本积累的学说是剩余价值理论的重要组成部分...
A. 资本主义扩大再生产的源泉
B. 资本有机构成呈现不断降低趋势的根本原因
C. 社会财富占有两极分化的重要原因
D. 资本主义社会失业现象产生的根源
那么你应该回答选项A、C、D的内容:"["资本主义扩大再生产的源泉","社会财富占有两极分化的重要原因","资本主义社会失业现象产生的根源"]"`
}
func trueFalseExample() string {
return `比如:
题目类型:判断题
题目内容:新中国是1949年10月1日成立的吗?
A. 正确
B. 错误
那么你应该回答选项A的内容:"["正确"]"`
}
func fillBlankExample() string {
return `比如:
题目类型:填空题
题目内容:新中国成立于( )年。
那么你应该回答:"["1949"]"`
}
func shortAnswerExample() string {
return `比如:
题目类型:简答题
题目内容:请简述新中国成立的历史意义
那么你应该回答:"["新中国的成立标志着中国人民从此站起来了,结束了半殖民地半封建社会的历史..."]"`
}
@@ -0,0 +1 @@
package quesbank
+23
View File
@@ -0,0 +1,23 @@
package quesbank
// QuestionType 题目类型
type QuestionType int
const (
QuestionTypeSingleChoice QuestionType = 0 // 单选题
QuestionTypeMultipleChoice QuestionType = 1 // 多选题
QuestionTypeFillBlank QuestionType = 2 // 填空题
QuestionTypeTrueFalse QuestionType = 3 // 判断题
QuestionTypeShortAnswer QuestionType = 4 // 简答题
)
// Question 通用题目结构
type Question 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"` // 填空题答案
}
+10 -5
View File
@@ -166,13 +166,18 @@ func (c *Course) Refresh(ctx context.Context, c2 model.Course, ch model.Chapter,
}
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 == "" {
// 网站已更新,chunk-vendors 不再存在。
// 现在从 HTML 中找到任一 chunk-*.js 的 URL,提取其 base 路径和 hash,
// 然后构造包含 src_views_home_index_vue 的组合 chunk 文件 URL。
re := regexp.MustCompile(`(https://[^"'\s]+/)chunk-(?:libs|elementUI)\.([0-9a-f]+)\.js`)
match := re.FindStringSubmatch(str)
if len(match) < 3 {
return ""
}
return strings.Replace(match, "chunk-vendors", "src_views_home_index_vue", 1)
basePath := match[1]
hash := match[2]
// 用已知的组合 chunk 名称拼出 URL:该 chunk 同时包含首页和 CourseList 组件
return basePath + "src_views_home_index_vue-src_views_noPermission_index_vue-src_views_components_CourseList_vue." + hash + ".js"
}
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{
+147
View File
@@ -0,0 +1,147 @@
package xuexitong
import (
"context"
"errors"
"fmt"
"likedream-goframe/internal/application/quesbank"
"likedream-goframe/internal/application/xuexitong/entity"
"likedream-goframe/internal/framework/logger"
"strings"
"go.uber.org/zap"
)
// AIType 类型别名,引用 quesbank 包
type AIType = quesbank.AIType
// AI类型常量
const (
AITypeChatGLM = quesbank.AITypeChatGLM
AITypeXingHuo = quesbank.AITypeXingHuo
AITypeTongYi = quesbank.AITypeTongYi
AITypeDouBao = quesbank.AITypeDouBao
AITypeOpenAI = quesbank.AITypeOpenAI
AITypeOther = quesbank.AITypeOther
)
// AIConfig 类型别名
type AIConfig = quesbank.AIConfig
// convertToQuesbankQuestion 将学习通题目转换为通用题目格式
func convertToQuesbankQuestion(q entity.WorkQuestion) quesbank.Question {
return quesbank.Question{
Qid: q.Qid,
Type: quesbank.QuestionType(q.Type),
Text: q.Text,
Options: q.Options,
Answers: q.Answers,
BlankCount: q.BlankCount,
BlankAnswer: q.BlankAnswer,
}
}
// AIAnswerWork AI答题处理作业
func (c *XuexitongCourse) AIAnswerWork(ctx context.Context, workDto *entity.WorkDto, aiConfig AIConfig) error {
aiService := quesbank.NewAIAnswerService(aiConfig)
for i := range workDto.Questions {
q := &workDto.Questions[i]
// 转换为通用题目格式
question := convertToQuesbankQuestion(*q)
// 构建提示词
prompt := quesbank.BuildQuestionPrompt(question)
// 获取AI答案
aiResponse, err := aiService.GetAnswer(ctx, prompt)
if err != nil {
logger.Logger.Error("AI答题失败",
zap.String("qid", q.Qid),
zap.String("text", q.Text),
zap.Error(err))
continue
}
// 解析答案
answers, err := quesbank.ParseAIAnswer(aiResponse)
if err != nil {
logger.Logger.Error("解析AI答案失败",
zap.String("qid", q.Qid),
zap.String("aiResponse", aiResponse),
zap.Error(err))
continue
}
// 根据题型处理答案
switch q.Type {
case entity.QuestionTypeSingleChoice, entity.QuestionTypeMultipleChoice:
var matchedAnswers []string
for _, ans := range answers {
matched := quesbank.MatchAnswerToOption(ans, q.Options)
if matched != "" {
matchedAnswers = append(matchedAnswers, matched)
}
}
q.Answers = matchedAnswers
case entity.QuestionTypeTrueFalse:
if len(answers) > 0 {
q.Answers = answers
}
case entity.QuestionTypeFillBlank:
q.BlankAnswer = make(map[string]string)
for idx, ans := range answers {
key := fmt.Sprintf("第%d空", idx+1)
q.BlankAnswer[key] = ans
}
q.BlankCount = len(answers)
case entity.QuestionTypeShortAnswer:
if len(answers) > 0 {
q.Answers = []string{strings.Join(answers, "")}
}
}
logger.Logger.Debug("AI答题成功",
zap.String("qid", q.Qid),
zap.Int("type", int(q.Type)),
zap.Strings("answers", q.Answers))
}
return nil
}
// AutoCompleteWorkWithAI 使用AI自动完成作业
func (c *XuexitongCourse) AutoCompleteWorkWithAI(ctx context.Context, p *entity.PointWorkDto, aiConfig AIConfig, autoSubmit bool) (string, error) {
// 获取作业题目
workDto, err := c.FetchWorkQuestions(ctx, p)
if err != nil {
return "", fmt.Errorf("获取作业题目失败: %w", err)
}
if workDto.IsPassed {
return "", errors.New("作业已完成")
}
if len(workDto.Questions) == 0 {
return "", errors.New("作业无题目")
}
logger.Logger.Info("开始AI答题",
zap.String("title", workDto.Title),
zap.Int("questionCount", len(workDto.Questions)))
// AI答题
if err := c.AIAnswerWork(ctx, workDto, aiConfig); err != nil {
return "", fmt.Errorf("AI答题失败: %w", err)
}
// 提交答案
result, err := c.SubmitWorkAnswers(ctx, workDto, !autoSubmit)
if err != nil {
return "", fmt.Errorf("提交答案失败: %w", err)
}
return result, nil
}
@@ -11,6 +11,7 @@ const (
PointTypeWork PointType = "insertwork"
PointTypeHyperlink PointType = "insertlink"
PointTypeLive PointType = "insertlive"
PointTypeExam PointType = "insertexam" // 考试任务点
)
// PointVideoDto 视频任务点
@@ -85,6 +86,25 @@ type PointHyperlinkDto struct {
IsJob bool // 是否是任务点
}
// PointExamDto 考试任务点
type PointExamDto struct {
CardIndex int
CourseID string
ClassID string
KnowledgeID int
Cpi string
ExamID string // 考试ID
Title string // 考试标题
JobID string // 任务ID
Enc string // 加密参数
PaperId string // 试卷ID
ExamRelationId string // 考试关联ID
TotalScore float64 // 总分
Duration int // 考试时长(分钟)
IsPassed bool // 是否已完成
IsJob bool // 是否是任务点
}
// PointLiveDto 直播任务点
type PointLiveDto struct {
CardIndex int
@@ -187,6 +207,7 @@ const (
ChapterPointTypeWork ChapterPointType = 3 // 作业
ChapterPointTypeHyperlink ChapterPointType = 4 // 外链
ChapterPointTypeLive ChapterPointType = 5 // 直播
ChapterPointTypeExam ChapterPointType = 6 // 考试
)
// ChapterPoint 统一的章节任务点结构(与Card一一对应)
@@ -245,6 +266,13 @@ type ChapterPoint struct {
LiveSetEnc string
LiveSwDsEnc string
VideoCompletePercent float64
// 考试相关字段
ExamID string // 考试ID
PaperId string // 试卷ID
ExamRelationId string // 考试关联ID
TotalScore float64 // 总分
ExamDuration int // 考试时长(分钟)
}
// QuestionType 题目类型
@@ -271,7 +299,8 @@ type WorkQuestion struct {
// WorkDto 作业详情
type WorkDto struct {
Title string `json:"title"` // 作业标题
Title string `json:"title"` // 作业标题
IsPassed bool `json:"isPassed"`
Cpi string `json:"cpi"` // cpi
JobId string `json:"jobId"` // 任务ID
WorkId string `json:"workId"` // 作业ID
@@ -0,0 +1,356 @@
package xuexitong
import (
"context"
"fmt"
"likedream-goframe/internal/application/xuexitong/entity"
"likedream-goframe/internal/framework/logger"
"go.uber.org/zap"
)
// AutoStudyByCourseID 通过课程ID自动刷课(定时任务主入口)
func (c *XuexitongCourse) AutoStudyByCourseID(ctx context.Context, courseID string) error {
return c.AutoStudyByCourseIDWithOptions(ctx, courseID, nil)
}
// AutoStudyByCourseIDWithOptions 通过课程ID自动刷课(带选项)
func (c *XuexitongCourse) AutoStudyByCourseIDWithOptions(ctx context.Context, courseID string, opts *AutoStudyOptions) error {
logger.Logger.Info(fmt.Sprintf("开始刷课,课程ID: %s", courseID))
courses := c.PullCourseList(ctx)
if len(courses) == 0 {
return fmt.Errorf("没有找到任何课程")
}
var targetCourse *entity.XueXiTCourse
for _, course := range courses {
if course.CourseID == courseID {
targetCourse = &course
break
}
}
if targetCourse == nil {
return fmt.Errorf("未找到课程ID为 %s 的课程", courseID)
}
if !targetCourse.IsStart {
return fmt.Errorf("课程 %s 未开课", targetCourse.CourseName)
}
if targetCourse.State != 0 {
return fmt.Errorf("课程 %s 已结束", targetCourse.CourseName)
}
if opts != nil {
return c.AutoStudyCourseWithOptions(ctx, *targetCourse, opts)
}
return c.AutoStudyCourse(ctx, *targetCourse)
}
// AutoStudyAllCourses 自动学习所有课程
func (c *XuexitongCourse) AutoStudyAllCourses(ctx context.Context) error {
logger.Logger.Info("开始自动刷所有课程任务")
courses := c.PullCourseList(ctx)
if len(courses) == 0 {
logger.Logger.Warn("没有找到任何课程")
return nil
}
logger.Logger.Info(fmt.Sprintf("找到 %d 个课程", len(courses)))
for i, course := range courses {
logger.Logger.Info(fmt.Sprintf("开始刷课 [%d/%d]: %s", i+1, len(courses), course.CourseName))
if !course.IsStart {
logger.Logger.Info(fmt.Sprintf("课程 %s 未开课,跳过", course.CourseName))
continue
}
if course.State != 0 {
logger.Logger.Info(fmt.Sprintf("课程 %s 已结束,跳过", course.CourseName))
continue
}
err := c.AutoStudyCourse(ctx, course)
if err != nil {
logger.Logger.Error(fmt.Sprintf("刷课失败: %s", course.CourseName), zap.Error(err))
continue
}
logger.Logger.Info(fmt.Sprintf("完成刷课 [%d/%d]: %s", i+1, len(courses), course.CourseName))
}
logger.Logger.Info("所有课程刷课任务完成")
return nil
}
// AutoStudyCourse 自动学习指定课程的所有章节
func (c *XuexitongCourse) AutoStudyCourse(ctx context.Context, course entity.XueXiTCourse) error {
return c.AutoStudyCourseWithOptions(ctx, course, nil)
}
// AutoStudyChapter 自动学习指定章节的所有任务点
func (c *XuexitongCourse) AutoStudyChapter(ctx context.Context, course entity.XueXiTCourse, chapter entity.XueXiTCourseChapter) error {
if err := c.EnterChapterForwardCall(ctx, course, chapter.ID); err != nil {
logger.Logger.Warn("EnterChapterForwardCall失败", zap.Error(err))
}
cards, points, err := c.FetchChapterCards(ctx, course, chapter.ID)
if err != nil {
return fmt.Errorf("获取章节任务点失败: %w", err)
}
if len(cards) == 0 || len(points) == 0 {
logger.Logger.Info(fmt.Sprintf("章节 %s 没有任务点", chapter.Name))
return nil
}
for i := range points {
if points[i].Type == entity.ChapterPointTypeVideo {
logger.Logger.Debug("视频任务点参数",
zap.String("objectID", points[i].ObjectID),
zap.String("jobID", points[i].JobID),
zap.String("otherInfo", points[i].OtherInfo),
zap.String("attDurationEnc", points[i].AttDurationEnc),
zap.String("videoFaceCaptureEnc", points[i].VideoFaceCaptureEnc),
zap.Int("FID", points[i].FID))
}
}
for i := range points {
if err := c.checkTimeRestriction(); err != nil {
// 更新任务状态为waiting
logger.Logger.Info("时间限制,暂停刷课", zap.String("chapter", chapter.Name))
return err
}
point := &points[i]
if point.Type == 0 {
continue
}
switch point.Type {
case entity.ChapterPointTypeVideo:
if err := c.AutoPlayVideoByPoint(ctx, course, point); err != nil {
logger.Logger.Error("视频刷课失败", zap.Error(err), zap.String("title", point.Title))
}
case entity.ChapterPointTypeDocument:
if err := c.AutoReadDocumentByPoint(ctx, course, point); err != nil {
logger.Logger.Error("文档刷课失败", zap.Error(err), zap.String("title", point.Title))
}
case entity.ChapterPointTypeWork:
// 作业需要AI配置,这里暂时跳过,使用 AutoStudyChapterWithAI 方法处理
logger.Logger.Info("作业任务点需要AI配置,跳过", zap.String("title", point.Title))
case entity.ChapterPointTypeHyperlink:
if err := c.AutoCompleteHyperlinkByPoint(ctx, course, point); err != nil {
logger.Logger.Error("外链刷课失败", zap.Error(err), zap.String("title", point.Title))
}
case entity.ChapterPointTypeLive:
if err := c.AutoWatchLiveByPoint(ctx, course, point); err != nil {
logger.Logger.Error("直播刷课失败", zap.Error(err), zap.String("title", point.Title))
}
}
}
return nil
}
// AutoStudyOptions 自动学习选项
type AutoStudyOptions struct {
AIConfig *AIConfig // AI配置(用于作业答题)
AutoSubmit bool // 是否自动提交作业
Thread int // 线程数
}
// AutoStudyCourseWithOptions 带选项的自动学习课程
func (c *XuexitongCourse) AutoStudyCourseWithOptions(ctx context.Context, course entity.XueXiTCourse, opts *AutoStudyOptions) error {
logger.Logger.Info(fmt.Sprintf("开始刷课程: %s", course.CourseName))
chapters := c.PullCourseChapterList(ctx, course)
if len(chapters) == 0 {
logger.Logger.Warn(fmt.Sprintf("课程 %s 没有章节", course.CourseName))
return nil
}
_, err := c.FetchChapterPointStatus(ctx, course, chapters)
if err != nil {
logger.Logger.Warn("获取章节状态失败", zap.Error(err))
}
// 获取线程数配置,默认为1(单线程)
threadCount := 1
if opts != nil && opts.Thread > 0 {
threadCount = opts.Thread
}
// 如果是单线程,使用原有逻辑
if threadCount == 1 {
for _, chapter := range chapters {
if err := c.checkTimeRestriction(); err != nil {
logger.Logger.Info("时间限制,暂停刷课", zap.String("course", course.CourseName))
return err
}
if chapter.JobCount > 0 && chapter.PointTotal > chapter.PointFinish {
err := c.AutoStudyChapterWithOptions(ctx, course, chapter, opts)
if err != nil {
if err == ErrTimeRestriction {
return err
}
logger.Logger.Error(fmt.Sprintf("刷章节失败: %s", chapter.Name), zap.Error(err))
continue
}
}
}
} else {
// 多线程并发处理
logger.Logger.Info(fmt.Sprintf("使用 %d 线程并发刷课", threadCount))
// 筛选需要处理的章节
var targetChapters []entity.XueXiTCourseChapter
for _, chapter := range chapters {
if chapter.JobCount > 0 && chapter.PointTotal > chapter.PointFinish {
targetChapters = append(targetChapters, chapter)
}
}
if len(targetChapters) == 0 {
logger.Logger.Info("没有需要处理的章节")
return nil
}
// 创建信号量控制并发数
semaphore := make(chan struct{}, threadCount)
errChan := make(chan error, len(targetChapters))
doneChan := make(chan bool, len(targetChapters))
// 并发处理章节
for _, chapter := range targetChapters {
semaphore <- struct{}{} // 获取信号量
go func(ch entity.XueXiTCourseChapter) {
defer func() {
<-semaphore // 释放信号量
doneChan <- true
}()
if err := c.checkTimeRestriction(); err != nil {
logger.Logger.Info("时间限制,暂停刷课", zap.String("chapter", ch.Name))
errChan <- err
return
}
err := c.AutoStudyChapterWithOptions(ctx, course, ch, opts)
if err != nil {
if err == ErrTimeRestriction {
errChan <- err
return
}
logger.Logger.Error(fmt.Sprintf("刷章节失败: %s", ch.Name), zap.Error(err))
}
}(chapter)
}
// 等待所有任务完成
for i := 0; i < len(targetChapters); i++ {
<-doneChan
}
close(errChan)
// 检查是否有时间限制错误
for err := range errChan {
if err == ErrTimeRestriction {
return err
}
}
}
logger.Logger.Info(fmt.Sprintf("课程完成: %s", course.CourseName))
return nil
}
// AutoStudyChapterWithOptions 带选项的自动学习章节
func (c *XuexitongCourse) AutoStudyChapterWithOptions(ctx context.Context, course entity.XueXiTCourse, chapter entity.XueXiTCourseChapter, opts *AutoStudyOptions) error {
if err := c.EnterChapterForwardCall(ctx, course, chapter.ID); err != nil {
logger.Logger.Warn("EnterChapterForwardCall失败", zap.Error(err))
}
cards, points, err := c.FetchChapterCards(ctx, course, chapter.ID)
if err != nil {
return fmt.Errorf("获取章节任务点失败: %w", err)
}
if len(cards) == 0 || len(points) == 0 {
logger.Logger.Info(fmt.Sprintf("章节 %s 没有任务点", chapter.Name))
return nil
}
//logger.Logger.Info("开始处理章节任务点",
// zap.String("chapter", chapter.Name),
// zap.Int("pointCount", len(points)))
for i := range points {
if err := c.checkTimeRestriction(); err != nil {
logger.Logger.Info("时间限制,暂停刷课", zap.String("chapter", chapter.Name))
return err
}
point := &points[i]
if point.IsPassed {
logger.Logger.Debug("任务点已通过,跳过", zap.String("title", point.Title))
continue
}
if point.Type == 0 {
logger.Logger.Debug("跳过类型为0的任务点", zap.Int("index", i))
continue
}
//logger.Logger.Info("处理任务点",
// zap.Int("index", i),
// zap.Any("type", point.Type),
// zap.String("title", point.Title),
// zap.String("jobID", point.JobID),
// zap.Bool("isPassed", point.IsPassed))
switch point.Type {
case entity.ChapterPointTypeVideo:
if err := c.AutoPlayVideoByPoint(ctx, course, point); err != nil {
logger.Logger.Error("视频刷课失败", zap.Error(err), zap.String("title", point.Title))
}
case entity.ChapterPointTypeDocument:
if err := c.AutoReadDocumentByPoint(ctx, course, point); err != nil {
logger.Logger.Error("文档刷课失败", zap.Error(err), zap.String("title", point.Title))
}
case entity.ChapterPointTypeWork:
logger.Logger.Info("发现作业任务点",
zap.String("title", point.Title),
zap.String("workID", point.WorkID),
zap.String("jobID", point.JobID),
zap.Int("cardIndex", point.CardIndex),
zap.Int("knowledgeID", point.KnowledgeID),
zap.String("cpi", point.Cpi),
zap.Bool("hasAIConfig", opts != nil && opts.AIConfig != nil))
if opts != nil && opts.AIConfig != nil {
if err := c.AutoCompleteWorkByPoint(ctx, course, point, opts.AIConfig, opts.AutoSubmit); err != nil {
logger.Logger.Error("作业刷课失败", zap.Error(err), zap.String("title", point.Title))
}
} else {
logger.Logger.Info("作业任务点需要AI配置,跳过", zap.String("title", point.Title))
}
case entity.ChapterPointTypeHyperlink:
if err := c.AutoCompleteHyperlinkByPoint(ctx, course, point); err != nil {
logger.Logger.Error("外链刷课失败", zap.Error(err), zap.String("title", point.Title))
}
case entity.ChapterPointTypeLive:
if err := c.AutoWatchLiveByPoint(ctx, course, point); err != nil {
logger.Logger.Error("直播刷课失败", zap.Error(err), zap.String("title", point.Title))
}
}
}
return nil
}
@@ -10,8 +10,11 @@ import (
)
// ApiVerifyCodeBytes 获取学习通验证码图片
func ApiVerifyCodeBytes(client *resty.Client) ([]byte, error) {
urlStr := "https://mooc1-api.chaoxing.com/processVerifyPng.ac?t=" + fmt.Sprintf("%d", time.Now().UnixMilli())
func ApiVerifyCodeBytes(client *resty.Client, prefixUrl string) ([]byte, error) {
if prefixUrl == "" {
prefixUrl = "https://mooc1-api.chaoxing.com"
}
urlStr := prefixUrl + "/processVerifyPng.ac?t=" + fmt.Sprintf("%d", time.Now().UnixMilli())
req := client.R()
req.Header.Add("User-Agent", GetUA("mobile"))
@@ -31,23 +34,27 @@ func ApiVerifyCodeBytes(client *resty.Client) ([]byte, error) {
}
// ApiPassVerifyCode 提交学习通验证码
func ApiPassVerifyCode(client *resty.Client, code string, retryCount int) bool {
func ApiPassVerifyCode(client *resty.Client, code string, prefixUrl string, retryCount int) bool {
if retryCount < 0 {
return false
}
urlStr := "https://mooc1-api.chaoxing.com/html/processVerify.ac"
if prefixUrl == "" {
prefixUrl = "https://mooc1-api.chaoxing.com"
}
//urlStr := "https://mooc1-api.chaoxing.com/html/processVerify.ac"
urlStr := prefixUrl + "/html/processVerify.ac"
client.SetRedirectPolicy(resty.NoRedirectPolicy())
req := client.R()
req.SetMultipartField("ucode", "", "", strings.NewReader(code))
req.SetMultipartField("app", "", "", strings.NewReader("0"))
req.SetMultipartField("app", "", "", strings.NewReader("1"))
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)
return ApiPassVerifyCode(client, code, prefixUrl, retryCount-1)
}
client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
return res.StatusCode() == 302
return res.StatusCode() == 302 || strings.Contains(res.String(), "解锁成功")
}
@@ -0,0 +1,764 @@
package xuexitong
import (
"context"
"encoding/json"
"fmt"
"likedream-goframe/internal/application/captcha"
"likedream-goframe/internal/application/xuexitong/entity"
errors2 "likedream-goframe/internal/common/errors"
"likedream-goframe/internal/framework/logger"
"likedream-goframe/internal/utils"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/avast/retry-go/v4"
"github.com/samber/oops"
"go.uber.org/zap"
"golang.org/x/net/html"
)
// PullCourseChapterList 拉取学习通课程章节列表并返回
func (c *XuexitongCourse) PullCourseChapterList(ctx context.Context, course entity.XueXiTCourse) []entity.XueXiTCourseChapter {
var chapterString string
key, _ := strconv.Atoi(course.Key)
e := retry.Do(func() error {
params := url.Values{}
params.Add("id", strconv.Itoa(key))
params.Add("personid", strconv.Itoa(course.Cpi))
params.Add("fields", "id,bbsid,classscore,isstart,allowdownload,chatid,name,state,isfiled,visiblescore,hideclazz,begindate,forbidintoclazz,coursesetting.fields(id,courseid,hiddencoursecover,coursefacecheck),course.fields(id,belongschoolid,name,infocontent,objectid,app,bulletformat,mappingcourseid,imageurl,teacherfactor,jobcount,knowledge.fields(id,name,indexOrder,parentnodeid,status,isReview,layer,label,jobcount,begintime,endtime,attachment.fields(id,type,objectid,extension).type(video)))")
params.Add("view", "json")
get, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("mobile"),
"Accept-Language": "zh_CN",
"Host": "mooc1-api.chaoxing.com",
"Connection": "Keep-Alive",
}).Get(ApiPullChapter + "?" + params.Encode())
if err != nil {
return err
}
chapterString = get.String()
return nil
}, retry.Attempts(3), retry.Delay(5*time.Second))
utils.OkOrPanic(e, oops.WithContext(ctx).With("chapterString", chapterString).Wrap)
var chapterMap map[string]interface{}
err := json.Unmarshal([]byte(chapterString), &chapterMap)
if err != nil {
utils.OkOrPanic(errors2.NewBusinessError(503, "解析章节数据失败").WithInnerError(err), oops.WithContext(ctx).Wrap)
}
dataArr, ok := chapterMap["data"].([]interface{})
if !ok || len(dataArr) == 0 {
return nil
}
firstData, ok := dataArr[0].(map[string]interface{})
if !ok {
return nil
}
courseData, ok := firstData["course"].(map[string]interface{})
if !ok {
return nil
}
courseDataArr, ok := courseData["data"].([]interface{})
if !ok || len(courseDataArr) == 0 {
return nil
}
firstCourse, ok := courseDataArr[0].(map[string]interface{})
if !ok {
return nil
}
knowledgeData, ok := firstCourse["knowledge"].(map[string]interface{})
if !ok {
return nil
}
knowledgeArr, ok := knowledgeData["data"].([]interface{})
if !ok {
return nil
}
var chapters []entity.XueXiTCourseChapter
for _, kd := range knowledgeArr {
kdMap, ok := kd.(map[string]interface{})
if !ok {
continue
}
chapter := entity.XueXiTCourseChapter{
ID: int(kdMap["id"].(float64)),
Name: kdMap["name"].(string),
ParentNodeID: int(kdMap["parentnodeid"].(float64)),
Layer: int(kdMap["layer"].(float64)),
Label: kdMap["label"].(string),
IndexOrder: int(kdMap["indexorder"].(float64)),
JobCount: int(kdMap["jobcount"].(float64)),
Status: kdMap["status"].(string),
IsReview: int(kdMap["isreview"].(float64)),
}
chapters = append(chapters, chapter)
}
sort.Slice(chapters, func(i, j int) bool {
iLabelParts := strings.Split(chapters[i].Label, ".")
jLabelParts := strings.Split(chapters[j].Label, ".")
for k := range iLabelParts {
if k >= len(jLabelParts) {
return false
}
iv, _ := strconv.Atoi(iLabelParts[k])
jv, _ := strconv.Atoi(jLabelParts[k])
if iv != jv {
return iv < jv
}
}
return len(iLabelParts) < len(jLabelParts)
})
return chapters
}
// FetchChapterPointStatus 获取章节任务点状态
func (c *XuexitongCourse) FetchChapterPointStatus(ctx context.Context, course entity.XueXiTCourse, chapters []entity.XueXiTCourseChapter) (entity.ChapterPointStatus, error) {
var nodes []string
for _, ch := range chapters {
nodes = append(nodes, strconv.Itoa(ch.ID))
}
ts := time.Now().UnixMilli()
classID, _ := strconv.Atoi(course.Key)
courseID, _ := strconv.Atoi(course.CourseID)
values := url.Values{
"view": {"json"},
"nodes": {strings.Join(nodes, ",")},
"clazzid": {strconv.Itoa(classID)},
"time": {strconv.FormatInt(ts, 10)},
"userid": {c.userID},
"cpi": {strconv.Itoa(course.Cpi)},
"courseid": {strconv.Itoa(courseID)},
}
logger.Logger.Debug("FetchChapterPointStatus 请求参数",
zap.String("userID", c.userID),
zap.Int("classID", classID),
zap.Int("courseID", courseID),
zap.Int("cpi", course.Cpi),
zap.Int("nodesCount", len(nodes)))
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("mobile"),
"Content-Type": "application/x-www-form-urlencoded",
}).SetBody(values.Encode()).Post(ApiChapterPoint)
if err != nil {
return err
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return nil, err
}
logger.Logger.Debug("FetchChapterPointStatus 响应", zap.String("body", body))
var status entity.ChapterPointStatus
err = json.Unmarshal([]byte(body), &status)
if err != nil {
logger.Logger.Error("FetchChapterPointStatus 解析失败", zap.Error(err), zap.String("body", body))
return nil, err
}
for i := range chapters {
chapterIDStr := strconv.Itoa(chapters[i].ID)
if pointData, exists := status[chapterIDStr]; exists {
chapters[i].PointTotal = pointData.UnFinishCount + pointData.FinishCount
chapters[i].PointFinish = pointData.FinishCount
}
}
return status, nil
}
// EnterChapterForwardCall 进入章节前的前置调用
func (c *XuexitongCourse) EnterChapterForwardCall(ctx context.Context, course entity.XueXiTCourse, chapterID int) error {
urlStr := fmt.Sprintf("%s?courseId=%s&clazzid=%s&chapterId=%d&cpi=%d&verificationcode=&mooc2=1&toComputer=false&microTopicId=0",
ApiEnterChapter, course.CourseID, course.Key, chapterID, course.Cpi)
_, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("mobile"),
}).Get(urlStr)
return err
}
// FetchChapterCards 获取章节任务点卡片(JSON方式)- 支持所有任务点类型
func (c *XuexitongCourse) FetchChapterCards(ctx context.Context, course entity.XueXiTCourse, chapterID int) ([]entity.Card, []entity.ChapterPoint, error) {
var body string
courseID, _ := strconv.Atoi(course.CourseID)
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("mobile"),
"Accept-Language": "zh_CN",
}).SetQueryParams(map[string]string{
"id": strconv.Itoa(chapterID),
"courseid": strconv.Itoa(courseID),
"fields": "id,parentnodeid,indexorder,label,layer,name,begintime,createtime,lastmodifytime,status,jobUnfinishedCount,clickcount,openlock,card.fields(id,knowledgeid,title,knowledgeTitile,description,cardorder).contentcard(all)",
"view": "json",
"token": "4faa8662c59590c6f43ae9fe5b002b42",
"_time": strconv.FormatInt(time.Now().UnixMilli(), 10),
}).Get(ApiChapterCards)
if err != nil {
return err
}
// 触发202 需要重新登录获取cookie
if resp.StatusCode() == 202 {
}
body = resp.String()
if strings.Contains(body, "请输入验证码") {
logger.Logger.Warn("触发验证码,正在进行AI智能识别绕过.....")
e := retry.Do(func() error {
bytes, e := ApiVerifyCodeBytes(c.client, "")
if e != nil {
return e
}
codeResult := captcha.ApiSemiOCRVerification(bytes, 1, 23)
time.Sleep(5 * time.Second)
if ApiPassVerifyCode(c.client, codeResult, "", 5) {
return nil
}
return errors2.NewBusinessError(503, "验证码识别失败")
}, retry.Attempts(5), retry.Delay(5*time.Second))
return e
}
statusCode := resp.StatusCode()
if statusCode == 500 || statusCode == 202 || statusCode == 400 || statusCode == 403 {
return errors2.NewBusinessError(statusCode, fmt.Sprintf("获取任务点失败,状态码: %d", statusCode))
}
return nil
}, retry.Attempts(3), retry.Delay(5*time.Second))
if err != nil {
return nil, nil, err
}
var apiResp entity.CardAPIResponse
if err := json.Unmarshal([]byte(body), &apiResp); err != nil {
return nil, nil, err
}
if len(apiResp.Data) == 0 {
return nil, nil, nil
}
dataItem := apiResp.Data[0]
cards := dataItem.Card.Data
var points []entity.ChapterPoint
cpiStr := strconv.Itoa(course.Cpi)
for cardIndex, card := range cards {
logger.Logger.Debug("处理卡片",
zap.Int("cardIndex", cardIndex),
zap.Int("cardID", card.ID),
zap.Int("cardKnowledgeID", card.KnowledgeID),
zap.String("cardTitle", card.Title),
zap.Int("descriptionLength", len(card.Description)))
if card.Description == "" {
continue
}
iframes, err := c.parseIframeData(card.Description)
if err != nil {
logger.Logger.Debug("解析卡片失败", zap.Error(err))
continue
}
if iframes == nil {
continue
}
for _, iframe := range iframes {
if !iframe.HasData {
continue
}
pointType, ok := iframe.Other["module"]
logger.Logger.Debug("解析到任务点", zap.String("module", pointType), zap.Bool("hasModule", ok), zap.Any("data", iframe.Data))
if !ok {
continue
}
point := entity.ChapterPoint{
CardIndex: cardIndex,
KnowledgeID: card.KnowledgeID,
CourseID: course.CourseID,
ClassID: course.Key,
Cpi: cpiStr,
Title: card.Title,
}
switch pointType {
case "insertvideo", "insertaudio":
objectID, ok := iframe.Data["objectid"].(string)
if !ok || objectID == "" {
continue
}
jobID, _ := iframe.Data["_jobid"].(string)
point.Type = entity.ChapterPointTypeVideo
point.ObjectID = objectID
point.JobID = jobID
cords2, err := c.FetchChapterCards2(ctx, course, cardIndex, card.KnowledgeID)
if err == nil && cords2 != nil {
c.fillVideoPointFromCards2(cords2, &point, objectID)
}
case "insertdoc", "document":
objectID, _ := iframe.Data["objectid"].(string)
jobID, _ := iframe.Data["_jobid"].(string)
if jobID == "" {
continue
}
point.Type = entity.ChapterPointTypeDocument
point.ObjectID = objectID
point.JobID = jobID
point.DocumentType = "document"
case "insertbook", "insertreadV2":
jobID, _ := iframe.Data["_jobid"].(string)
if jobID == "" {
continue
}
point.Type = entity.ChapterPointTypeDocument
point.JobID = jobID
point.DocumentType = "insertbook"
case "work", "insertwork":
workID, _ := iframe.Data["workid"].(string)
schoolID, _ := iframe.Data["schoolid"].(string)
jobID, _ := iframe.Data["_jobid"].(string)
if workID == "" || jobID == "" {
continue
}
if schoolID == "" {
schoolID = "0"
}
point.Type = entity.ChapterPointTypeWork
point.WorkID = workID
point.SchoolID = schoolID
point.JobID = jobID
case "hyperlink", "insertlink":
jobID, _ := iframe.Data["_jobid"].(string)
if jobID == "" {
continue
}
linkType := 0
if lt, ok := iframe.Data["linkType"].(float64); ok {
linkType = int(lt)
}
point.Type = entity.ChapterPointTypeHyperlink
point.JobID = jobID
point.LinkType = linkType
case "insertlive":
jobID, _ := iframe.Data["_jobid"].(string)
if jobID == "" {
continue
}
title, _ := iframe.Data["title"].(string)
liveStatus, _ := iframe.Data["liveStatus"].(string)
streamName, _ := iframe.Data["streamName"].(string)
vdoid, _ := iframe.Data["vdoid"].(string)
liveId := ""
if lid, ok := iframe.Data["liveId"].(float64); ok {
liveId = strconv.FormatInt(int64(lid), 10)
}
live, _ := iframe.Data["live"].(bool)
point.Type = entity.ChapterPointTypeLive
point.JobID = jobID
point.Title = title
point.LiveStatusStr = liveStatus
point.StreamName = streamName
point.Vdoid = vdoid
point.LiveId = liveId
point.Live = live
point.Module = pointType
point.UserId = c.userID
default:
logger.Logger.Debug("未知的任务点类型", zap.String("type", pointType))
continue
}
points = append(points, point)
}
}
return cards, points, nil
}
// fillVideoPointFromCards2 从 FetchChapterCards2 结果填充视频任务点参数
func (c *XuexitongCourse) fillVideoPointFromCards2(cords2 map[string]interface{}, point *entity.ChapterPoint, objectID string) {
if attachments, ok := cords2["attachments"].([]interface{}); ok {
for _, att := range attachments {
if attMap, ok := att.(map[string]interface{}); ok {
attObjectID, _ := attMap["objectId"].(string)
if attObjectID == "" {
if prop, ok := attMap["property"].(map[string]interface{}); ok {
attObjectID, _ = prop["objectid"].(string)
}
}
if attObjectID == objectID {
if otherInfo, ok := attMap["otherInfo"].(string); ok && len(otherInfo) > 80 {
parts := strings.SplitN(otherInfo, "&", 2)
if len(parts) > 0 {
point.OtherInfo = parts[0]
}
}
if jid, ok := attMap["jobid"].(string); ok && jid != "" {
point.JobID = jid
}
if playTime, ok := attMap["playTime"].(float64); ok {
point.PlayTime = int(playTime) / 1000
}
if ade, ok := attMap["attDurationEnc"].(string); ok {
point.AttDurationEnc = ade
}
if vfce, ok := attMap["videoFaceCaptureEnc"].(string); ok {
point.VideoFaceCaptureEnc = vfce
}
if isPassed, ok := attMap["isPassed"].(bool); ok {
point.IsPassed = isPassed
}
point.RT = 0.9
if prop, ok := attMap["property"].(map[string]interface{}); ok {
if rtObj, ok := prop["rt"]; ok {
switch v := rtObj.(type) {
case float64:
point.RT = v
case string:
if rt, err := strconv.ParseFloat(v, 64); err == nil {
point.RT = rt
}
}
}
}
logger.Logger.Debug("从 FetchChapterCards2 获取到视频参数",
zap.String("objectID", objectID),
zap.String("otherInfo", point.OtherInfo),
zap.String("attDurationEnc", point.AttDurationEnc),
zap.String("videoFaceCaptureEnc", point.VideoFaceCaptureEnc),
zap.Float64("RT", point.RT))
break
}
}
}
}
if defaults, ok := cords2["defaults"].(map[string]interface{}); ok {
if fid, ok := defaults["fid"].(string); ok {
point.FID, _ = strconv.Atoi(fid)
}
if cpiVal, ok := defaults["cpi"]; ok {
switch v := cpiVal.(type) {
case float64:
point.Cpi = strconv.FormatInt(int64(v), 10)
case string:
point.Cpi = v
}
}
if userid, ok := defaults["userid"].(string); ok {
c.userID = userid
}
}
}
// parseIframeData 从HTML中解析iframe数据
func (c *XuexitongCourse) parseIframeData(htmlString string) ([]entity.IframeAttributes, error) {
node, err := html.Parse(strings.NewReader(htmlString))
if err != nil {
return nil, err
}
var iframes []entity.IframeAttributes
var traverse func(n *html.Node)
traverse = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "iframe" {
attrs := entity.IframeAttributes{
Other: make(map[string]string),
}
hasData := false
for _, attr := range n.Attr {
if attr.Key == "data" && strings.TrimSpace(attr.Val) != "" {
hasData = true
cleanedData := strings.ReplaceAll(attr.Val, "&quot;", "\"")
cleanedData = regexp.MustCompile(`\s+`).ReplaceAllString(cleanedData, "")
if err := json.Unmarshal([]byte(cleanedData), &attrs.Data); err != nil {
logger.Logger.Debug("Failed to decode JSON", zap.Error(err))
}
} else {
attrs.Other[attr.Key] = attr.Val
}
}
attrs.HasData = hasData
iframes = append(iframes, attrs)
}
for ch := n.FirstChild; ch != nil; ch = ch.NextSibling {
traverse(ch)
}
}
traverse(node)
return iframes, nil
}
// FetchChapterCards2 获取章节卡片(新接口,用于获取 otherInfo 等)
func (c *XuexitongCourse) FetchChapterCards2(ctx context.Context, course entity.XueXiTCourse, num int, knowledgeID int) (map[string]interface{}, error) {
urlStr := fmt.Sprintf("%s?clazzid=%s&courseid=%s&knowledgeid=%d&num=%d&ut=s&cpi=%d&v=2025-0424-1038-3&mooc2=1&isMicroCourse=false&editorPreview=0",
ApiChapterCards2, course.Key, course.CourseID, knowledgeID, num, course.Cpi)
logger.Logger.Debug("FetchChapterCards2 请求",
zap.String("url", urlStr),
zap.Int("knowledgeID", knowledgeID))
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("mobile"),
}).Get(urlStr)
if err != nil {
return err
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
logger.Logger.Error("FetchChapterCards2 请求失败", zap.Error(err))
return nil, err
}
logger.Logger.Debug("FetchChapterCards2 响应",
zap.Int("bodyLength", len(body)),
zap.Bool("containsMArg", strings.Contains(body, "mArg")))
re := regexp.MustCompile(`mArg = ([^;]{6,})`)
matches := re.FindStringSubmatch(body)
if len(matches) <= 1 {
logger.Logger.Warn("FetchChapterCards2 未找到 mArg")
return nil, nil
}
logger.Logger.Debug("FetchChapterCards2 提取到 mArg", zap.Int("jsonLength", len(matches[1])))
var result map[string]interface{}
if err := json.Unmarshal([]byte(matches[1]), &result); err != nil {
logger.Logger.Error("FetchChapterCards2 解析 JSON 失败", zap.Error(err))
return nil, err
}
if attachments, ok := result["attachments"].([]interface{}); ok {
logger.Logger.Debug("FetchChapterCards2 解析成功",
zap.Int("attachmentsCount", len(attachments)))
}
return result, nil
}
// PageMobileChapterCard 获取章节卡片页面(SSR方式,用于获取附件信息)
func (c *XuexitongCourse) PageMobileChapterCard(ctx context.Context, course entity.XueXiTCourse, knowledgeID, cardIndex int) (*entity.AttachmentSetting, string, error) {
classID, _ := strconv.Atoi(course.Key)
courseID, _ := strconv.Atoi(course.CourseID)
params := url.Values{}
params.Add("clazzid", strconv.Itoa(classID))
params.Add("courseid", strconv.Itoa(courseID))
params.Add("knowledgeid", strconv.Itoa(knowledgeID))
params.Add("num", strconv.Itoa(cardIndex))
params.Add("isPhone", "1")
params.Add("control", "true")
params.Add("cpi", strconv.Itoa(course.Cpi))
requestURL := PageChapterCard + "?" + params.Encode()
logger.Logger.Debug("PageMobileChapterCard 请求",
zap.String("url", requestURL),
zap.Int("knowledgeID", knowledgeID),
zap.Int("cardIndex", cardIndex))
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("mobile"),
}).Get(requestURL)
if err != nil {
return err
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
logger.Logger.Error("PageMobileChapterCard 请求失败", zap.Error(err))
return nil, "", err
}
logger.Logger.Debug("PageMobileChapterCard 响应",
zap.Int("bodyLength", len(body)),
zap.Bool("containsAttachmentSetting", strings.Contains(body, "window.AttachmentSetting")),
zap.Bool("containsBlankTips", strings.Contains(body, "blankTips")),
zap.String("bodyPreview", body[:min(len(body), 500)]))
doc, err := html.Parse(strings.NewReader(body))
if err != nil {
logger.Logger.Error("解析 HTML 失败", zap.Error(err))
return nil, "", err
}
var scriptContent string
var scriptCount int
var traverse func(*html.Node)
traverse = func(n *html.Node) {
if scriptContent != "" {
return
}
if n.Type == html.ElementNode && n.Data == "script" {
hasTypeAttr := false
for _, attr := range n.Attr {
if attr.Key == "type" && strings.TrimSpace(attr.Val) == "text/javascript" {
hasTypeAttr = true
break
}
}
if hasTypeAttr {
scriptCount++
if n.FirstChild != nil && n.FirstChild.Type == html.TextNode {
scriptContent = strings.TrimSpace(n.FirstChild.Data)
logger.Logger.Debug("找到 script 标签",
zap.Int("scriptIndex", scriptCount),
zap.Int("contentLength", len(scriptContent)),
zap.Bool("hasAttachmentSetting", strings.Contains(scriptContent, "window.AttachmentSetting")))
return
}
}
}
for ch := n.FirstChild; ch != nil; ch = ch.NextSibling {
traverse(ch)
}
}
traverse(doc)
logger.Logger.Debug("HTML 解析完成", zap.Int("scriptCount", scriptCount), zap.Bool("hasScriptContent", scriptContent != ""))
if scriptContent == "" || !strings.Contains(scriptContent, "window.AttachmentSetting") {
var blankTips string
traverse = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "p" {
for _, attr := range n.Attr {
if attr.Key == "class" && strings.Contains(attr.Val, "blankTips") {
if n.FirstChild != nil {
blankTips = strings.TrimSpace(n.FirstChild.Data)
}
return
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
traverse(c)
}
}
traverse(doc)
if blankTips != "" {
logger.Logger.Warn("章节提示信息", zap.String("blankTips", blankTips))
}
if strings.Contains(body, "window.AttachmentSetting") {
scriptContent = body
logger.Logger.Debug("从原始 body 中找到 window.AttachmentSetting")
} else {
logger.Logger.Warn("未找到 window.AttachmentSetting",
zap.Int("knowledgeID", knowledgeID),
zap.Int("cardIndex", cardIndex),
zap.String("blankTips", blankTips))
return nil, "", nil
}
}
re := regexp.MustCompile(`window\.AttachmentSetting\s*=\s*(\{.*?\});`)
matches := re.FindStringSubmatch(scriptContent)
var jsonStr string
if len(matches) > 1 {
jsonStr = matches[1]
logger.Logger.Debug("正则提取 JSON 成功", zap.Int("jsonLength", len(jsonStr)))
} else {
jsonStr = extractJSONObject(scriptContent, "window.AttachmentSetting")
if jsonStr == "" {
logger.Logger.Warn("无法提取 JSON",
zap.Int("scriptContentLength", len(scriptContent)))
return nil, "", nil
}
logger.Logger.Debug("括号匹配提取 JSON 成功", zap.Int("jsonLength", len(jsonStr)))
}
var attachment entity.AttachmentSetting
if err := json.Unmarshal([]byte(jsonStr), &attachment); err != nil {
logger.Logger.Error("解析 AttachmentSetting 失败",
zap.Error(err),
zap.String("jsonStr", jsonStr[:min(len(jsonStr), 500)]))
return nil, "", err
}
logger.Logger.Debug("解析 AttachmentSetting 成功",
zap.Int("attachmentsCount", len(attachment.Attachments)),
zap.Any("defaults", attachment.Defaults))
for idx, att := range attachment.Attachments {
property, _ := att["property"].(map[string]interface{})
objectid := ""
workid := ""
if property != nil {
if oid, ok := property["objectid"].(string); ok {
objectid = oid
}
if wid, ok := property["workid"].(string); ok {
workid = wid
}
}
otherInfo, _ := att["otherInfo"].(string)
attDurationEnc, _ := att["attDurationEnc"].(string)
videoFaceCaptureEnc, _ := att["videoFaceCaptureEnc"].(string)
enc, _ := att["enc"].(string)
logger.Logger.Debug("附件信息",
zap.Int("index", idx),
zap.String("objectid", objectid),
zap.String("workid", workid),
zap.String("enc", enc),
zap.Bool("hasOtherInfo", otherInfo != ""),
zap.Bool("hasAttDurationEnc", attDurationEnc != ""),
zap.Bool("hasVideoFaceCaptureEnc", videoFaceCaptureEnc != ""))
}
reEnc := regexp.MustCompile(`<input type="hidden" id="from" value="[^_]+_[^_]+_[^_]+_([^"]+)"/>`)
matchesEnc := reEnc.FindStringSubmatch(body)
enc := ""
if len(matchesEnc) > 1 {
enc = matchesEnc[1]
}
return &attachment, enc, nil
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,231 @@
package xuexitong
import (
"context"
"encoding/json"
"likedream-goframe/internal/application/xuexitong/entity"
errors2 "likedream-goframe/internal/common/errors"
"likedream-goframe/internal/framework/logger"
"net/url"
"strconv"
"time"
"github.com/avast/retry-go/v4"
"go.uber.org/zap"
)
// attachmentDetectDocument 从附件设置中检测并填充文档信息
func (c *XuexitongCourse) attachmentDetectDocument(attachment *entity.AttachmentSetting, p *entity.ChapterPoint) {
for _, att := range attachment.Attachments {
typeStr := ""
// 进行不同方案的类型值获取
if t, ok := att["type"].(string); ok {
typeStr = t
} else if property, ok := att["property"].(map[string]interface{}); ok {
if module, ok := property["module"].(string); ok {
typeStr = module
}
}
property, ok := att["property"].(map[string]interface{})
if !ok {
continue
}
// 如果未给出文档类型,先进行文档解析尝试
if typeStr == "" {
if name, ok := property["name"].(string); ok {
p.Title = name
}
if jid, ok := property["jobid"].(string); ok {
p.JobID = jid
}
if jtoken, ok := att["jtoken"].(string); ok {
p.Jtoken = jtoken
}
if isJob, ok := att["job"].(bool); ok {
p.IsJob = isJob
} else {
p.IsJob = false
}
if bookname, ok := property["bookname"].(string); ok {
p.Title = bookname
}
// 过滤匹配
objectid, _ := property["objectid"].(string)
jobid, _ := property["jobid"].(string)
if (p.ObjectID != "" && objectid == p.ObjectID) || (jobid != "" && p.JobID == jobid) {
break
}
} else if typeStr == "insertbook" || typeStr == "read" {
if jtoken, ok := att["jtoken"].(string); ok {
p.Jtoken = jtoken
}
if isJob, ok := att["job"].(bool); ok {
p.IsJob = isJob
} else {
p.IsJob = false
}
if bookname, ok := property["bookname"].(string); ok {
p.Title = bookname
}
if typeStr == "insertbook" {
p.DocumentType = "insertbook"
} else {
p.DocumentType = "read"
}
// 过滤匹配
objectid, _ := property["objectid"].(string)
jobid, _ := property["jobid"].(string)
if (p.ObjectID != "" && objectid == p.ObjectID) || (jobid != "" && p.JobID == jobid) {
break
}
} else if typeStr == "document" || typeStr == "insertdoc" {
if isJob, ok := att["job"].(bool); ok {
p.IsJob = isJob
}
objectid, _ := property["objectid"].(string)
if objectid == p.ObjectID {
if name, ok := property["name"].(string); ok {
p.Title = name
}
if jid, ok := property["jobid"].(string); ok {
p.JobID = jid
}
if jtoken, ok := att["jtoken"].(string); ok {
p.Jtoken = jtoken
}
p.DocumentType = "document"
break
}
}
}
}
// DocumentDtoReadingReport 提交文档阅读完成
func (c *XuexitongCourse) DocumentDtoReadingReport(ctx context.Context, p *entity.PointDocumentDto) (string, error) {
params := url.Values{}
params.Add("jobid", p.JobID)
params.Add("knowledgeid", strconv.Itoa(p.KnowledgeID))
params.Add("courseid", p.CourseID)
params.Add("clazzid", p.ClassID)
params.Add("jtoken", p.Jtoken)
params.Add("_dc", strconv.FormatInt(time.Now().UnixMilli(), 10))
apiUrl := ApiDocumentReport
if p.Cpi == "insertbook" {
apiUrl = ApiDocumentBook
}
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("mobile"),
}).Get(apiUrl + "?" + params.Encode())
if err != nil {
return err
}
if resp.StatusCode() != 200 {
return errors2.NewBusinessError(resp.StatusCode(), "提交文档阅读失败")
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return "", err
}
return body, nil
}
// AutoReadDocumentByPoint 自动阅读文档(使用统一的 ChapterPoint)
func (c *XuexitongCourse) AutoReadDocumentByPoint(ctx context.Context, course entity.XueXiTCourse, p *entity.ChapterPoint) error {
// 获取附件信息填充 Jtoken、IsJob 等参数
attachment, _, err := c.PageMobileChapterCard(ctx, course, p.KnowledgeID, p.CardIndex)
if err != nil {
logger.Logger.Error("获取章节卡片失败", zap.String("title", p.Title), zap.Error(err))
return err
}
if attachment != nil {
c.attachmentDetectDocument(attachment, p)
}
logger.Logger.Debug("文档参数",
zap.String("jobID", p.JobID),
zap.String("objectID", p.ObjectID),
zap.String("jtoken", p.Jtoken),
zap.Bool("isJob", p.IsJob),
zap.String("documentType", p.DocumentType))
if !p.IsJob {
logger.Logger.Info("文档不是任务点,跳过", zap.String("jobID", p.JobID), zap.String("title", p.Title))
return nil
}
logger.Logger.Info("开始刷文档",
zap.String("jobID", p.JobID),
zap.String("title", p.Title))
report, err := c.DocumentDtoReadingReportByPoint(ctx, p)
if err != nil {
return err
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(report), &result); err == nil {
if status, ok := result["status"].(bool); ok && status {
logger.Logger.Info("文档刷课完成", zap.String("jobID", p.JobID), zap.String("title", p.Title))
c.onTaskComplete()
return nil
}
if msg, ok := result["msg"].(string); ok {
logger.Logger.Warn("文档无法正常学习", zap.String("msg", msg))
}
}
return nil
}
// DocumentDtoReadingReportByPoint 提交文档阅读完成(使用统一的 ChapterPoint)
func (c *XuexitongCourse) DocumentDtoReadingReportByPoint(ctx context.Context, p *entity.ChapterPoint) (string, error) {
formParams := map[string]string{
"jobid": p.JobID,
"knowledgeid": strconv.Itoa(p.KnowledgeID),
"courseid": p.CourseID,
"clazzid": p.ClassID,
"jtoken": p.Jtoken,
"_dc": strconv.FormatInt(time.Now().UnixMilli(), 10),
}
apiUrl := ApiDocumentReport
if p.DocumentType == "insertbook" {
apiUrl = ApiDocumentBook
} else if p.DocumentType == "read" {
apiUrl = ApiDocumentReadV2
}
// read 类型使用 document 接口
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("mobile"),
}).SetQueryParams(formParams).Get(apiUrl)
if err != nil {
return err
}
if resp.StatusCode() != 200 {
return errors2.NewBusinessError(resp.StatusCode(), "提交文档阅读失败")
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return "", err
}
return body, nil
}
@@ -0,0 +1,188 @@
package xuexitong
import (
"context"
"encoding/json"
"fmt"
"likedream-goframe/internal/application/xuexitong/entity"
errors2 "likedream-goframe/internal/common/errors"
"likedream-goframe/internal/framework/logger"
"time"
"github.com/avast/retry-go/v4"
"go.uber.org/zap"
)
// attachmentDetectHyperlink 从附件设置中检测并填充外链信息
func (c *XuexitongCourse) attachmentDetectHyperlink(attachment *entity.AttachmentSetting, p *entity.ChapterPoint) {
for _, att := range attachment.Attachments {
jobid, _ := att["jobid"].(string)
if jobid != p.JobID {
continue
}
property, ok := att["property"].(map[string]interface{})
if !ok {
continue
}
if title, ok := property["title"].(string); ok {
p.Title = title
}
if jtoken, ok := att["jtoken"].(string); ok {
p.Jtoken = jtoken
}
if isJob, ok := att["job"].(bool); ok {
p.IsJob = isJob
}
break
}
}
// AttachmentsDetectionHyperlink 从附件设置中检测并填充外链信息
func (c *XuexitongCourse) AttachmentsDetectionHyperlink(attachment *entity.AttachmentSetting, p *entity.PointHyperlinkDto) error {
if attachment == nil {
return errors2.NewBusinessError(503, "附件设置为空")
}
for _, att := range attachment.Attachments {
jobid, _ := att["jobid"].(string)
if jobid != p.JobID {
continue
}
property, ok := att["property"].(map[string]interface{})
if !ok {
continue
}
if title, ok := property["title"].(string); ok {
p.Title = title
}
if jtoken, ok := att["jtoken"].(string); ok {
p.Jtoken = jtoken
}
if isJob, ok := att["job"].(bool); ok {
p.IsJob = isJob
}
break
}
return nil
}
// AutoCompleteHyperlink 自动完成外链任务点
func (c *XuexitongCourse) AutoCompleteHyperlink(ctx context.Context, course entity.XueXiTCourse, p *entity.PointHyperlinkDto) error {
if !p.IsJob {
logger.Logger.Info("外链不是任务点,跳过", zap.String("jobID", p.JobID), zap.String("title", p.Title))
return nil
}
logger.Logger.Info("开始刷外链",
zap.String("jobID", p.JobID),
zap.String("title", p.Title))
report, err := c.HyperlinkDtoCompleteReport(ctx, p)
if err != nil {
return fmt.Errorf("提交外链完成失败: %w", err)
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(report), &result); err == nil {
if status, ok := result["status"].(bool); ok && status {
logger.Logger.Info("外链刷课完成", zap.String("jobID", p.JobID), zap.String("title", p.Title))
c.onTaskComplete()
return nil
}
if msg, ok := result["msg"].(string); ok {
logger.Logger.Warn("外链无法正常学习", zap.String("msg", msg))
}
}
return nil
}
// AutoCompleteHyperlinkByPoint 自动完成外链任务点(使用统一的 ChapterPoint)
func (c *XuexitongCourse) AutoCompleteHyperlinkByPoint(ctx context.Context, course entity.XueXiTCourse, p *entity.ChapterPoint) error {
if !p.IsJob {
logger.Logger.Info("外链不是任务点,跳过", zap.String("jobID", p.JobID), zap.String("title", p.Title))
return nil
}
logger.Logger.Info("开始刷外链",
zap.String("jobID", p.JobID),
zap.String("title", p.Title))
report, err := c.HyperlinkDtoCompleteReportByPoint(ctx, p)
if err != nil {
return fmt.Errorf("提交外链完成失败: %w", err)
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(report), &result); err == nil {
if status, ok := result["status"].(bool); ok && status {
logger.Logger.Info("外链刷课完成", zap.String("jobID", p.JobID), zap.String("title", p.Title))
c.onTaskComplete()
return nil
}
if msg, ok := result["msg"].(string); ok {
logger.Logger.Warn("外链无法正常学习", zap.String("msg", msg))
}
}
return nil
}
// HyperlinkDtoCompleteReport 提交外链完成
func (c *XuexitongCourse) HyperlinkDtoCompleteReport(ctx context.Context, p *entity.PointHyperlinkDto) (string, error) {
urlStr := fmt.Sprintf("https://mooc1.chaoxing.com/ananas/job/hyperlink?jobid=%s&knowledgeid=%d&courseid=%s&clazzid=%s&jtoken=%s&checkMicroTopic=true&microTopicId=undefined&_dc=%d",
p.JobID, p.KnowledgeID, p.CourseID, p.ClassID, p.Jtoken, time.Now().UnixMilli())
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("web"),
}).Get(urlStr)
if err != nil {
return err
}
if resp.StatusCode() != 200 {
return errors2.NewBusinessError(resp.StatusCode(), "提交外链完成失败")
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return "", err
}
return body, nil
}
// HyperlinkDtoCompleteReportByPoint 提交外链完成(使用统一的 ChapterPoint)
func (c *XuexitongCourse) HyperlinkDtoCompleteReportByPoint(ctx context.Context, p *entity.ChapterPoint) (string, error) {
urlStr := fmt.Sprintf("https://mooc1.chaoxing.com/ananas/job/hyperlink?jobid=%s&knowledgeid=%d&courseid=%s&clazzid=%s&jtoken=%s&checkMicroTopic=true&microTopicId=undefined&_dc=%d",
p.JobID, p.KnowledgeID, p.CourseID, p.ClassID, p.Jtoken, time.Now().UnixMilli())
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("web"),
}).Get(urlStr)
if err != nil {
return err
}
if resp.StatusCode() != 200 {
return errors2.NewBusinessError(resp.StatusCode(), "提交外链完成失败")
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return "", err
}
return body, nil
}
@@ -0,0 +1,413 @@
package xuexitong
import (
"context"
"encoding/json"
"fmt"
"likedream-goframe/internal/application/xuexitong/entity"
errors2 "likedream-goframe/internal/common/errors"
"likedream-goframe/internal/framework/logger"
"strconv"
"time"
"github.com/avast/retry-go/v4"
"go.uber.org/zap"
)
// attachmentDetectLive 从附件设置中检测并填充直播信息
func (c *XuexitongCourse) attachmentDetectLive(attachment *entity.AttachmentSetting, p *entity.ChapterPoint) {
for _, att := range attachment.Attachments {
jobid, _ := att["jobid"].(string)
if jobid != p.JobID {
continue
}
if authEnc, ok := att["authEnc"].(string); ok {
p.AuthEnc = authEnc
}
if liveDragEnc, ok := att["liveDragEnc"].(string); ok {
p.LiveDragEnc = liveDragEnc
}
if liveSetEnc, ok := att["liveSetEnc"].(string); ok {
p.LiveSetEnc = liveSetEnc
}
if otherInfo, ok := att["otherInfo"].(string); ok {
p.OtherInfo = otherInfo
}
if enc, ok := att["enc"].(string); ok {
p.Enc = enc
}
if liveSwDsEnc, ok := att["liveSwDsEnc"].(string); ok {
p.LiveSwDsEnc = liveSwDsEnc
}
if isJob, ok := att["job"].(bool); ok {
p.IsJob = isJob
}
if aid, ok := att["aid"].(float64); ok {
p.Aid = strconv.FormatInt(int64(aid), 10)
}
break
}
}
// AttachmentsDetectionLive 从附件设置中检测并填充直播信息
func (c *XuexitongCourse) AttachmentsDetectionLive(attachment *entity.AttachmentSetting, p *entity.PointLiveDto) error {
if attachment == nil {
return errors2.NewBusinessError(503, "附件设置为空")
}
for _, att := range attachment.Attachments {
jobid, _ := att["jobid"].(string)
if jobid != p.JobID {
continue
}
if authEnc, ok := att["authEnc"].(string); ok {
p.AuthEnc = authEnc
}
if liveDragEnc, ok := att["liveDragEnc"].(string); ok {
p.LiveDragEnc = liveDragEnc
}
if liveSetEnc, ok := att["liveSetEnc"].(string); ok {
p.LiveSetEnc = liveSetEnc
}
if otherInfo, ok := att["otherInfo"].(string); ok {
p.OtherInfo = otherInfo
}
if enc, ok := att["enc"].(string); ok {
p.Enc = enc
}
if liveSwDsEnc, ok := att["liveSwDsEnc"].(string); ok {
p.LiveSwDsEnc = liveSwDsEnc
}
if isJob, ok := att["job"].(bool); ok {
p.IsJob = isJob
}
if aid, ok := att["aid"].(float64); ok {
p.Aid = strconv.FormatInt(int64(aid), 10)
}
break
}
return nil
}
// AutoWatchLive 自动观看直播任务点
func (c *XuexitongCourse) AutoWatchLive(ctx context.Context, course entity.XueXiTCourse, p *entity.PointLiveDto) error {
if !p.IsJob {
logger.Logger.Info("直播不是任务点,跳过", zap.String("jobID", p.JobID), zap.String("title", p.Title))
return nil
}
if err := c.PullLiveInfo(ctx, p); err != nil {
logger.Logger.Warn("获取直播信息失败", zap.Error(err))
}
if p.LiveStatusCode == 0 {
logger.Logger.Info("直播还未开始,跳过", zap.String("title", p.Title))
return nil
}
if p.VideoCompletePercent >= 90 {
logger.Logger.Info("直播已完成", zap.String("title", p.Title), zap.Float64("percent", p.VideoCompletePercent))
return nil
}
logger.Logger.Info("开始刷直播",
zap.String("jobID", p.JobID),
zap.String("title", p.Title),
zap.Float64("currentPercent", p.VideoCompletePercent))
if _, err := c.LiveRelationReport(ctx, p); err != nil {
logger.Logger.Warn("建立直播连接失败", zap.Error(err))
}
for {
report, err := c.LiveSaveTimeReport(ctx, p)
if err != nil {
logger.Logger.Error("提交直播观看进度失败", zap.Error(err))
return err
}
if err := c.PullLiveInfo(ctx, p); err != nil {
logger.Logger.Warn("更新直播信息失败", zap.Error(err))
}
logger.Logger.Debug("直播观看中",
zap.String("title", p.Title),
zap.Float64("percent", p.VideoCompletePercent),
zap.String("report", report))
if p.VideoCompletePercent >= 90 {
logger.Logger.Info("直播刷课完成", zap.String("title", p.Title))
c.onTaskComplete()
break
}
time.Sleep(30 * time.Second)
}
return nil
}
// AutoWatchLiveByPoint 自动观看直播任务点(使用统一的 ChapterPoint)
func (c *XuexitongCourse) AutoWatchLiveByPoint(ctx context.Context, course entity.XueXiTCourse, p *entity.ChapterPoint) error {
if !p.IsJob {
logger.Logger.Info("直播不是任务点,跳过", zap.String("jobID", p.JobID), zap.String("title", p.Title))
return nil
}
if err := c.PullLiveInfoByPoint(ctx, p); err != nil {
logger.Logger.Warn("获取直播信息失败", zap.Error(err))
}
if p.LiveStatusCode == 0 {
logger.Logger.Info("直播还未开始,跳过", zap.String("title", p.Title))
return nil
}
if p.VideoCompletePercent >= 90 {
logger.Logger.Info("直播已完成", zap.String("title", p.Title), zap.Float64("percent", p.VideoCompletePercent))
return nil
}
logger.Logger.Info("开始刷直播",
zap.String("jobID", p.JobID),
zap.String("title", p.Title),
zap.Float64("currentPercent", p.VideoCompletePercent))
if _, err := c.LiveRelationReportByPoint(ctx, p); err != nil {
logger.Logger.Warn("建立直播连接失败", zap.Error(err))
}
for {
report, err := c.LiveSaveTimeReportByPoint(ctx, p)
if err != nil {
logger.Logger.Error("提交直播观看进度失败", zap.Error(err))
return err
}
if err := c.PullLiveInfoByPoint(ctx, p); err != nil {
logger.Logger.Warn("更新直播信息失败", zap.Error(err))
}
logger.Logger.Debug("直播观看中",
zap.String("title", p.Title),
zap.Float64("percent", p.VideoCompletePercent),
zap.String("report", report))
if p.VideoCompletePercent >= 90 {
logger.Logger.Info("直播刷课完成", zap.String("title", p.Title))
c.onTaskComplete()
break
}
time.Sleep(30 * time.Second)
}
return nil
}
// PullLiveInfo 获取直播信息
func (c *XuexitongCourse) PullLiveInfo(ctx context.Context, p *entity.PointLiveDto) error {
urlStr := fmt.Sprintf("https://mooc1.chaoxing.com/ananas/live/liveinfo?liveid=%s&userid=%s&clazzid=%s&knowledgeid=%d&courseid=%s&jobid=%s&ut=s",
p.LiveId, p.UserId, p.ClassID, p.KnowledgeID, p.CourseID, p.JobID)
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("web"),
}).Get(urlStr)
if err != nil {
return err
}
if resp.StatusCode() != 200 {
return errors2.NewBusinessError(resp.StatusCode(), "获取直播信息失败")
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return err
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(body), &result); err != nil {
return err
}
if status, ok := result["status"].(bool); ok && status {
if temp, ok := result["temp"].(map[string]interface{}); ok {
if data, ok := temp["data"].(map[string]interface{}); ok {
if percentValue, ok := data["percentValue"].(float64); ok {
p.VideoCompletePercent = percentValue
}
if duration, ok := data["duration"].(float64); ok {
p.VideoDuration = int(duration)
}
if liveStatus, ok := data["liveStatus"].(float64); ok {
p.LiveStatusCode = int(liveStatus)
}
}
}
}
return nil
}
// PullLiveInfoByPoint 获取直播信息(使用统一的 ChapterPoint)
func (c *XuexitongCourse) PullLiveInfoByPoint(ctx context.Context, p *entity.ChapterPoint) error {
urlStr := fmt.Sprintf("https://mooc1.chaoxing.com/ananas/live/liveinfo?liveid=%s&userid=%s&clazzid=%s&knowledgeid=%d&courseid=%s&jobid=%s&ut=s",
p.LiveId, p.UserId, p.ClassID, p.KnowledgeID, p.CourseID, p.JobID)
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("web"),
}).Get(urlStr)
if err != nil {
return err
}
if resp.StatusCode() != 200 {
return errors2.NewBusinessError(resp.StatusCode(), "获取直播信息失败")
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return err
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(body), &result); err != nil {
return err
}
if status, ok := result["status"].(bool); ok && status {
if temp, ok := result["temp"].(map[string]interface{}); ok {
if data, ok := temp["data"].(map[string]interface{}); ok {
if percentValue, ok := data["percentValue"].(float64); ok {
p.VideoCompletePercent = percentValue
}
if duration, ok := data["duration"].(float64); ok {
p.VideoDuration = int(duration)
}
if liveStatus, ok := data["liveStatus"].(float64); ok {
p.LiveStatusCode = int(liveStatus)
}
}
}
}
return nil
}
// LiveRelationReport 建立直播连接
func (c *XuexitongCourse) LiveRelationReport(ctx context.Context, p *entity.PointLiveDto) (string, error) {
urlStr := fmt.Sprintf("https://mooc1.chaoxing.com/mooc-ans/live/relation?courseid=%s&knowledgeid=%d&ut=s&jobid=%s&aid=%s",
p.CourseID, p.KnowledgeID, p.JobID, p.Aid)
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("web"),
}).Get(urlStr)
if err != nil {
return err
}
if resp.StatusCode() != 200 {
return errors2.NewBusinessError(resp.StatusCode(), "建立直播连接失败")
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return "", err
}
return body, nil
}
// LiveRelationReportByPoint 建立直播连接(使用统一的 ChapterPoint)
func (c *XuexitongCourse) LiveRelationReportByPoint(ctx context.Context, p *entity.ChapterPoint) (string, error) {
urlStr := fmt.Sprintf("https://mooc1.chaoxing.com/mooc-ans/live/relation?courseid=%s&knowledgeid=%d&ut=s&jobid=%s&aid=%s",
p.CourseID, p.KnowledgeID, p.JobID, p.Aid)
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("web"),
}).Get(urlStr)
if err != nil {
return err
}
if resp.StatusCode() != 200 {
return errors2.NewBusinessError(resp.StatusCode(), "建立直播连接失败")
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return "", err
}
return body, nil
}
// LiveSaveTimeReport 提交直播观看进度
func (c *XuexitongCourse) LiveSaveTimeReport(ctx context.Context, p *entity.PointLiveDto) (string, error) {
urlStr := fmt.Sprintf("https://zhibo.chaoxing.com/saveTimePc?streamName=%s&vdoid=%s&userId=%s&isStart=1&t=%d&courseId=%s",
p.StreamName, p.Vdoid, p.UserId, time.Now().UnixMilli(), p.CourseID)
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("web"),
}).Get(urlStr)
if err != nil {
return err
}
if resp.StatusCode() != 200 {
return errors2.NewBusinessError(resp.StatusCode(), "提交直播观看进度失败")
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return "", err
}
return body, nil
}
// LiveSaveTimeReportByPoint 提交直播观看进度(使用统一的 ChapterPoint)
func (c *XuexitongCourse) LiveSaveTimeReportByPoint(ctx context.Context, p *entity.ChapterPoint) (string, error) {
urlStr := fmt.Sprintf("https://zhibo.chaoxing.com/saveTimePc?streamName=%s&vdoid=%s&userId=%s&isStart=1&t=%d&courseId=%s",
p.StreamName, p.Vdoid, p.UserId, time.Now().UnixMilli(), p.CourseID)
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("web"),
}).Get(urlStr)
if err != nil {
return err
}
if resp.StatusCode() != 200 {
return errors2.NewBusinessError(resp.StatusCode(), "提交直播观看进度失败")
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return "", err
}
return body, nil
}
@@ -2,22 +2,65 @@ package xuexitong
import (
"context"
"likedream-goframe/internal/application/quesbank"
"likedream-goframe/internal/application/xuexitong/entity"
"likedream-goframe/internal/common/db"
"likedream-goframe/internal/ent/sysconfig"
"likedream-goframe/internal/framework/errors"
"likedream-goframe/internal/framework/logger"
"likedream-goframe/internal/utils"
"strconv"
"strings"
"testing"
"github.com/bytedance/sonic"
"go.uber.org/zap"
)
// getAIConfig 获取AI配置
func getAIConfig(ctx context.Context) *quesbank.AIConfig {
db.InitDB()
config, err := db.Global.SysConfig.Query().
Where(sysconfig.KeyEQ("ai.config")).
Only(ctx)
if err != nil {
logger.Logger.Debug("未配置AI,作业将跳过", zap.Error(err))
return nil
}
var aiConfig quesbank.AIConfig
if err := sonic.Unmarshal([]byte(config.Value), &aiConfig); err != nil {
logger.Logger.Warn("AI配置解析失败", zap.Error(err))
return nil
}
return &aiConfig
}
func TestSchedule(t *testing.T) {
ctx := context.Background()
xuexitongLogin := NewXuexitongLogin(ctx)
err := xuexitongLogin.Login(context.Background(), "13151832241", "yyz@083422", 5)
err := xuexitongLogin.Login(context.Background(), "15754238084", "1575423yuan.dby", 5)
utils.OkOrPanic(err)
client := xuexitongLogin.GetClient()
courseService := NewXuexitongCourse(client)
// 人人必修的人工智能课
err = courseService.AutoStudyByCourseID(ctx, "258109701")
config := getAIConfig(ctx)
if config != nil {
logger.Logger.Info("AI配置加载成功",
zap.String("aiType", string(config.Type)),
zap.String("apiURL", config.URL),
zap.String("model", config.Model))
} else {
logger.Logger.Warn("AI配置为空,作业将被跳过")
}
err = courseService.AutoStudyByCourseIDWithOptions(ctx, "255690793", &AutoStudyOptions{
AIConfig: config,
AutoSubmit: true, // 默认不自动提交,仅保存
Thread: 1,
})
if err != nil {
utils.OkOrPanic(errors.NewBusinessErrorWithDetail(503, "刷课失败", err))
}
@@ -141,3 +184,235 @@ func TestAutoStudyAllCourses(t *testing.T) {
t.Log("所有课程刷课完成")
}
func TestSplitWords(t *testing.T) {
str := "ABCD"
split := strings.Split(str, "")
t.Log(split)
}
// TestExam 测试考试功能 - 完成整个考试流程但不提交(只暂存)
func TestExam(t *testing.T) {
ctx := context.Background()
// 登录
xuexitongLogin := NewXuexitongLogin(ctx)
err := xuexitongLogin.Login(ctx, "17320926947", "zhijie2001", 5)
utils.OkOrPanic(err)
client := xuexitongLogin.GetClient()
courseService := NewXuexitongCourse(client)
// 获取AI配置(可选,用于AI答题兜底)
aiConfig := getAIConfig(ctx)
if aiConfig != nil {
t.Logf("AI配置加载成功: %s %s", aiConfig.Type, aiConfig.Model)
} else {
t.Log("未配置AI,将仅使用题库答题")
}
// 创建统一答题服务(本地题库 -> 第三方题库 -> AI答题)
answerService := quesbank.NewUnifiedAnswerService(aiConfig)
// 获取课程列表,找到指定课程
courseList := courseService.PullCourseList(ctx)
var targetCourse *entity.XueXiTCourse
for i, course := range courseList {
if course.CourseID == "259828097" {
targetCourse = &courseList[i]
break
}
}
if targetCourse == nil {
t.Fatal("未找到指定课程")
}
t.Logf("找到课程: %s (ID: %s)", targetCourse.CourseName, targetCourse.CourseID)
// 使用考试列表API获取考试
examList, err := courseService.FetchExamTaskList(ctx, *targetCourse)
if err != nil {
t.Fatalf("获取考试列表失败: %v", err)
}
t.Logf("考试数量: %d", len(examList))
for _, exam := range examList {
t.Logf("考试: %s (状态: %s, TaskRefId: %s)",
exam.Name, exam.Status, exam.TaskRefId)
}
// 找一个待做的考试
var targetExam *ExamTaskItem
for i := range examList {
if examList[i].Status == "待做" {
targetExam = &examList[i]
break
}
}
if targetExam == nil {
t.Log("没有待做的考试")
return
}
t.Logf("开始考试: %s", targetExam.Name)
// 进入考试
err = courseService.EnterExam(ctx, targetExam)
if err != nil {
t.Fatalf("进入考试失败: %v", err)
}
t.Logf("进入考试成功, 题目数: %d, ExamRelationId: %s, AnswerId: %s",
targetExam.QuestionTotal, targetExam.ExamRelationId, targetExam.AnswerId)
if targetExam.QuestionTotal == 0 {
t.Log("考试没有题目")
return
}
// 遍历所有题目
for i := 0; i < targetExam.QuestionTotal; i++ {
t.Logf("========== 第 %d/%d 题 ==========", i, targetExam.QuestionTotal)
// 拉取题目
question, err := courseService.PullExamQuestion(ctx, targetExam, i)
if err != nil {
t.Logf("拉取第%d题失败: %v", i, err)
continue
}
t.Logf("题目类型: %s", question.QuestionTypeStr)
t.Logf("题目内容: %s", question.Content)
if len(question.Options) > 0 {
t.Logf("选项: %v", question.Options)
}
// 使用统一答题服务获取答案(本地题库 -> 第三方题库 -> AI答题)
questionInfo := quesbank.QuestionInfo{
Content: question.Content,
TypeCode: question.QuestionTypeCode,
TypeName: quesbank.GetQuestionTypeName(question.QuestionTypeCode),
Options: question.Options,
CourseName: targetCourse.CourseName,
QuestionID: question.QuestionId,
}
result, err := answerService.GetAnswer(ctx, questionInfo)
if err != nil {
t.Logf("获取答案失败: %v, 使用默认答案", err)
question.Answers = []string{"A"}
} else {
question.Answers = result.Answers
t.Logf("答案来源: %s, 答案: %v", result.Source, result.Answers)
}
// 提交答案(暂存,不交卷)
// tempSave=true 表示暂存,不交卷
submitResult, err := courseService.SubmitExamAnswer(ctx, question, true)
if err != nil {
t.Logf("提交第%d题答案失败: %v", i, err)
} else {
t.Logf("提交第%d题答案成功: %s", i, submitResult)
}
}
t.Log("========== 考试完成(已暂存,未交卷)==========")
}
// buildExamQuestionPrompt 构建考试题目的AI提示词
func buildExamQuestionPrompt(q *ExamQuestion) string {
var prompt strings.Builder
switch q.QuestionTypeCode {
case "0": // 单选题
prompt.WriteString("题目类型:单选题\n")
prompt.WriteString("题目内容:" + q.Content + "\n")
for _, opt := range q.Options {
prompt.WriteString(opt + "\n")
}
prompt.WriteString("\n请只回答选项字母,不要解释。格式:[\"A\"]")
case "1": // 多选题
prompt.WriteString("题目类型:多选题\n")
prompt.WriteString("题目内容:" + q.Content + "\n")
for _, opt := range q.Options {
prompt.WriteString(opt + "\n")
}
prompt.WriteString("\n请只回答选项字母,不要解释。格式:[\"A\",\"B\"]")
case "3": // 判断题
prompt.WriteString("题目类型:判断题\n")
prompt.WriteString("题目内容:" + q.Content + "\n")
for _, opt := range q.Options {
prompt.WriteString(opt + "\n")
}
prompt.WriteString("\n请只回答\"A\"(正确)或\"B\"(错误),不要解释。格式:[\"A\"]")
case "2": // 填空题
prompt.WriteString("题目类型:填空题\n")
prompt.WriteString("题目内容:" + q.Content + "\n")
blankCount := len(q.Options)
if blankCount == 0 {
blankCount = 1
}
prompt.WriteString("共有" + strconv.Itoa(blankCount) + "个空\n")
prompt.WriteString("\n请按顺序回答每个空的答案,不要解释。格式:[\"答案1\",\"答案2\"]")
case "4", "6": // 简答题、论述题
prompt.WriteString("题目类型:简答题\n")
prompt.WriteString("题目内容:" + q.Content + "\n")
prompt.WriteString("\n请直接回答问题,格式:[\"你的答案\"]")
default:
prompt.WriteString("题目:" + q.Content + "\n")
prompt.WriteString("请回答这道题,格式:[\"答案\"]")
}
return prompt.String()
}
// TestExamDetail 测试获取考试详情
func TestExamDetail(t *testing.T) {
ctx := context.Background()
// 登录
xuexitongLogin := NewXuexitongLogin(ctx)
err := xuexitongLogin.Login(ctx, "13151832241", "yyz@083422", 5)
utils.OkOrPanic(err)
client := xuexitongLogin.GetClient()
courseService := NewXuexitongCourse(client)
// 获取课程列表,找到指定课程
courseList := courseService.PullCourseList(ctx)
var targetCourse *entity.XueXiTCourse
for i, course := range courseList {
if course.CourseID == "258109701" {
targetCourse = &courseList[i]
break
}
}
if targetCourse == nil {
t.Fatal("未找到指定课程 courseId=258109701")
}
// 获取考试列表
examList, err := courseService.FetchExamTaskList(ctx, *targetCourse)
if err != nil {
t.Fatalf("获取考试列表失败: %v", err)
}
if len(examList) == 0 {
t.Log("该课程没有考试")
return
}
// 打印所有考试信息
for i, exam := range examList {
t.Logf("考试%d: 标题=%s, 状态=%s, TaskRefId=%s, CourseId=%s, ClazzId=%s",
i+1, exam.Name, exam.Status, exam.TaskRefId, exam.CourseId, exam.ClazzId)
}
}
@@ -0,0 +1,456 @@
package xuexitong
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"likedream-goframe/internal/application/xuexitong/entity"
errors2 "likedream-goframe/internal/common/errors"
"likedream-goframe/internal/framework/logger"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/avast/retry-go/v4"
"go.uber.org/zap"
)
// VideoDtoFetch 获取视频详细信息
func (c *XuexitongCourse) VideoDtoFetch(ctx context.Context, p *entity.PointVideoDto) error {
params := url.Values{}
params.Set("k", strconv.Itoa(p.FID))
params.Set("flag", "normal")
params.Set("_dc", strconv.FormatInt(time.Now().UnixMilli(), 10))
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("mobile"),
}).Get(fmt.Sprintf("%s/%s?%s", ApiVideoStatus, p.ObjectID, params.Encode()))
if err != nil {
return err
}
if resp.StatusCode() != 200 {
return errors2.NewBusinessError(resp.StatusCode(), "获取视频信息失败")
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return err
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(body), &result); err != nil {
return err
}
if dtoken, ok := result["dtoken"].(string); ok {
p.DToken = dtoken
}
if duration, ok := result["duration"].(float64); ok {
p.Duration = int(duration)
}
if filename, ok := result["filename"].(string); ok {
if decoded, err := url.QueryUnescape(filename); err == nil {
p.Title = decoded
} else {
p.Title = filename
}
}
if status, ok := result["status"].(string); ok && status == "success" {
return nil
}
return errors2.NewBusinessError(503, "视频状态异常")
}
// AttachmentsDetectionVideo 从附件设置中检测并填充视频信息(旧版,保留兼容)
func (c *XuexitongCourse) AttachmentsDetectionVideo(attachment *entity.AttachmentSetting, p *entity.PointVideoDto) error {
if attachment == nil {
return errors2.NewBusinessError(503, "附件设置为空")
}
for _, att := range attachment.Attachments {
property, ok := att["property"].(map[string]interface{})
if !ok {
continue
}
objectid, _ := property["objectid"].(string)
if objectid != p.ObjectID {
continue
}
if jobid, ok := property["jobid"].(string); ok {
p.JobID = jobid
} else if jobidFloat, ok := property["jobid"].(float64); ok {
p.JobID = strconv.FormatFloat(jobidFloat, 'f', -1, 64)
}
if otherInfo, ok := att["otherInfo"].(string); ok {
parts := strings.SplitN(otherInfo, "&", 2)
if len(parts) > 0 {
p.OtherInfo = parts[0]
}
}
if isPassed, ok := att["isPassed"].(bool); ok {
p.IsPassed = isPassed
}
if rtObj, ok := property["rt"]; ok {
switch v := rtObj.(type) {
case float64:
p.RT = v
case string:
if rt, err := strconv.ParseFloat(v, 64); err == nil {
p.RT = rt
} else {
p.RT = 0.9
}
}
} else {
p.RT = 0.9
}
if playTime, ok := att["playTime"].(float64); ok {
p.PlayTime = int(playTime) / 1000
}
if mid, ok := att["mid"].(string); ok {
p.Mid = mid
}
if rct, ok := att["randomCaptureTime"].(string); ok {
p.RandomCaptureTime = rct
}
if ade, ok := att["attDurationEnc"].(string); ok {
p.AttDurationEnc = ade
}
if vfce, ok := att["videoFaceCaptureEnc"].(string); ok {
p.VideoFaceCaptureEnc = vfce
}
break
}
if defaults := attachment.Defaults; defaults != nil {
if fid, ok := defaults["fid"].(string); ok {
p.FID, _ = strconv.Atoi(fid)
}
if userid, ok := defaults["userid"].(string); ok {
c.userID = userid
}
}
return nil
}
// attachmentDetectVideo 从附件设置中检测并填充视频信息
func (c *XuexitongCourse) attachmentDetectVideo(attachment *entity.AttachmentSetting, p *entity.ChapterPoint) {
logger.Logger.Debug("attachmentDetectVideo 开始匹配",
zap.String("targetObjectID", p.ObjectID),
zap.Int("attachmentsCount", len(attachment.Attachments)))
found := false
for idx, att := range attachment.Attachments {
property, ok := att["property"].(map[string]interface{})
if !ok {
continue
}
objectid := property["objectid"]
if objectid == nil {
continue
}
objectidStr, _ := objectid.(string)
logger.Logger.Debug("检查附件",
zap.Int("index", idx),
zap.String("objectid", objectidStr),
zap.String("targetObjectID", p.ObjectID))
if objectidStr != p.ObjectID {
continue
}
found = true
logger.Logger.Debug("找到匹配的附件", zap.Int("index", idx))
if jobid, ok := property["jobid"].(string); ok {
p.JobID = jobid
} else if jobidFloat, ok := property["jobid"].(float64); ok {
p.JobID = strconv.FormatFloat(jobidFloat, 'f', -1, 64)
}
if otherInfo, ok := att["otherInfo"].(string); ok {
parts := strings.SplitN(otherInfo, "&", 2)
if len(parts) > 0 {
p.OtherInfo = parts[0]
}
logger.Logger.Debug("获取到 otherInfo", zap.String("otherInfo", p.OtherInfo))
}
if isPassed, ok := att["isPassed"].(bool); ok {
p.IsPassed = isPassed
}
if rtObj, ok := property["rt"]; ok {
switch v := rtObj.(type) {
case float64:
p.RT = v
case string:
if rt, err := strconv.ParseFloat(v, 64); err == nil {
p.RT = rt
} else {
p.RT = 0.9
}
}
} else {
p.RT = 0.9
}
if playTime, ok := att["playTime"].(float64); ok {
p.PlayTime = int(playTime) / 1000
}
if mid, ok := att["mid"].(string); ok {
p.Mid = mid
}
if rct, ok := att["randomCaptureTime"].(string); ok {
p.RandomCaptureTime = rct
}
if ade, ok := att["attDurationEnc"].(string); ok {
p.AttDurationEnc = ade
logger.Logger.Debug("获取到 attDurationEnc", zap.String("attDurationEnc", ade))
}
if vfce, ok := att["videoFaceCaptureEnc"].(string); ok {
p.VideoFaceCaptureEnc = vfce
logger.Logger.Debug("获取到 videoFaceCaptureEnc", zap.String("videoFaceCaptureEnc", vfce))
}
break
}
if !found {
logger.Logger.Warn("未找到匹配的视频附件", zap.String("objectID", p.ObjectID))
}
if defaults := attachment.Defaults; defaults != nil {
if fid, ok := defaults["fid"].(string); ok {
p.FID, _ = strconv.Atoi(fid)
}
if userid, ok := defaults["userid"].(string); ok {
c.userID = userid
}
}
}
// AutoPlayVideoByPoint 自动播放视频(使用统一的 ChapterPoint)
func (c *XuexitongCourse) AutoPlayVideoByPoint(ctx context.Context, course entity.XueXiTCourse, p *entity.ChapterPoint) error {
if err := c.VideoDtoFetchByPoint(ctx, p); err != nil {
return fmt.Errorf("获取视频信息失败: %w", err)
}
if p.Duration <= 0 {
logger.Logger.Warn("视频时长为0,跳过", zap.String("jobID", p.JobID))
return nil
}
if p.IsPassed {
return nil
}
logger.Logger.Info("开始刷视频",
zap.String("jobID", p.JobID),
zap.Int("duration", p.Duration),
zap.Int("playTime", p.PlayTime),
zap.String("title", p.Title),
zap.String("userID", c.userID),
zap.String("cpi", p.Cpi),
zap.Int("FID", p.FID),
zap.String("otherInfo", p.OtherInfo))
playingTime := p.PlayTime
if !p.IsPassed && p.PlayTime == p.Duration {
playingTime = 0
}
selectSec := 58
isFirstSubmit := true
for {
var playReport string
var err error
isdrag := 0
if isFirstSubmit && playingTime == p.PlayTime {
isdrag = 3
}
playReport, err = c.VideoSubmitStudyTimeByPoint(ctx, p, playingTime, isdrag)
isFirstSubmit = false
if err != nil {
logger.Logger.Error("提交视频进度失败", zap.Error(err))
return err
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(playReport), &result); err == nil {
if isPassed, ok := result["isPassed"].(bool); ok && isPassed {
logger.Logger.Info("视频播放完成", zap.String("jobID", p.JobID))
c.onTaskComplete()
return nil
}
}
logger.Logger.Info("视频播放", zap.Int("playingTime", playingTime), zap.Int("duration", p.Duration))
if p.Duration-playingTime < selectSec && p.Duration != playingTime {
sleepTime := p.Duration - playingTime
playingTime = p.Duration
time.Sleep(time.Duration(sleepTime) * time.Second)
} else if playingTime >= p.Duration {
time.Sleep(5 * time.Second)
} else {
playingTime += selectSec
time.Sleep(time.Duration(selectSec) * time.Second)
}
}
}
// VideoDtoFetchByPoint 获取视频详细信息(使用统一的 ChapterPoint)
func (c *XuexitongCourse) VideoDtoFetchByPoint(ctx context.Context, p *entity.ChapterPoint) error {
params := url.Values{}
params.Set("k", strconv.Itoa(p.FID))
params.Set("flag", "normal")
params.Set("_dc", strconv.FormatInt(time.Now().UnixMilli(), 10))
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("mobile"),
}).Get(fmt.Sprintf("%s/%s?%s", ApiVideoStatus, p.ObjectID, params.Encode()))
if err != nil {
return err
}
if resp.StatusCode() != 200 {
return errors2.NewBusinessError(resp.StatusCode(), "获取视频信息失败")
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return err
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(body), &result); err != nil {
return err
}
if dtoken, ok := result["dtoken"].(string); ok {
p.DToken = dtoken
}
if duration, ok := result["duration"].(float64); ok {
p.Duration = int(duration)
}
if filename, ok := result["filename"].(string); ok {
if decoded, err := url.QueryUnescape(filename); err == nil {
p.Title = decoded
} else {
p.Title = filename
}
}
if status, ok := result["status"].(string); ok && status == "success" {
return nil
}
return errors2.NewBusinessError(503, "视频状态异常")
}
// VideoSubmitStudyTimeByPoint 提交视频观看进度(使用统一的 ChapterPoint)
func (c *XuexitongCourse) VideoSubmitStudyTimeByPoint(ctx context.Context, p *entity.ChapterPoint, playingTime, isdrag int) (string, error) {
clipTime := fmt.Sprintf("0_%d", p.Duration)
hash := md5.Sum([]byte(fmt.Sprintf("[%s][%s][%s][%s][%d][%s][%d][%s]",
p.ClassID, c.userID, p.JobID, p.ObjectID, playingTime*1000, VideoEncKey, p.Duration*1000, clipTime)))
enc := hex.EncodeToString(hash[:])
urlStr := "https://mooc1.chaoxing.com/mooc-ans/multimedia/log/a/" + p.Cpi + "/" + p.DToken +
"?clazzId=" + p.ClassID +
"&playingTime=" + strconv.Itoa(playingTime) +
"&duration=" + strconv.Itoa(p.Duration) +
"&clipTime=" + clipTime +
"&objectId=" + p.ObjectID +
"&otherInfo=" + p.OtherInfo +
"&courseId=" + p.CourseID +
"&jobid=" + p.JobID +
"&userid=" + c.userID +
"&isdrag=" + strconv.Itoa(isdrag) +
"&view=pc" +
"&enc=" + enc +
"&rt=" + fmt.Sprintf("%.2f", p.RT) +
"&videoFaceCaptureEnc=" + p.VideoFaceCaptureEnc +
"&dtype=Video" +
"&_t=" + strconv.FormatInt(time.Now().UnixMilli(), 10) +
"&attDuration=" + strconv.Itoa(p.Duration) +
"&attDurationEnc=" + p.AttDurationEnc
logger.Logger.Debug("VideoSubmitStudyTimeByPoint 请求参数",
zap.String("cpi", p.Cpi),
zap.String("dtoken", p.DToken),
zap.String("classID", p.ClassID),
zap.String("courseID", p.CourseID),
zap.String("objectID", p.ObjectID),
zap.String("jobID", p.JobID),
zap.String("userID", c.userID),
zap.Int("playingTime", playingTime),
zap.Int("duration", p.Duration),
zap.Int("isdrag", isdrag),
zap.String("otherInfo", p.OtherInfo),
zap.String("attDurationEnc", p.AttDurationEnc),
zap.String("videoFaceCaptureEnc", p.VideoFaceCaptureEnc),
zap.Float64("RT", p.RT),
zap.Int("FID", p.FID),
zap.String("enc", enc))
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0",
"Content-Type": "application/json",
"Accept": "*/*",
"Host": "mooc1.chaoxing.com",
"Connection": "keep-alive",
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Dest": "empty",
"Pragma": "no-cache",
"Sec-Ch-Ua-Platform": "Windows",
}).SetCookies([]*http.Cookie{
{Name: "fanyamoocs", Value: "11401F839C536D9E"},
{Name: "thirdRegist", Value: "0"},
{Name: "videojs_id", Value: "1778753"},
}).Get(urlStr)
if err != nil {
return err
}
if resp.StatusCode() != 200 {
return errors2.NewBusinessError(resp.StatusCode(), fmt.Sprintf("提交视频进度失败,状态码: %d", resp.StatusCode()))
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return "", err
}
return body, nil
}
@@ -0,0 +1,539 @@
package xuexitong
import (
"bytes"
"context"
"fmt"
"likedream-goframe/internal/application/xuexitong/entity"
"likedream-goframe/internal/framework/logger"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/avast/retry-go/v4"
"go.uber.org/zap"
"resty.dev/v3"
)
// attachmentDetectWork 从附件设置中检测并填充作业信息
func (c *XuexitongCourse) attachmentDetectWork(attachment *entity.AttachmentSetting, p *entity.ChapterPoint) {
for _, att := range attachment.Attachments {
property, ok := att["property"].(map[string]interface{})
if !ok {
continue
}
workId, _ := property["workid"].(string)
if workId != p.WorkID {
continue
}
if enc, ok := att["enc"].(string); ok {
p.Enc = enc
}
if title, ok := property["title"].(string); ok {
p.Title = title
}
break
}
if defaults := attachment.Defaults; defaults != nil {
if ktoken, ok := defaults["ktoken"].(string); ok {
p.KToken = ktoken
}
if userid, ok := defaults["userid"].(string); ok {
p.PUID = userid
}
}
}
// AttachmentsDetectionWork 从附件设置中检测并填充作业信息
func (c *XuexitongCourse) AttachmentsDetectionWork(attachment *entity.AttachmentSetting, p *entity.PointWorkDto) error {
if attachment == nil {
logger.Logger.Warn("attachment 为空")
return nil
}
logger.Logger.Debug("AttachmentsDetectionWork 开始",
zap.Int("attachmentsCount", len(attachment.Attachments)),
zap.String("targetWorkID", p.WorkID))
foundMatch := false
for i, att := range attachment.Attachments {
property, ok := att["property"].(map[string]interface{})
if !ok {
logger.Logger.Debug("附件 property 不存在", zap.Int("index", i))
continue
}
workId, _ := property["workid"].(string)
logger.Logger.Debug("检查附件",
zap.Int("index", i),
zap.String("workId", workId),
zap.String("targetWorkID", p.WorkID),
zap.Bool("match", workId == p.WorkID))
if workId != p.WorkID {
continue
}
foundMatch = true
if enc, ok := att["enc"].(string); ok {
p.Enc = enc
logger.Logger.Debug("从附件获取 enc", zap.String("enc", enc))
}
if title, ok := property["title"].(string); ok {
p.Title = title
}
break
}
if !foundMatch {
logger.Logger.Warn("未找到匹配的作业附件", zap.String("workID", p.WorkID))
}
if defaults := attachment.Defaults; defaults != nil {
logger.Logger.Debug("defaults 内容", zap.Any("defaults", defaults))
if ktoken, ok := defaults["ktoken"].(string); ok {
p.KToken = ktoken
logger.Logger.Debug("从 defaults 获取 ktoken", zap.String("ktoken", ktoken))
} else {
logger.Logger.Warn("defaults 中没有 ktoken")
}
if userid, ok := defaults["userid"].(string); ok {
p.PUID = userid
logger.Logger.Debug("从 defaults 获取 userid", zap.String("userid", userid))
} else {
logger.Logger.Warn("defaults 中没有 userid")
}
// 检查 defaults 中是否有 cpi,如果有则使用
if cpi, ok := defaults["cpi"]; ok {
switch v := cpi.(type) {
case string:
p.Cpi = v
logger.Logger.Debug("从 defaults 获取 cpi (string)", zap.String("cpi", v))
case float64:
p.Cpi = strconv.FormatInt(int64(v), 10)
logger.Logger.Debug("从 defaults 获取 cpi (float64)", zap.String("cpi", p.Cpi))
}
}
} else {
logger.Logger.Warn("attachment.Defaults 为空")
}
logger.Logger.Debug("AttachmentsDetectionWork 完成",
zap.String("PUID", p.PUID),
zap.String("KToken", p.KToken),
zap.String("Enc", p.Enc))
return nil
}
// AutoCompleteWorkByPoint 自动完成作业任务点(使用统一的 ChapterPoint)
func (c *XuexitongCourse) AutoCompleteWorkByPoint(ctx context.Context, course entity.XueXiTCourse, p *entity.ChapterPoint, aiConfig *AIConfig, autoSubmit bool) error {
if p.IsPassed {
logger.Logger.Info("作业已完成", zap.String("jobID", p.JobID), zap.String("title", p.Title))
return nil
}
// 如果没有AI配置,跳过
if aiConfig == nil {
logger.Logger.Warn("未配置AI,跳过作业", zap.String("title", p.Title))
return nil
}
// 转换为 PointWorkDto
workDto := &entity.PointWorkDto{
CardIndex: p.CardIndex,
CourseID: p.CourseID,
ClassID: p.ClassID,
KnowledgeID: p.KnowledgeID,
Cpi: p.Cpi,
WorkID: p.WorkID,
SchoolID: p.SchoolID,
JobID: p.JobID,
PUID: p.PUID,
KToken: p.KToken,
Enc: p.Enc,
Title: p.Title,
IsPassed: p.IsPassed,
}
// 关键修复:获取 AttachmentSetting 来填充 PUID、KToken、Enc 等参数
attachment, enc, err := c.PageMobileChapterCard(ctx, course, p.KnowledgeID, p.CardIndex)
if err != nil {
logger.Logger.Error("获取章节卡片失败", zap.String("title", p.Title), zap.Error(err))
return err
}
if attachment != nil {
// 填充作业参数
if err := c.AttachmentsDetectionWork(attachment, workDto); err != nil {
logger.Logger.Error("填充作业参数失败", zap.String("title", p.Title), zap.Error(err))
return err
}
// 只有当附件中没有获取到 enc 时,才使用从页面获取的 enc
if workDto.Enc == "" && enc != "" {
workDto.Enc = enc
logger.Logger.Debug("使用页面 enc", zap.String("enc", enc))
}
}
logger.Logger.Debug("作业参数",
zap.String("workID", workDto.WorkID),
zap.String("PUID", workDto.PUID),
zap.String("KToken", workDto.KToken),
zap.String("Enc", workDto.Enc),
zap.String("Cpi", workDto.Cpi))
// 使用AI自动完成作业
result, err := c.AutoCompleteWorkWithAI(ctx, workDto, *aiConfig, autoSubmit)
if err != nil {
logger.Logger.Error("AI答题失败", zap.String("title", p.Title), zap.Error(err))
return err
}
logger.Logger.Info("AI答题完成", zap.String("title", p.Title), zap.String("result", result))
return nil
}
// FetchWorkQuestions 获取作业题目
func (c *XuexitongCourse) FetchWorkQuestions(ctx context.Context, p *entity.PointWorkDto) (*entity.WorkDto, error) {
c.client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
defer func() {
c.client.SetRedirectPolicy(resty.NoRedirectPolicy())
}()
params := url.Values{}
params.Add("courseid", p.CourseID)
workID := p.WorkID
if p.SchoolID != "" && p.SchoolID != "0" {
workID = p.SchoolID + "-" + p.WorkID
}
params.Add("workid", workID)
params.Add("jobid", p.JobID)
params.Add("needRedirect", "true")
params.Add("knowledgeid", strconv.Itoa(p.KnowledgeID))
// 关键修复:使用 p.PUID 而不是 c.userID
params.Add("userid", p.PUID)
params.Add("ut", "s")
params.Add("clazzId", p.ClassID)
params.Add("cpi", p.Cpi)
params.Add("ktoken", p.KToken)
params.Add("enc", p.Enc)
logger.Logger.Debug("FetchWorkQuestions 请求参数",
zap.String("courseid", p.CourseID),
zap.String("workid", workID),
zap.String("jobid", p.JobID),
zap.Int("knowledgeid", p.KnowledgeID),
zap.String("userid", p.PUID),
zap.String("clazzId", p.ClassID),
zap.String("cpi", p.Cpi),
zap.String("ktoken", p.KToken),
zap.String("enc", p.Enc))
var body string
requestURL := ApiWorkFetch + "?" + params.Encode()
logger.Logger.Debug("FetchWorkQuestions 请求URL", zap.String("url", requestURL))
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("mobile"),
"Sec-Ch-Ua-Platform": "Windows",
"Accept": "*/*",
"Host": "mooc1-api.chaoxing.com",
"Connection": "keep-alive",
"Accept-Language": "zh_CN",
}).Get(requestURL)
if err != nil {
return err
}
body = resp.String()
logger.Logger.Debug("FetchWorkQuestions 响应", zap.String("body", body[:min(len(body), 500)]))
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return nil, err
}
workDto, err := c.parseWorkHTML(body, p)
if err != nil {
return nil, err
}
return workDto, nil
}
// parseWorkHTML 解析作业HTML页面,提取题目信息(使用 goquery,与 pkg/yatori-go-core 保持一致)
func (c *XuexitongCourse) parseWorkHTML(htmlContent string, p *entity.PointWorkDto) (*entity.WorkDto, error) {
workDto := &entity.WorkDto{
WorkId: p.WorkID,
ClassId: p.ClassID,
CourseId: p.CourseID,
KnowledgeId: strconv.Itoa(p.KnowledgeID),
Cpi: p.Cpi,
JobId: p.JobID,
UserId: p.PUID,
}
// 使用 goquery 解析 HTML
doc, err := goquery.NewDocumentFromReader(bytes.NewReader([]byte(htmlContent)))
if err != nil {
logger.Logger.Error("解析作业HTML失败", zap.Error(err))
return nil, err
}
// 解析作业信息(与 pkg/yatori-go-core/utils/XueXiTParseWorkUtils.go 中的 ParseWorkInform 一致)
findList := []string{"userId", "courseId", "classId", "api", "workAnswerId",
"answerId", "totalQuestionNum", "fullScore", "knowledgeid", "oldSchoolId",
"oldWorkId", "jobid", "workRelationId", "enc", "enc_work", "cpi", "workId"}
for _, find := range findList {
doc.Find("input#" + find).Each(func(i int, s *goquery.Selection) {
val, exists := s.Attr("value")
if exists {
switch find {
case "workAnswerId":
workDto.WorkAnswerId = val
case "answerId":
workDto.AnswerId = val
case "totalQuestionNum":
workDto.TotalQuestionNum = val
case "fullScore":
workDto.FullScore = val
case "oldWorkId":
workDto.OldWorkId = val
case "oldSchoolId":
workDto.OldSchoolId = val
case "api":
workDto.Api = val
case "workRelationId":
workDto.WorkRelationId = val
case "enc_work":
workDto.EncWork = val
}
}
})
}
// 截取试卷标题
doc.Find(".chapter-title").Each(func(i int, s *goquery.Selection) {
val, exists := s.Attr("workname")
if exists {
workDto.Title = val
}
})
// 解析题目(与 pkg/yatori-go-core/aggregation/xuexitong/XueXiTongCardAction.go 中的 ParseWorkQuestionAction 一致)
questionNodes := doc.Find("div.Py-mian1")
questionNodes.Each(func(i int, questionNode *goquery.Selection) {
// 获取题目ID
qid, _ := questionNode.Attr("data")
// 获取题目HTML
questionHTML, _ := questionNode.Html()
qdoc, err := goquery.NewDocumentFromReader(bytes.NewReader([]byte(questionHTML)))
if err != nil {
return
}
// 提取题目类型
quesTypeText := qdoc.Find(".Py-m1-title .quesType").Text()
quesType := extractQuestionType(quesTypeText)
// 提取题目文本
var quesTextBuilder strings.Builder
qdoc.Find(".Py-m1-title").Contents().Each(func(i int, n *goquery.Selection) {
quesTextBuilder.WriteString(n.Text())
})
quesText := cleanText(quesTextBuilder.String())
question := entity.WorkQuestion{
Qid: qid,
Text: quesText,
Options: make(map[string]string),
}
// 根据题型解析选项
switch quesType {
case "单选题":
question.Type = entity.QuestionTypeSingleChoice
qdoc.Find(".answerList.singleChoice li").Each(func(i int, s *goquery.Selection) {
optionLetter := s.Find("em.choose-opt").Text()
ccContent := s.Find("cc").Text()
if ccContent == "" {
img, exists := s.Find("cc img").Attr("src")
if exists {
ccContent = "Image: " + img
}
}
question.Options[optionLetter] = strings.TrimSpace(ccContent)
})
case "多选题":
question.Type = entity.QuestionTypeMultipleChoice
qdoc.Find(".answerList.multiChoice li").Each(func(i int, s *goquery.Selection) {
optionLetter := s.Find("em.choose-opt").Text()
ccContent := s.Find("cc").Text()
if ccContent == "" {
img, exists := s.Find("cc img").Attr("src")
if exists {
ccContent = "Image: " + img
}
}
question.Options[optionLetter] = strings.TrimSpace(ccContent)
})
case "判断题":
question.Type = entity.QuestionTypeTrueFalse
qdoc.Find(".answerList.panduan li").Each(func(i int, s *goquery.Selection) {
optionLetter := s.Find("em").Text()
pContent := s.Find("p").Text()
question.Options[optionLetter] = strings.TrimSpace(pContent)
})
case "填空题":
question.Type = entity.QuestionTypeFillBlank
// 统计填空数量
blankCount := 0
qdoc.Find("ul.blankList2").Each(func(i int, selection *goquery.Selection) {
blankCount++
})
question.BlankCount = blankCount
if question.BlankCount == 0 {
question.BlankCount = 1
}
case "简答题":
question.Type = entity.QuestionTypeShortAnswer
}
workDto.Questions = append(workDto.Questions, question)
})
logger.Logger.Debug("解析作业完成",
zap.String("title", workDto.Title),
zap.Int("questionCount", len(workDto.Questions)))
return workDto, nil
}
// extractQuestionType 从文本中提取题目类型
func extractQuestionType(text string) string {
start := strings.Index(text, "[")
end := strings.Index(text, "]")
if start != -1 && end != -1 && end > start {
return strings.TrimSpace(text[start+1 : end])
}
return ""
}
// cleanText 清理文本中的多余空白
func cleanText(text string) string {
text = strings.TrimSpace(text)
// 移除多余的空白字符
text = strings.Join(strings.Fields(text), " ")
return text
}
// SubmitWorkAnswers 提交作业答案
func (c *XuexitongCourse) SubmitWorkAnswers(ctx context.Context, workDto *entity.WorkDto, isSave bool) (string, error) {
pyFlag := ""
if isSave {
pyFlag = "1"
}
formMap := map[string]string{
"pyFlag": pyFlag,
"courseId": workDto.CourseId,
"classId": workDto.ClassId,
"api": workDto.Api,
"mooc": "0",
"workAnswerId": workDto.WorkAnswerId,
"answerId": "",
"totalQuestionNum": workDto.TotalQuestionNum,
"fullScore": workDto.FullScore,
"knowledgeid": workDto.KnowledgeId,
"oldSchoolId": workDto.OldSchoolId,
"oldWorkId": workDto.OldWorkId,
"jobid": workDto.JobId,
"workRelationId": workDto.WorkRelationId,
"enc_work": workDto.EncWork,
"isphone": "true",
"userId": workDto.UserId,
"workTimesEnc": "",
"isAccessibleCustomFid": "0",
}
answerwqbid := ""
for _, q := range workDto.Questions {
if q.Qid != "" {
answerwqbid += q.Qid + ","
}
switch q.Type {
case entity.QuestionTypeSingleChoice:
answer := strings.Join(q.Answers, "")
formMap["answer"+q.Qid] = answer
formMap["answertype"+q.Qid] = "0"
case entity.QuestionTypeMultipleChoice:
answer := strings.Join(q.Answers, "")
formMap["answer"+q.Qid] = answer
formMap["answertype"+q.Qid] = "1"
case entity.QuestionTypeFillBlank:
for key, val := range q.BlankAnswer {
re := regexp.MustCompile(`\d+`)
numbers := re.FindAllString(key, -1)
if len(numbers) > 0 {
formMap["answer"+q.Qid+numbers[0]] = val
}
}
formMap["tiankongsize"+q.Qid] = strconv.Itoa(q.BlankCount)
formMap["answertype"+q.Qid] = "2"
case entity.QuestionTypeTrueFalse:
answer := ""
for _, a := range q.Answers {
if a == "正确" || a == "对" {
answer = "true"
} else if a == "错误" || a == "错" {
answer = "false"
} else {
answer = a
}
}
formMap["answer"+q.Qid] = answer
formMap["answertype"+q.Qid] = "3"
case entity.QuestionTypeShortAnswer:
answer := strings.Join(q.Answers, "")
formMap["answer"+q.Qid] = answer
formMap["answertype"+q.Qid] = "4"
}
}
formMap["answerwqbid"] = answerwqbid
urlStr := fmt.Sprintf("%s?_classId=%s&courseid=%s&token=%s&totalQuestionNum=%s&ua=pc&formType=post&saveStatus=1&version=1&tempsave=1",
ApiWorkSubmit, workDto.ClassId, workDto.CourseId, workDto.EncWork, workDto.TotalQuestionNum)
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("mobile"),
"Content-Type": "application/x-www-form-urlencoded",
}).SetFormData(formMap).Post(urlStr)
if err != nil {
return err
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return "", err
}
return body, nil
}
+20 -12
View File
@@ -15,6 +15,7 @@ const (
ConfigAuthorizationKey = "server.authorization"
ConfigJwtKey = "server.jwt"
ConfigCaptchaBaseUrl = "captcha.baseUrl"
ConfigCaptchaApiKey = "captcha.apiKey"
ConfigS3AccessKey = "s3.accessKey"
ConfigS3SecretKey = "s3.secretKey"
ConfigS3BaseUrl = "s3.baseUrl"
@@ -28,33 +29,40 @@ const (
ConfigTaskConcurrency = "task.concurrency"
// Email配置常量
ConfigEmailSMTPHost = "email.smtpHost"
ConfigEmailSMTPPort = "email.smtpPort"
ConfigEmailSMTPUsername = "email.smtpUsername"
ConfigEmailSMTPPassword = "email.smtpPassword"
ConfigEmailFromEmail = "email.fromEmail"
ConfigEmailFromName = "email.fromName"
ConfigEmailAuthMode = "email.authMode"
ConfigEmailSMTPHost = "email.smtpHost"
ConfigEmailSMTPPort = "email.smtpPort"
ConfigEmailSMTPUsername = "email.smtpUsername"
ConfigEmailSMTPPassword = "email.smtpPassword"
ConfigEmailFromEmail = "email.fromEmail"
ConfigEmailFromName = "email.fromName"
ConfigEmailOAuthTenant = "email.oauthTenant"
ConfigEmailOAuthClientID = "email.oauthClientId"
ConfigEmailOAuthClientSecret = "email.oauthClientSecret"
ConfigEmailOAuthRedirectURL = "email.oauthRedirectUrl"
ConfigEmailOAuthRefreshToken = "email.oauthRefreshToken"
ConfigEmailOAuthScope = "email.oauthScope"
// 基础积分配置
ConfigBasePointCpdailyTask = "basePoint.cpdailyTask"
ConfigBasePointCpdailyLeave = "basePoint.cpdailyLeave"
ConfigBasePointCpdailyPhone = "basePoint.cpdailyPhone"
// 系统配置
ConfigSystemAllowRegister = "system.allowRegister" // 是否允许注册
ConfigSystemAllowRegister = "system.allowRegister"
ConfigSystemRequireInviteCode = "system.requireInviteCode"
ConfigRedisCpdailyTask = "price.cpdaily_task"
ConfigRedisCpdailyLeave = "price.cpdaily_leave"
ConfigRedisCpdailyPhone = "price.cpdaily_phone"
ConfigRedisXxt = "price.xxt"
ConfigRedisSmarteduSc = "price.smartedu_sc"
ConfigQuesBankUrl = "quesbank.url"
ConfigQuesBankKey = "quesbank.key"
)
const (
// 应用程序常量
AppName = "若梦系统"
AppCopyright = "© 2025 若梦系统. 保留所有权利."
AppCopyright = "© 2025 若梦系统. 保留所有权利"
)
var (
+3 -1
View File
@@ -7,6 +7,7 @@ const (
RedisBalanceMigrate = "balancemigrate:" // 积分转移token
RedisCpUserLogin = "cpuserlogin:"
RedisCpUserAuthorize = "cpuser:authorize:"
RedisCpUserQrCode = "cpuser:qrcode:" // 二维码UUID -> 系统用户ID
RedisSchoolInfo = "cpuser:schoolinfo:"
)
@@ -17,5 +18,6 @@ var (
RedisBalanceMigrateExpire = int64(3600) // 积分转移链接1小时有效
RedisCpUserLoginExpire = int64(180)
RedisCpUserAuthorizeExpire = int64(1800)
RedisSchoolInfoExpire = int64(3600) // 学校信息1小时有效
RedisCpUserQrCodeExpire = int64(300) // 二维码5分钟有效
RedisSchoolInfoExpire = int64(3600 * 24 * 30) // 学校信息30天有效
)
+6 -5
View File
@@ -1,11 +1,12 @@
package cst
const (
TaskCpdaily = "task:cpdaily"
TaskEmail = "task:email"
TaskResetDaily = "task:reset:daily"
TaskXuexitong = "task:xuexitong"
TaskXuexitongVio = "task:xuexitong:violence"
TaskCpdaily = "task:cpdaily"
TaskEmail = "task:email"
TaskResetDaily = "task:reset:daily"
TaskRefreshSchoolDetail = "task:refresh:school:detail"
TaskXuexitong = "task:xuexitong"
TaskXuexitongVio = "task:xuexitong:violence"
TaskCpdailyId = "cpdaily:task:schedule:%d"
+17 -13
View File
@@ -33,18 +33,22 @@ func InitDB() {
Global = client
dbDsn2, err := g.Cfg().Get(gctx.New(), cst.ConfigDatabaseDsn2)
utils.OkOrPanic(err)
GlobalGorm, err = gorm.Open(postgres.Open(dbDsn2.String()), &gorm.Config{})
if err != nil {
logger.Logger.Fatal("连接pg数据库失败: %v", zap.Error(err))
// PG 数据库为可选配置,如果 dsn2 为空则跳过
if dbDsn2.String() != "" {
GlobalGorm, err = gorm.Open(postgres.Open(dbDsn2.String()), &gorm.Config{})
if err != nil {
logger.Logger.Warn("连接pg数据库失败, 跳过: %v", zap.Error(err))
} else {
// 可选:配置连接池
sqlDB, err := GlobalGorm.DB()
if err != nil {
logger.Logger.Warn("获取pg数据库实例失败, 跳过: %v", zap.Error(err))
} else {
// 设置连接池参数
sqlDB.SetMaxIdleConns(10) // 设置空闲连接池中连接的最大数量
sqlDB.SetMaxOpenConns(100) // 设置打开数据库连接的最大数量
sqlDB.SetConnMaxLifetime(0) // 设置连接可复用的最大时间(0表示永久)
}
}
}
// 可选:配置连接池
sqlDB, err := GlobalGorm.DB()
if err != nil {
logger.Logger.Fatal("获取数据库实例失败: %v", zap.Error(err))
}
// 设置连接池参数
sqlDB.SetMaxIdleConns(10) // 设置空闲连接池中连接的最大数量
sqlDB.SetMaxOpenConns(100) // 设置打开数据库连接的最大数量
sqlDB.SetConnMaxLifetime(0) // 设置连接可复用的最大时间(0表示永久)
}
@@ -0,0 +1,40 @@
package config
import (
"context"
v1 "likedream-goframe/internal/api/v1"
"likedream-goframe/internal/service"
"likedream-goframe/internal/utils"
)
// AIConfigCtrlV1 AI配置控制器
type AIConfigCtrlV1 struct{}
// NewAIConfigCtrlV1 创建AI配置控制器
func NewAIConfigCtrlV1() IAIConfigCtrlV1 {
return &AIConfigCtrlV1{}
}
// Get 获取AI配置
func (c *AIConfigCtrlV1) Get(ctx context.Context, req *v1.AIConfigGetReq) (res *utils.Response[*v1.AIConfigGetResp], err error) {
defer utils.Pass(&err)
return service.Global.AIConfig.Get(ctx, req), nil
}
// Update 更新AI配置
func (c *AIConfigCtrlV1) Update(ctx context.Context, req *v1.AIConfigUpdateReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.AIConfig.Update(ctx, req), nil
}
// Test 测试AI配置
func (c *AIConfigCtrlV1) Test(ctx context.Context, req *v1.AIConfigTestReq) (res *utils.Response[*v1.AIConfigTestResp], err error) {
defer utils.Pass(&err)
return service.Global.AIConfig.Test(ctx, req), nil
}
// GetTypeList 获取支持的AI类型列表
func (c *AIConfigCtrlV1) GetTypeList(ctx context.Context, req *v1.AITypeListReq) (res *utils.Response[*v1.AITypeListResp], err error) {
defer utils.Pass(&err)
return service.Global.AIConfig.GetTypeList(ctx, req), nil
}
+22 -4
View File
@@ -2,14 +2,14 @@ package config
import (
"context"
v1 "likedream-goframe/internal/api/v1"
"likedream-goframe/internal/ent"
"likedream-goframe/internal/service"
"likedream-goframe/internal/utils"
)
type ConfigCtrlV1 struct {
}
type ConfigCtrlV1 struct{}
func (c ConfigCtrlV1) Get(ctx context.Context, req *v1.ConfigGetReq) (res *utils.Response[*ent.SysConfig], err error) {
defer utils.Pass(&err)
@@ -31,13 +31,11 @@ func (c ConfigCtrlV1) Update(ctx context.Context, req *v1.ConfigUpdateReq) (res
return service.Global.Config.Update(ctx, req), nil
}
// GetPriceConfig 获取费率配置
func (c ConfigCtrlV1) GetPriceConfig(ctx context.Context, req *v1.PriceConfigGetReq) (res *utils.Response[*v1.PriceConfigResp], err error) {
defer utils.Pass(&err)
return service.Global.Config.GetPriceConfig(ctx, req), nil
}
// UpdatePriceConfig 更新费率配置
func (c ConfigCtrlV1) UpdatePriceConfig(ctx context.Context, req *v1.PriceConfigUpdateReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.Config.UpdatePriceConfig(ctx, req), nil
@@ -46,3 +44,23 @@ func (c ConfigCtrlV1) UpdatePriceConfig(ctx context.Context, req *v1.PriceConfig
func NewConfigCtrlV1() IConfigCtrlV1 {
return &ConfigCtrlV1{}
}
func (c ConfigCtrlV1) GetSmtpConfig(ctx context.Context, req *v1.SmtpConfigGetReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.Config.GetSmtpConfig(ctx, req), nil
}
func (c ConfigCtrlV1) UpdateSmtpConfig(ctx context.Context, req *v1.SmtpConfigUpdateReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.Config.UpdateSmtpConfig(ctx, req), nil
}
func (c ConfigCtrlV1) TestSmtp(ctx context.Context, req *v1.SmtpTestReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.Config.TestSmtp(ctx, req), nil
}
func (c ConfigCtrlV1) GetOutlookOAuthAuthorizeURL(ctx context.Context, req *v1.OutlookOAuthAuthorizeURLReq) (res *utils.Response[*v1.OutlookOAuthAuthorizeURLResp], err error) {
defer utils.Pass(&err)
return service.Global.Config.GetOutlookOAuthAuthorizeURL(ctx, req), nil
}
@@ -0,0 +1,19 @@
package config
import (
"context"
v1 "likedream-goframe/internal/api/v1"
"likedream-goframe/internal/utils"
)
// IAIConfigCtrlV1 AI配置控制器接口
type IAIConfigCtrlV1 interface {
// Get 获取AI配置
Get(ctx context.Context, req *v1.AIConfigGetReq) (*utils.Response[*v1.AIConfigGetResp], error)
// Update 更新AI配置
Update(ctx context.Context, req *v1.AIConfigUpdateReq) (*utils.Response[any], error)
// Test 测试AI配置
Test(ctx context.Context, req *v1.AIConfigTestReq) (*utils.Response[*v1.AIConfigTestResp], error)
// GetTypeList 获取支持的AI类型列表
GetTypeList(ctx context.Context, req *v1.AITypeListReq) (*utils.Response[*v1.AITypeListResp], error)
}
@@ -2,6 +2,7 @@ package config
import (
"context"
v1 "likedream-goframe/internal/api/v1"
"likedream-goframe/internal/ent"
"likedream-goframe/internal/utils"
@@ -14,4 +15,8 @@ type IConfigCtrlV1 interface {
Update(ctx context.Context, req *v1.ConfigUpdateReq) (*utils.Response[any], error)
GetPriceConfig(ctx context.Context, req *v1.PriceConfigGetReq) (*utils.Response[*v1.PriceConfigResp], error)
UpdatePriceConfig(ctx context.Context, req *v1.PriceConfigUpdateReq) (*utils.Response[any], error)
GetSmtpConfig(ctx context.Context, req *v1.SmtpConfigGetReq) (*utils.Response[any], error)
UpdateSmtpConfig(ctx context.Context, req *v1.SmtpConfigUpdateReq) (*utils.Response[any], error)
TestSmtp(ctx context.Context, req *v1.SmtpTestReq) (*utils.Response[any], error)
GetOutlookOAuthAuthorizeURL(ctx context.Context, req *v1.OutlookOAuthAuthorizeURLReq) (*utils.Response[*v1.OutlookOAuthAuthorizeURLResp], error)
}
@@ -0,0 +1,37 @@
package config
import (
"context"
"fmt"
v1 "likedream-goframe/internal/api/v1"
mailservice "likedream-goframe/internal/service/email"
"likedream-goframe/internal/utils"
"github.com/gogf/gf/v2/net/ghttp"
)
type MailOAuthCtrlV1 struct{}
func NewMailOAuthCtrlV1() *MailOAuthCtrlV1 {
return &MailOAuthCtrlV1{}
}
func (c *MailOAuthCtrlV1) OutlookCallback(ctx context.Context, req *v1.OutlookOAuthCallbackReq) (res *utils.Response[any], err error) {
r := ghttp.RequestFromCtx(ctx)
r.Response.Header().Set("Content-Type", "text/html; charset=utf-8")
if req.Error != "" {
r.Response.Write(mailservice.BuildOutlookOAuthErrorHTML(fmt.Sprintf("%s: %s", req.Error, req.ErrorDescription)))
return nil, nil
}
refreshToken, frontendOrigin, exchangeErr := mailservice.NewV1().ExchangeOutlookAuthorizeCode(ctx, req.State, req.Code)
if exchangeErr != nil {
r.Response.Write(mailservice.BuildOutlookOAuthPopupHTML("*", false, exchangeErr.Error(), ""))
return nil, nil
}
r.Response.Write(mailservice.BuildOutlookOAuthPopupHTML(frontendOrigin, true, "授权成功", refreshToken))
return nil, nil
}
@@ -31,6 +31,31 @@ func (c CpLeaveCtrlV1) Upload(ctx context.Context, req *v1.CpLeaveUploadReq) (re
return service.Global.CpLeave.Upload(ctx, req), nil
}
func (c CpLeaveCtrlV1) CheckLeave(ctx context.Context, req *v1.CpLeaveCheckReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.CpLeave.CheckLeave(ctx, req), nil
}
func (c CpLeaveCtrlV1) GetLeaveTypes(ctx context.Context, req *v1.CpLeaveTypesReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.CpLeave.GetLeaveTypes(ctx, req), nil
}
func (c CpLeaveCtrlV1) GetLeaveTypeCfg(ctx context.Context, req *v1.CpLeaveTypeCfgReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.CpLeave.GetLeaveTypeCfg(ctx, req), nil
}
func (c CpLeaveCtrlV1) GetApproveFlow(ctx context.Context, req *v1.CpLeaveApproveFlowReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.CpLeave.GetApproveFlow(ctx, req), nil
}
func (c CpLeaveCtrlV1) Apply(ctx context.Context, req *v1.CpLeaveApplyReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.CpLeave.Apply(ctx, req), nil
}
func NewCpLeaveCtrlV1() ICpLeaveCtrlV1 {
return &CpLeaveCtrlV1{}
}
@@ -56,6 +56,11 @@ func (c CpTaskCtrlV1) Delete(ctx context.Context, req *v1.DeleteTaskReq) (res *u
return service.Global.CpTask.Delete(ctx, req), nil
}
func (c CpTaskCtrlV1) UpdateTaskTitle(ctx context.Context, req *v1.UpdateTaskTitleReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.CpTask.UpdateTaskTitle(ctx, req), nil
}
func (c CpTaskCtrlV1) ExecuteNow(ctx context.Context, req *v1.ExecuteNowReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.CpTask.ExecuteNow(ctx, req), nil
@@ -71,6 +76,16 @@ func (c CpTaskCtrlV1) GetTaskStatistics(ctx context.Context, req *v1.GetTaskStat
return service.Global.CpTask.GetTaskStatistics(ctx, req), nil
}
func (c CpTaskCtrlV1) GetTaskStatisticsV2(ctx context.Context, req *v1.GetTaskStatisticsV2Req) (res *utils.Response[[]*v1.TaskStatisticsV2Item], err error) {
defer utils.Pass(&err)
return service.Global.CpTask.GetTaskStatisticsV2(ctx, req), nil
}
func (c CpTaskCtrlV1) GetDashboardSummary(ctx context.Context, req *v1.GetDashboardSummaryReq) (res *utils.Response[*v1.DashboardSummary], err error) {
defer utils.Pass(&err)
return service.Global.CpTask.GetDashboardSummary(ctx, req), nil
}
func NewCpTaskCtrlV1() ICpTaskCtrlV1 {
return &CpTaskCtrlV1{}
}
+12 -2
View File
@@ -66,9 +66,9 @@ func (c CpUserCtrlV1) AuthorizeConfirm(ctx context.Context, req *v1.CpUserAuthor
return service.Global.CpUser.AuthorizeConfirm(ctx, req), nil
}
func (c CpUserCtrlV1) AuthorizeRandom(ctx context.Context, req *v1.CpUserAuthorizeRandomReq) (res *utils.Response[any], err error) {
func (c CpUserCtrlV1) CustomDevice(ctx context.Context, req *v1.CpUserCustomDeviceReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.CpUser.AuthorizeRandom(ctx, req), nil
return service.Global.CpUser.CustomDevice(ctx, req), nil
}
func (c CpUserCtrlV1) LoginGetCode(ctx context.Context, req *v1.LoginGetCode) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
@@ -101,3 +101,13 @@ func (c CpUserCtrlV1) AddByCode(ctx context.Context, req *v1.AddByCode) (res *ut
}
return service.Global.CpUser.AddByCode(ctx, req), nil
}
func (c CpUserCtrlV1) GenerateQr(ctx context.Context, req *v1.CpUserGenerateQrReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.CpUser.GenerateQr(ctx, req), nil
}
func (c CpUserCtrlV1) CheckQr(ctx context.Context, req *v1.CpUserCheckQrReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.CpUser.CheckQr(ctx, req), nil
}
@@ -12,4 +12,9 @@ type ICpLeaveCtrlV1 interface {
List(ctx context.Context, req *v1.CpLeaveListReq) (*utils.Response[[]*ent.CpLeave], error)
Report(ctx context.Context, req *v1.CpLeaveReportReq) (*utils.Response[any], error)
Upload(ctx context.Context, req *v1.CpLeaveUploadReq) (*utils.Response[string], error)
CheckLeave(ctx context.Context, req *v1.CpLeaveCheckReq) (*utils.Response[any], error)
GetLeaveTypes(ctx context.Context, req *v1.CpLeaveTypesReq) (*utils.Response[any], error)
GetLeaveTypeCfg(ctx context.Context, req *v1.CpLeaveTypeCfgReq) (*utils.Response[any], error)
GetApproveFlow(ctx context.Context, req *v1.CpLeaveApproveFlowReq) (*utils.Response[any], error)
Apply(ctx context.Context, req *v1.CpLeaveApplyReq) (*utils.Response[any], error)
}
@@ -19,5 +19,8 @@ type ICpTaskCtrlV1 interface {
ExecuteNow(ctx context.Context, req *v1.ExecuteNowReq) (res *utils.Response[any], err error)
PauseTask(ctx context.Context, req *v1.PauseTaskReq) (res *utils.Response[any], err error)
FindLocation(ctx context.Context, req *v1.FindLocationReq) (res *utils.Response[any], err error)
UpdateTaskTitle(ctx context.Context, req *v1.UpdateTaskTitleReq) (res *utils.Response[any], err error)
GetTaskStatistics(ctx context.Context, req *v1.GetTaskStatisticsReq) (res *utils.Response[[]*v1.TaskStatisticsItem], err error)
GetTaskStatisticsV2(ctx context.Context, req *v1.GetTaskStatisticsV2Req) (res *utils.Response[[]*v1.TaskStatisticsV2Item], err error)
GetDashboardSummary(ctx context.Context, req *v1.GetDashboardSummaryReq) (res *utils.Response[*v1.DashboardSummary], err error)
}
+6 -2
View File
@@ -25,10 +25,14 @@ type ICpUserCtrlV1 interface {
AuthorizeDevice(ctx context.Context, req *v1.CpUserAuthorizeDeviceReq) (*utils.Response[any], error)
// 验证授权是否成功
AuthorizeConfirm(ctx context.Context, req *v1.CpUserAuthorizeConfirmReq) (*utils.Response[any], error)
// 随机授权
AuthorizeRandom(ctx context.Context, req *v1.CpUserAuthorizeRandomReq) (res *utils.Response[any], err error)
// 自定义设备码
CustomDevice(ctx context.Context, req *v1.CpUserCustomDeviceReq) (res *utils.Response[any], err error)
// 手机登录获取验证码
LoginGetCode(ctx context.Context, req *v1.LoginGetCode) (*utils.Response[any], error)
AddByCode(ctx context.Context, req *v1.AddByCode) (*utils.Response[any], error)
// 二维码获取设备码
GenerateQr(ctx context.Context, req *v1.CpUserGenerateQrReq) (*utils.Response[any], error)
CheckQr(ctx context.Context, req *v1.CpUserCheckQrReq) (*utils.Response[any], error)
}
@@ -9,4 +9,6 @@ import (
type ICpImageCtrlV1 interface {
Upload(ctx context.Context, req *v1.UploadImageReq) (res *utils.Response[any], err error)
Preview(ctx context.Context, req *v1.PreviewImageReq) (res *utils.Response[any], err error)
GetUploadPolicy(ctx context.Context, req *v1.GetUploadPolicyReq) (res *utils.Response[any], err error)
GetPreviewUrl(ctx context.Context, req *v1.GetPreviewUrlReq) (res *utils.Response[any], err error)
}
@@ -20,6 +20,16 @@ func (c CpImageCtrlV1) Preview(ctx context.Context, req *v1.PreviewImageReq) (re
return service.Global.CpImage.Preview(ctx, req), nil
}
func (c CpImageCtrlV1) GetUploadPolicy(ctx context.Context, req *v1.GetUploadPolicyReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.CpImage.GetUploadPolicy(ctx, req), nil
}
func (c CpImageCtrlV1) GetPreviewUrl(ctx context.Context, req *v1.GetPreviewUrlReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.CpImage.GetPreviewUrl(ctx, req), nil
}
func NewCpImageCtrlV1() ICpImageCtrlV1 {
return &CpImageCtrlV1{}
}
@@ -8,6 +8,8 @@ import (
)
type IUserInfoV1 interface {
// 刷新Token
RefreshToken(ctx context.Context, req *v1.RefreshTokenReq) (*utils.Response[any], error)
PermissionList(ctx context.Context, _ *v1.CommonReq) (*utils.Response[*v1.UserPermissionRes], error)
//用户基础信息
UserInfo(ctx context.Context, _ *v1.CommonReq) (*utils.Response[any], error)
@@ -3,6 +3,7 @@ package user_login
import (
"context"
v1 "likedream-goframe/internal/api/v1"
cst "likedream-goframe/internal/common/cst"
"likedream-goframe/internal/ent"
"likedream-goframe/internal/framework/captcha"
"likedream-goframe/internal/framework/redis"
@@ -10,6 +11,7 @@ import (
"likedream-goframe/internal/utils"
"github.com/bytedance/sonic"
"github.com/gogf/gf/v2/net/ghttp"
)
type UserInfoV1 struct {
@@ -19,6 +21,15 @@ func NewUserInfoV1() IUserInfoV1 {
return &UserInfoV1{}
}
// 刷新Token(token有效期内可换取新token)
func (c *UserInfoV1) RefreshToken(ctx context.Context, _ *v1.RefreshTokenReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
user := utils.GetUserInfoFromCtx(ctx)
r := ghttp.RequestFromCtx(ctx)
r.Response.Header().Set(cst.AuthorizationKey, utils.GenerateToken(user.ID))
return utils.SuccessWithMsg("刷新成功"), nil
}
// 获取用户权限列表
func (c *UserInfoV1) PermissionList(ctx context.Context, _ *v1.CommonReq) (res *utils.Response[*v1.UserPermissionRes], err error) {
defer utils.Pass(&err)
+2 -1
View File
@@ -126,6 +126,7 @@ type Type string
// Type values.
const (
TypeReport Type = "report"
TypeApply Type = "apply"
)
func (_type Type) String() string {
@@ -135,7 +136,7 @@ func (_type Type) String() string {
// TypeValidator is a validator for the "type" field enum values. It is called by the builders before save.
func TypeValidator(_type Type) error {
switch _type {
case TypeReport:
case TypeReport, TypeApply:
return nil
default:
return fmt.Errorf("cpleave: invalid enum value for type field: %q", _type)
+16
View File
@@ -3,6 +3,7 @@
package ent
import (
"encoding/json"
"fmt"
"likedream-goframe/internal/ent/cptask"
"likedream-goframe/internal/ent/cpuser"
@@ -51,6 +52,8 @@ type CpTask struct {
ExecuteMinute *int `json:"execute_minute,omitempty"`
// NextExecuteTime holds the value of the "next_execute_time" field.
NextExecuteTime *int64 `json:"next_execute_time,omitempty"`
// 执行星期 1-7,1=周一,7=周日
ExecuteDays []int `json:"execute_days,omitempty"`
// Edges holds the relations/edges for other nodes in the graph.
// The values are being populated by the CpTaskQuery when eager-loading is set.
Edges CpTaskEdges `json:"edges"`
@@ -107,6 +110,8 @@ func (*CpTask) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
for i := range columns {
switch columns[i] {
case cptask.FieldExecuteDays:
values[i] = new([]byte)
case cptask.FieldCreateTime, cptask.FieldUpdateTime:
values[i] = new(ent2.CustomTime)
case cptask.FieldID, cptask.FieldCpUserID, cptask.FieldUserID, cptask.FieldFrequency, cptask.FieldSuccessCount, cptask.FieldExecuteHour, cptask.FieldExecuteMinute, cptask.FieldNextExecuteTime:
@@ -233,6 +238,14 @@ func (_m *CpTask) assignValues(columns []string, values []any) error {
_m.NextExecuteTime = new(int64)
*_m.NextExecuteTime = value.Int64
}
case cptask.FieldExecuteDays:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field execute_days", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &_m.ExecuteDays); err != nil {
return fmt.Errorf("unmarshal field execute_days: %w", err)
}
}
default:
_m.selectValues.Set(columns[i], values[i])
}
@@ -337,6 +350,9 @@ func (_m *CpTask) String() string {
builder.WriteString("next_execute_time=")
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
builder.WriteString("execute_days=")
builder.WriteString(fmt.Sprintf("%v", _m.ExecuteDays))
builder.WriteByte(')')
return builder.String()
}
+3
View File
@@ -48,6 +48,8 @@ const (
FieldExecuteMinute = "execute_minute"
// FieldNextExecuteTime holds the string denoting the next_execute_time field in the database.
FieldNextExecuteTime = "next_execute_time"
// FieldExecuteDays holds the string denoting the execute_days field in the database.
FieldExecuteDays = "execute_days"
// EdgeCpUser holds the string denoting the cp_user edge name in mutations.
EdgeCpUser = "cp_user"
// EdgeSysUser holds the string denoting the sys_user edge name in mutations.
@@ -98,6 +100,7 @@ var Columns = []string{
FieldExecuteHour,
FieldExecuteMinute,
FieldNextExecuteTime,
FieldExecuteDays,
}
// ValidColumn reports if the column name is valid (part of the table columns).
+10
View File
@@ -821,6 +821,16 @@ func NextExecuteTimeNotNil() predicate.CpTask {
return predicate.CpTask(sql.FieldNotNull(FieldNextExecuteTime))
}
// ExecuteDaysIsNil applies the IsNil predicate on the "execute_days" field.
func ExecuteDaysIsNil() predicate.CpTask {
return predicate.CpTask(sql.FieldIsNull(FieldExecuteDays))
}
// ExecuteDaysNotNil applies the NotNil predicate on the "execute_days" field.
func ExecuteDaysNotNil() predicate.CpTask {
return predicate.CpTask(sql.FieldNotNull(FieldExecuteDays))
}
// HasCpUser applies the HasEdge predicate on the "cp_user" edge.
func HasCpUser() predicate.CpTask {
return predicate.CpTask(func(s *sql.Selector) {
+70
View File
@@ -177,6 +177,12 @@ func (_c *CpTaskCreate) SetNillableNextExecuteTime(v *int64) *CpTaskCreate {
return _c
}
// SetExecuteDays sets the "execute_days" field.
func (_c *CpTaskCreate) SetExecuteDays(v []int) *CpTaskCreate {
_c.mutation.SetExecuteDays(v)
return _c
}
// SetCpUser sets the "cp_user" edge to the CpUser entity.
func (_c *CpTaskCreate) SetCpUser(v *CpUser) *CpTaskCreate {
return _c.SetCpUserID(v.ID)
@@ -420,6 +426,10 @@ func (_c *CpTaskCreate) createSpec() (*CpTask, *sqlgraph.CreateSpec) {
_spec.SetField(cptask.FieldNextExecuteTime, field.TypeInt64, value)
_node.NextExecuteTime = &value
}
if value, ok := _c.mutation.ExecuteDays(); ok {
_spec.SetField(cptask.FieldExecuteDays, field.TypeJSON, value)
_node.ExecuteDays = value
}
if nodes := _c.mutation.CpUserIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
@@ -765,6 +775,24 @@ func (u *CpTaskUpsert) ClearNextExecuteTime() *CpTaskUpsert {
return u
}
// SetExecuteDays sets the "execute_days" field.
func (u *CpTaskUpsert) SetExecuteDays(v []int) *CpTaskUpsert {
u.Set(cptask.FieldExecuteDays, v)
return u
}
// UpdateExecuteDays sets the "execute_days" field to the value that was provided on create.
func (u *CpTaskUpsert) UpdateExecuteDays() *CpTaskUpsert {
u.SetExcluded(cptask.FieldExecuteDays)
return u
}
// ClearExecuteDays clears the value of the "execute_days" field.
func (u *CpTaskUpsert) ClearExecuteDays() *CpTaskUpsert {
u.SetNull(cptask.FieldExecuteDays)
return u
}
// UpdateNewValues updates the mutable fields using the new values that were set on create.
// Using this option is equivalent to using:
//
@@ -1090,6 +1118,27 @@ func (u *CpTaskUpsertOne) ClearNextExecuteTime() *CpTaskUpsertOne {
})
}
// SetExecuteDays sets the "execute_days" field.
func (u *CpTaskUpsertOne) SetExecuteDays(v []int) *CpTaskUpsertOne {
return u.Update(func(s *CpTaskUpsert) {
s.SetExecuteDays(v)
})
}
// UpdateExecuteDays sets the "execute_days" field to the value that was provided on create.
func (u *CpTaskUpsertOne) UpdateExecuteDays() *CpTaskUpsertOne {
return u.Update(func(s *CpTaskUpsert) {
s.UpdateExecuteDays()
})
}
// ClearExecuteDays clears the value of the "execute_days" field.
func (u *CpTaskUpsertOne) ClearExecuteDays() *CpTaskUpsertOne {
return u.Update(func(s *CpTaskUpsert) {
s.ClearExecuteDays()
})
}
// Exec executes the query.
func (u *CpTaskUpsertOne) Exec(ctx context.Context) error {
if len(u.create.conflict) == 0 {
@@ -1581,6 +1630,27 @@ func (u *CpTaskUpsertBulk) ClearNextExecuteTime() *CpTaskUpsertBulk {
})
}
// SetExecuteDays sets the "execute_days" field.
func (u *CpTaskUpsertBulk) SetExecuteDays(v []int) *CpTaskUpsertBulk {
return u.Update(func(s *CpTaskUpsert) {
s.SetExecuteDays(v)
})
}
// UpdateExecuteDays sets the "execute_days" field to the value that was provided on create.
func (u *CpTaskUpsertBulk) UpdateExecuteDays() *CpTaskUpsertBulk {
return u.Update(func(s *CpTaskUpsert) {
s.UpdateExecuteDays()
})
}
// ClearExecuteDays clears the value of the "execute_days" field.
func (u *CpTaskUpsertBulk) ClearExecuteDays() *CpTaskUpsertBulk {
return u.Update(func(s *CpTaskUpsert) {
s.ClearExecuteDays()
})
}
// Exec executes the query.
func (u *CpTaskUpsertBulk) Exec(ctx context.Context) error {
if u.create.err != nil {
+59
View File
@@ -16,6 +16,7 @@ import (
"entgo.io/ent/dialect/sql"
"entgo.io/ent/dialect/sql/sqlgraph"
"entgo.io/ent/dialect/sql/sqljson"
"entgo.io/ent/schema/field"
)
@@ -300,6 +301,24 @@ func (_u *CpTaskUpdate) ClearNextExecuteTime() *CpTaskUpdate {
return _u
}
// SetExecuteDays sets the "execute_days" field.
func (_u *CpTaskUpdate) SetExecuteDays(v []int) *CpTaskUpdate {
_u.mutation.SetExecuteDays(v)
return _u
}
// AppendExecuteDays appends value to the "execute_days" field.
func (_u *CpTaskUpdate) AppendExecuteDays(v []int) *CpTaskUpdate {
_u.mutation.AppendExecuteDays(v)
return _u
}
// ClearExecuteDays clears the value of the "execute_days" field.
func (_u *CpTaskUpdate) ClearExecuteDays() *CpTaskUpdate {
_u.mutation.ClearExecuteDays()
return _u
}
// SetCpUser sets the "cp_user" edge to the CpUser entity.
func (_u *CpTaskUpdate) SetCpUser(v *CpUser) *CpTaskUpdate {
return _u.SetCpUserID(v.ID)
@@ -519,6 +538,17 @@ func (_u *CpTaskUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if _u.mutation.NextExecuteTimeCleared() {
_spec.ClearField(cptask.FieldNextExecuteTime, field.TypeInt64)
}
if value, ok := _u.mutation.ExecuteDays(); ok {
_spec.SetField(cptask.FieldExecuteDays, field.TypeJSON, value)
}
if value, ok := _u.mutation.AppendedExecuteDays(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, cptask.FieldExecuteDays, value)
})
}
if _u.mutation.ExecuteDaysCleared() {
_spec.ClearField(cptask.FieldExecuteDays, field.TypeJSON)
}
if _u.mutation.CpUserCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
@@ -920,6 +950,24 @@ func (_u *CpTaskUpdateOne) ClearNextExecuteTime() *CpTaskUpdateOne {
return _u
}
// SetExecuteDays sets the "execute_days" field.
func (_u *CpTaskUpdateOne) SetExecuteDays(v []int) *CpTaskUpdateOne {
_u.mutation.SetExecuteDays(v)
return _u
}
// AppendExecuteDays appends value to the "execute_days" field.
func (_u *CpTaskUpdateOne) AppendExecuteDays(v []int) *CpTaskUpdateOne {
_u.mutation.AppendExecuteDays(v)
return _u
}
// ClearExecuteDays clears the value of the "execute_days" field.
func (_u *CpTaskUpdateOne) ClearExecuteDays() *CpTaskUpdateOne {
_u.mutation.ClearExecuteDays()
return _u
}
// SetCpUser sets the "cp_user" edge to the CpUser entity.
func (_u *CpTaskUpdateOne) SetCpUser(v *CpUser) *CpTaskUpdateOne {
return _u.SetCpUserID(v.ID)
@@ -1169,6 +1217,17 @@ func (_u *CpTaskUpdateOne) sqlSave(ctx context.Context) (_node *CpTask, err erro
if _u.mutation.NextExecuteTimeCleared() {
_spec.ClearField(cptask.FieldNextExecuteTime, field.TypeInt64)
}
if value, ok := _u.mutation.ExecuteDays(); ok {
_spec.SetField(cptask.FieldExecuteDays, field.TypeJSON, value)
}
if value, ok := _u.mutation.AppendedExecuteDays(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, cptask.FieldExecuteDays, value)
})
}
if _u.mutation.ExecuteDaysCleared() {
_spec.ClearField(cptask.FieldExecuteDays, field.TypeJSON)
}
if _u.mutation.CpUserCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
+12 -1
View File
@@ -37,6 +37,8 @@ type CpUser struct {
MobileCode string `json:"mobile_code,omitempty"`
// Cookie holds the value of the "cookie" field.
Cookie string `json:"cookie,omitempty"`
// Tgc holds the value of the "tgc" field.
Tgc string `json:"tgc,omitempty"`
// MobileType holds the value of the "mobile_type" field.
MobileType cpuser.MobileType `json:"mobile_type,omitempty"`
// LoginType holds the value of the "login_type" field.
@@ -125,7 +127,7 @@ func (*CpUser) scanValues(columns []string) ([]any, error) {
values[i] = new(ent2.CustomTime)
case cpuser.FieldID, cpuser.FieldUserID:
values[i] = new(sql.NullInt64)
case cpuser.FieldUsername, cpuser.FieldPassword, cpuser.FieldSchoolID, cpuser.FieldEmail, cpuser.FieldMobileCode, cpuser.FieldCookie, cpuser.FieldMobileType, cpuser.FieldLoginType:
case cpuser.FieldUsername, cpuser.FieldPassword, cpuser.FieldSchoolID, cpuser.FieldEmail, cpuser.FieldMobileCode, cpuser.FieldCookie, cpuser.FieldTgc, cpuser.FieldMobileType, cpuser.FieldLoginType:
values[i] = new(sql.NullString)
default:
values[i] = new(sql.UnknownType)
@@ -202,6 +204,12 @@ func (_m *CpUser) assignValues(columns []string, values []any) error {
} else if value.Valid {
_m.Cookie = value.String
}
case cpuser.FieldTgc:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field tgc", values[i])
} else if value.Valid {
_m.Tgc = value.String
}
case cpuser.FieldMobileType:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field mobile_type", values[i])
@@ -302,6 +310,9 @@ func (_m *CpUser) String() string {
builder.WriteString("cookie=")
builder.WriteString(_m.Cookie)
builder.WriteString(", ")
builder.WriteString("tgc=")
builder.WriteString(_m.Tgc)
builder.WriteString(", ")
builder.WriteString("mobile_type=")
builder.WriteString(fmt.Sprintf("%v", _m.MobileType))
builder.WriteString(", ")
+10 -1
View File
@@ -34,6 +34,8 @@ const (
FieldMobileCode = "mobile_code"
// FieldCookie holds the string denoting the cookie field in the database.
FieldCookie = "cookie"
// FieldTgc holds the string denoting the tgc field in the database.
FieldTgc = "tgc"
// FieldMobileType holds the string denoting the mobile_type field in the database.
FieldMobileType = "mobile_type"
// FieldLoginType holds the string denoting the login_type field in the database.
@@ -99,6 +101,7 @@ var Columns = []string{
FieldEmail,
FieldMobileCode,
FieldCookie,
FieldTgc,
FieldMobileType,
FieldLoginType,
}
@@ -137,6 +140,7 @@ type MobileType string
const (
MobileTypeRandom MobileType = "random"
MobileTypeAuthorize MobileType = "authorize"
MobileTypeManual MobileType = "manual"
)
func (mt MobileType) String() string {
@@ -146,7 +150,7 @@ func (mt MobileType) String() string {
// MobileTypeValidator is a validator for the "mobile_type" field enum values. It is called by the builders before save.
func MobileTypeValidator(mt MobileType) error {
switch mt {
case MobileTypeRandom, MobileTypeAuthorize:
case MobileTypeRandom, MobileTypeAuthorize, MobileTypeManual:
return nil
default:
return fmt.Errorf("cpuser: invalid enum value for mobile_type field: %q", mt)
@@ -232,6 +236,11 @@ func ByCookie(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldCookie, opts...).ToFunc()
}
// ByTgc orders the results by the tgc field.
func ByTgc(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldTgc, opts...).ToFunc()
}
// ByMobileType orders the results by the mobile_type field.
func ByMobileType(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldMobileType, opts...).ToFunc()
+80
View File
@@ -101,6 +101,11 @@ func Cookie(v string) predicate.CpUser {
return predicate.CpUser(sql.FieldEQ(FieldCookie, v))
}
// Tgc applies equality check predicate on the "tgc" field. It's identical to TgcEQ.
func Tgc(v string) predicate.CpUser {
return predicate.CpUser(sql.FieldEQ(FieldTgc, v))
}
// CreateTimeEQ applies the EQ predicate on the "create_time" field.
func CreateTimeEQ(v *ent2.CustomTime) predicate.CpUser {
return predicate.CpUser(sql.FieldEQ(FieldCreateTime, v))
@@ -691,6 +696,81 @@ func CookieContainsFold(v string) predicate.CpUser {
return predicate.CpUser(sql.FieldContainsFold(FieldCookie, v))
}
// TgcEQ applies the EQ predicate on the "tgc" field.
func TgcEQ(v string) predicate.CpUser {
return predicate.CpUser(sql.FieldEQ(FieldTgc, v))
}
// TgcNEQ applies the NEQ predicate on the "tgc" field.
func TgcNEQ(v string) predicate.CpUser {
return predicate.CpUser(sql.FieldNEQ(FieldTgc, v))
}
// TgcIn applies the In predicate on the "tgc" field.
func TgcIn(vs ...string) predicate.CpUser {
return predicate.CpUser(sql.FieldIn(FieldTgc, vs...))
}
// TgcNotIn applies the NotIn predicate on the "tgc" field.
func TgcNotIn(vs ...string) predicate.CpUser {
return predicate.CpUser(sql.FieldNotIn(FieldTgc, vs...))
}
// TgcGT applies the GT predicate on the "tgc" field.
func TgcGT(v string) predicate.CpUser {
return predicate.CpUser(sql.FieldGT(FieldTgc, v))
}
// TgcGTE applies the GTE predicate on the "tgc" field.
func TgcGTE(v string) predicate.CpUser {
return predicate.CpUser(sql.FieldGTE(FieldTgc, v))
}
// TgcLT applies the LT predicate on the "tgc" field.
func TgcLT(v string) predicate.CpUser {
return predicate.CpUser(sql.FieldLT(FieldTgc, v))
}
// TgcLTE applies the LTE predicate on the "tgc" field.
func TgcLTE(v string) predicate.CpUser {
return predicate.CpUser(sql.FieldLTE(FieldTgc, v))
}
// TgcContains applies the Contains predicate on the "tgc" field.
func TgcContains(v string) predicate.CpUser {
return predicate.CpUser(sql.FieldContains(FieldTgc, v))
}
// TgcHasPrefix applies the HasPrefix predicate on the "tgc" field.
func TgcHasPrefix(v string) predicate.CpUser {
return predicate.CpUser(sql.FieldHasPrefix(FieldTgc, v))
}
// TgcHasSuffix applies the HasSuffix predicate on the "tgc" field.
func TgcHasSuffix(v string) predicate.CpUser {
return predicate.CpUser(sql.FieldHasSuffix(FieldTgc, v))
}
// TgcIsNil applies the IsNil predicate on the "tgc" field.
func TgcIsNil() predicate.CpUser {
return predicate.CpUser(sql.FieldIsNull(FieldTgc))
}
// TgcNotNil applies the NotNil predicate on the "tgc" field.
func TgcNotNil() predicate.CpUser {
return predicate.CpUser(sql.FieldNotNull(FieldTgc))
}
// TgcEqualFold applies the EqualFold predicate on the "tgc" field.
func TgcEqualFold(v string) predicate.CpUser {
return predicate.CpUser(sql.FieldEqualFold(FieldTgc, v))
}
// TgcContainsFold applies the ContainsFold predicate on the "tgc" field.
func TgcContainsFold(v string) predicate.CpUser {
return predicate.CpUser(sql.FieldContainsFold(FieldTgc, v))
}
// MobileTypeEQ applies the EQ predicate on the "mobile_type" field.
func MobileTypeEQ(v MobileType) predicate.CpUser {
return predicate.CpUser(sql.FieldEQ(FieldMobileType, v))
+78
View File
@@ -113,6 +113,20 @@ func (_c *CpUserCreate) SetNillableCookie(v *string) *CpUserCreate {
return _c
}
// SetTgc sets the "tgc" field.
func (_c *CpUserCreate) SetTgc(v string) *CpUserCreate {
_c.mutation.SetTgc(v)
return _c
}
// SetNillableTgc sets the "tgc" field if the given value is not nil.
func (_c *CpUserCreate) SetNillableTgc(v *string) *CpUserCreate {
if v != nil {
_c.SetTgc(*v)
}
return _c
}
// SetMobileType sets the "mobile_type" field.
func (_c *CpUserCreate) SetMobileType(v cpuser.MobileType) *CpUserCreate {
_c.mutation.SetMobileType(v)
@@ -357,6 +371,10 @@ func (_c *CpUserCreate) createSpec() (*CpUser, *sqlgraph.CreateSpec) {
_spec.SetField(cpuser.FieldCookie, field.TypeString, value)
_node.Cookie = value
}
if value, ok := _c.mutation.Tgc(); ok {
_spec.SetField(cpuser.FieldTgc, field.TypeString, value)
_node.Tgc = value
}
if value, ok := _c.mutation.MobileType(); ok {
_spec.SetField(cpuser.FieldMobileType, field.TypeEnum, value)
_node.MobileType = value
@@ -624,6 +642,24 @@ func (u *CpUserUpsert) ClearCookie() *CpUserUpsert {
return u
}
// SetTgc sets the "tgc" field.
func (u *CpUserUpsert) SetTgc(v string) *CpUserUpsert {
u.Set(cpuser.FieldTgc, v)
return u
}
// UpdateTgc sets the "tgc" field to the value that was provided on create.
func (u *CpUserUpsert) UpdateTgc() *CpUserUpsert {
u.SetExcluded(cpuser.FieldTgc)
return u
}
// ClearTgc clears the value of the "tgc" field.
func (u *CpUserUpsert) ClearTgc() *CpUserUpsert {
u.SetNull(cpuser.FieldTgc)
return u
}
// SetMobileType sets the "mobile_type" field.
func (u *CpUserUpsert) SetMobileType(v cpuser.MobileType) *CpUserUpsert {
u.Set(cpuser.FieldMobileType, v)
@@ -833,6 +869,27 @@ func (u *CpUserUpsertOne) ClearCookie() *CpUserUpsertOne {
})
}
// SetTgc sets the "tgc" field.
func (u *CpUserUpsertOne) SetTgc(v string) *CpUserUpsertOne {
return u.Update(func(s *CpUserUpsert) {
s.SetTgc(v)
})
}
// UpdateTgc sets the "tgc" field to the value that was provided on create.
func (u *CpUserUpsertOne) UpdateTgc() *CpUserUpsertOne {
return u.Update(func(s *CpUserUpsert) {
s.UpdateTgc()
})
}
// ClearTgc clears the value of the "tgc" field.
func (u *CpUserUpsertOne) ClearTgc() *CpUserUpsertOne {
return u.Update(func(s *CpUserUpsert) {
s.ClearTgc()
})
}
// SetMobileType sets the "mobile_type" field.
func (u *CpUserUpsertOne) SetMobileType(v cpuser.MobileType) *CpUserUpsertOne {
return u.Update(func(s *CpUserUpsert) {
@@ -1212,6 +1269,27 @@ func (u *CpUserUpsertBulk) ClearCookie() *CpUserUpsertBulk {
})
}
// SetTgc sets the "tgc" field.
func (u *CpUserUpsertBulk) SetTgc(v string) *CpUserUpsertBulk {
return u.Update(func(s *CpUserUpsert) {
s.SetTgc(v)
})
}
// UpdateTgc sets the "tgc" field to the value that was provided on create.
func (u *CpUserUpsertBulk) UpdateTgc() *CpUserUpsertBulk {
return u.Update(func(s *CpUserUpsert) {
s.UpdateTgc()
})
}
// ClearTgc clears the value of the "tgc" field.
func (u *CpUserUpsertBulk) ClearTgc() *CpUserUpsertBulk {
return u.Update(func(s *CpUserUpsert) {
s.ClearTgc()
})
}
// SetMobileType sets the "mobile_type" field.
func (u *CpUserUpsertBulk) SetMobileType(v cpuser.MobileType) *CpUserUpsertBulk {
return u.Update(func(s *CpUserUpsert) {
+52
View File
@@ -163,6 +163,26 @@ func (_u *CpUserUpdate) ClearCookie() *CpUserUpdate {
return _u
}
// SetTgc sets the "tgc" field.
func (_u *CpUserUpdate) SetTgc(v string) *CpUserUpdate {
_u.mutation.SetTgc(v)
return _u
}
// SetNillableTgc sets the "tgc" field if the given value is not nil.
func (_u *CpUserUpdate) SetNillableTgc(v *string) *CpUserUpdate {
if v != nil {
_u.SetTgc(*v)
}
return _u
}
// ClearTgc clears the value of the "tgc" field.
func (_u *CpUserUpdate) ClearTgc() *CpUserUpdate {
_u.mutation.ClearTgc()
return _u
}
// SetMobileType sets the "mobile_type" field.
func (_u *CpUserUpdate) SetMobileType(v cpuser.MobileType) *CpUserUpdate {
_u.mutation.SetMobileType(v)
@@ -438,6 +458,12 @@ func (_u *CpUserUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if _u.mutation.CookieCleared() {
_spec.ClearField(cpuser.FieldCookie, field.TypeString)
}
if value, ok := _u.mutation.Tgc(); ok {
_spec.SetField(cpuser.FieldTgc, field.TypeString, value)
}
if _u.mutation.TgcCleared() {
_spec.ClearField(cpuser.FieldTgc, field.TypeString)
}
if value, ok := _u.mutation.MobileType(); ok {
_spec.SetField(cpuser.FieldMobileType, field.TypeEnum, value)
}
@@ -802,6 +828,26 @@ func (_u *CpUserUpdateOne) ClearCookie() *CpUserUpdateOne {
return _u
}
// SetTgc sets the "tgc" field.
func (_u *CpUserUpdateOne) SetTgc(v string) *CpUserUpdateOne {
_u.mutation.SetTgc(v)
return _u
}
// SetNillableTgc sets the "tgc" field if the given value is not nil.
func (_u *CpUserUpdateOne) SetNillableTgc(v *string) *CpUserUpdateOne {
if v != nil {
_u.SetTgc(*v)
}
return _u
}
// ClearTgc clears the value of the "tgc" field.
func (_u *CpUserUpdateOne) ClearTgc() *CpUserUpdateOne {
_u.mutation.ClearTgc()
return _u
}
// SetMobileType sets the "mobile_type" field.
func (_u *CpUserUpdateOne) SetMobileType(v cpuser.MobileType) *CpUserUpdateOne {
_u.mutation.SetMobileType(v)
@@ -1107,6 +1153,12 @@ func (_u *CpUserUpdateOne) sqlSave(ctx context.Context) (_node *CpUser, err erro
if _u.mutation.CookieCleared() {
_spec.ClearField(cpuser.FieldCookie, field.TypeString)
}
if value, ok := _u.mutation.Tgc(); ok {
_spec.SetField(cpuser.FieldTgc, field.TypeString, value)
}
if _u.mutation.TgcCleared() {
_spec.ClearField(cpuser.FieldTgc, field.TypeString)
}
if value, ok := _u.mutation.MobileType(); ok {
_spec.SetField(cpuser.FieldMobileType, field.TypeEnum, value)
}
+12
View File
@@ -146,6 +146,7 @@ var schemaGraph = func() *sqlgraph.Schema {
cptask.FieldExecuteHour: {Type: field.TypeInt, Column: cptask.FieldExecuteHour},
cptask.FieldExecuteMinute: {Type: field.TypeInt, Column: cptask.FieldExecuteMinute},
cptask.FieldNextExecuteTime: {Type: field.TypeInt64, Column: cptask.FieldNextExecuteTime},
cptask.FieldExecuteDays: {Type: field.TypeJSON, Column: cptask.FieldExecuteDays},
},
}
graph.Nodes[5] = &sqlgraph.Node{
@@ -189,6 +190,7 @@ var schemaGraph = func() *sqlgraph.Schema {
cpuser.FieldEmail: {Type: field.TypeString, Column: cpuser.FieldEmail},
cpuser.FieldMobileCode: {Type: field.TypeString, Column: cpuser.FieldMobileCode},
cpuser.FieldCookie: {Type: field.TypeString, Column: cpuser.FieldCookie},
cpuser.FieldTgc: {Type: field.TypeString, Column: cpuser.FieldTgc},
cpuser.FieldMobileType: {Type: field.TypeEnum, Column: cpuser.FieldMobileType},
cpuser.FieldLoginType: {Type: field.TypeEnum, Column: cpuser.FieldLoginType},
},
@@ -1381,6 +1383,11 @@ func (f *CpTaskFilter) WhereNextExecuteTime(p entql.Int64P) {
f.Where(p.Field(cptask.FieldNextExecuteTime))
}
// WhereExecuteDays applies the entql json.RawMessage predicate on the execute_days field.
func (f *CpTaskFilter) WhereExecuteDays(p entql.BytesP) {
f.Where(p.Field(cptask.FieldExecuteDays))
}
// WhereHasCpUser applies a predicate to check if query has an edge cp_user.
func (f *CpTaskFilter) WhereHasCpUser() {
f.Where(entql.HasEdge("cp_user"))
@@ -1630,6 +1637,11 @@ func (f *CpUserFilter) WhereCookie(p entql.StringP) {
f.Where(p.Field(cpuser.FieldCookie))
}
// WhereTgc applies the entql string predicate on the tgc field.
func (f *CpUserFilter) WhereTgc(p entql.StringP) {
f.Where(p.Field(cpuser.FieldTgc))
}
// WhereMobileType applies the entql string predicate on the mobile_type field.
func (f *CpUserFilter) WhereMobileType(p entql.StringP) {
f.Where(p.Field(cpuser.FieldMobileType))
File diff suppressed because one or more lines are too long
+8 -6
View File
@@ -77,7 +77,7 @@ var (
{Name: "leave_id", Type: field.TypeString},
{Name: "title", Type: field.TypeString},
{Name: "status", Type: field.TypeEnum, Enums: []string{"done", "fail"}},
{Name: "type", Type: field.TypeEnum, Enums: []string{"report"}},
{Name: "type", Type: field.TypeEnum, Enums: []string{"report", "apply"}},
{Name: "desc", Type: field.TypeString, Nullable: true, Size: 2147483647},
{Name: "cp_user_id", Type: field.TypeInt64},
{Name: "user_id", Type: field.TypeInt64},
@@ -134,6 +134,7 @@ var (
{Name: "execute_hour", Type: field.TypeInt, Nullable: true},
{Name: "execute_minute", Type: field.TypeInt, Nullable: true},
{Name: "next_execute_time", Type: field.TypeInt64, Nullable: true},
{Name: "execute_days", Type: field.TypeJSON, Nullable: true},
{Name: "cp_user_id", Type: field.TypeInt64},
{Name: "user_id", Type: field.TypeInt64},
}
@@ -145,13 +146,13 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "cp_tasks_cp_users_cptasks",
Columns: []*schema.Column{CpTasksColumns[15]},
Columns: []*schema.Column{CpTasksColumns[16]},
RefColumns: []*schema.Column{CpUsersColumns[0]},
OnDelete: schema.Cascade,
},
{
Symbol: "cp_tasks_sys_user_cp_task",
Columns: []*schema.Column{CpTasksColumns[16]},
Columns: []*schema.Column{CpTasksColumns[17]},
RefColumns: []*schema.Column{SysUserColumns[0]},
OnDelete: schema.Cascade,
},
@@ -205,7 +206,8 @@ var (
{Name: "email", Type: field.TypeString, Nullable: true},
{Name: "mobile_code", Type: field.TypeString, Nullable: true},
{Name: "cookie", Type: field.TypeString, Nullable: true, Size: 2147483647},
{Name: "mobile_type", Type: field.TypeEnum, Enums: []string{"random", "authorize"}},
{Name: "tgc", Type: field.TypeString, Nullable: true, Size: 2147483647},
{Name: "mobile_type", Type: field.TypeEnum, Enums: []string{"random", "authorize", "manual"}},
{Name: "login_type", Type: field.TypeEnum, Enums: []string{"account", "phone"}, Default: "account"},
{Name: "school_id", Type: field.TypeString},
{Name: "user_id", Type: field.TypeInt64},
@@ -218,13 +220,13 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "cp_users_cp_schools_cpusers",
Columns: []*schema.Column{CpUsersColumns[10]},
Columns: []*schema.Column{CpUsersColumns[11]},
RefColumns: []*schema.Column{CpSchoolsColumns[0]},
OnDelete: schema.NoAction,
},
{
Symbol: "cp_users_sys_user_cpuser",
Columns: []*schema.Column{CpUsersColumns[11]},
Columns: []*schema.Column{CpUsersColumns[12]},
RefColumns: []*schema.Column{SysUserColumns[0]},
OnDelete: schema.Cascade,
},
+165 -2
View File
@@ -3766,6 +3766,8 @@ type CpTaskMutation struct {
addexecute_minute *int
next_execute_time *int64
addnext_execute_time *int64
execute_days *[]int
appendexecute_days []int
clearedFields map[string]struct{}
cp_user *int64
clearedcp_user bool
@@ -4621,6 +4623,71 @@ func (m *CpTaskMutation) ResetNextExecuteTime() {
delete(m.clearedFields, cptask.FieldNextExecuteTime)
}
// SetExecuteDays sets the "execute_days" field.
func (m *CpTaskMutation) SetExecuteDays(i []int) {
m.execute_days = &i
m.appendexecute_days = nil
}
// ExecuteDays returns the value of the "execute_days" field in the mutation.
func (m *CpTaskMutation) ExecuteDays() (r []int, exists bool) {
v := m.execute_days
if v == nil {
return
}
return *v, true
}
// OldExecuteDays returns the old "execute_days" field's value of the CpTask entity.
// If the CpTask object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *CpTaskMutation) OldExecuteDays(ctx context.Context) (v []int, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldExecuteDays is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldExecuteDays requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldExecuteDays: %w", err)
}
return oldValue.ExecuteDays, nil
}
// AppendExecuteDays adds i to the "execute_days" field.
func (m *CpTaskMutation) AppendExecuteDays(i []int) {
m.appendexecute_days = append(m.appendexecute_days, i...)
}
// AppendedExecuteDays returns the list of values that were appended to the "execute_days" field in this mutation.
func (m *CpTaskMutation) AppendedExecuteDays() ([]int, bool) {
if len(m.appendexecute_days) == 0 {
return nil, false
}
return m.appendexecute_days, true
}
// ClearExecuteDays clears the value of the "execute_days" field.
func (m *CpTaskMutation) ClearExecuteDays() {
m.execute_days = nil
m.appendexecute_days = nil
m.clearedFields[cptask.FieldExecuteDays] = struct{}{}
}
// ExecuteDaysCleared returns if the "execute_days" field was cleared in this mutation.
func (m *CpTaskMutation) ExecuteDaysCleared() bool {
_, ok := m.clearedFields[cptask.FieldExecuteDays]
return ok
}
// ResetExecuteDays resets all changes to the "execute_days" field.
func (m *CpTaskMutation) ResetExecuteDays() {
m.execute_days = nil
m.appendexecute_days = nil
delete(m.clearedFields, cptask.FieldExecuteDays)
}
// ClearCpUser clears the "cp_user" edge to the CpUser entity.
func (m *CpTaskMutation) ClearCpUser() {
m.clearedcp_user = true
@@ -4776,7 +4843,7 @@ func (m *CpTaskMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *CpTaskMutation) Fields() []string {
fields := make([]string, 0, 16)
fields := make([]string, 0, 17)
if m.create_time != nil {
fields = append(fields, cptask.FieldCreateTime)
}
@@ -4825,6 +4892,9 @@ func (m *CpTaskMutation) Fields() []string {
if m.next_execute_time != nil {
fields = append(fields, cptask.FieldNextExecuteTime)
}
if m.execute_days != nil {
fields = append(fields, cptask.FieldExecuteDays)
}
return fields
}
@@ -4865,6 +4935,8 @@ func (m *CpTaskMutation) Field(name string) (ent.Value, bool) {
return m.ExecuteMinute()
case cptask.FieldNextExecuteTime:
return m.NextExecuteTime()
case cptask.FieldExecuteDays:
return m.ExecuteDays()
}
return nil, false
}
@@ -4906,6 +4978,8 @@ func (m *CpTaskMutation) OldField(ctx context.Context, name string) (ent.Value,
return m.OldExecuteMinute(ctx)
case cptask.FieldNextExecuteTime:
return m.OldNextExecuteTime(ctx)
case cptask.FieldExecuteDays:
return m.OldExecuteDays(ctx)
}
return nil, fmt.Errorf("unknown CpTask field %s", name)
}
@@ -5027,6 +5101,13 @@ func (m *CpTaskMutation) SetField(name string, value ent.Value) error {
}
m.SetNextExecuteTime(v)
return nil
case cptask.FieldExecuteDays:
v, ok := value.([]int)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetExecuteDays(v)
return nil
}
return fmt.Errorf("unknown CpTask field %s", name)
}
@@ -5135,6 +5216,9 @@ func (m *CpTaskMutation) ClearedFields() []string {
if m.FieldCleared(cptask.FieldNextExecuteTime) {
fields = append(fields, cptask.FieldNextExecuteTime)
}
if m.FieldCleared(cptask.FieldExecuteDays) {
fields = append(fields, cptask.FieldExecuteDays)
}
return fields
}
@@ -5164,6 +5248,9 @@ func (m *CpTaskMutation) ClearField(name string) error {
case cptask.FieldNextExecuteTime:
m.ClearNextExecuteTime()
return nil
case cptask.FieldExecuteDays:
m.ClearExecuteDays()
return nil
}
return fmt.Errorf("unknown CpTask nullable field %s", name)
}
@@ -5220,6 +5307,9 @@ func (m *CpTaskMutation) ResetField(name string) error {
case cptask.FieldNextExecuteTime:
m.ResetNextExecuteTime()
return nil
case cptask.FieldExecuteDays:
m.ResetExecuteDays()
return nil
}
return fmt.Errorf("unknown CpTask field %s", name)
}
@@ -6251,6 +6341,7 @@ type CpUserMutation struct {
email *string
mobile_code *string
cookie *string
tgc *string
mobile_type *cpuser.MobileType
login_type *cpuser.LoginType
clearedFields map[string]struct{}
@@ -6746,6 +6837,55 @@ func (m *CpUserMutation) ResetCookie() {
delete(m.clearedFields, cpuser.FieldCookie)
}
// SetTgc sets the "tgc" field.
func (m *CpUserMutation) SetTgc(s string) {
m.tgc = &s
}
// Tgc returns the value of the "tgc" field in the mutation.
func (m *CpUserMutation) Tgc() (r string, exists bool) {
v := m.tgc
if v == nil {
return
}
return *v, true
}
// OldTgc returns the old "tgc" field's value of the CpUser entity.
// If the CpUser object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *CpUserMutation) OldTgc(ctx context.Context) (v string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldTgc is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldTgc requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldTgc: %w", err)
}
return oldValue.Tgc, nil
}
// ClearTgc clears the value of the "tgc" field.
func (m *CpUserMutation) ClearTgc() {
m.tgc = nil
m.clearedFields[cpuser.FieldTgc] = struct{}{}
}
// TgcCleared returns if the "tgc" field was cleared in this mutation.
func (m *CpUserMutation) TgcCleared() bool {
_, ok := m.clearedFields[cpuser.FieldTgc]
return ok
}
// ResetTgc resets all changes to the "tgc" field.
func (m *CpUserMutation) ResetTgc() {
m.tgc = nil
delete(m.clearedFields, cpuser.FieldTgc)
}
// SetMobileType sets the "mobile_type" field.
func (m *CpUserMutation) SetMobileType(ct cpuser.MobileType) {
m.mobile_type = &ct
@@ -7081,7 +7221,7 @@ func (m *CpUserMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *CpUserMutation) Fields() []string {
fields := make([]string, 0, 11)
fields := make([]string, 0, 12)
if m.create_time != nil {
fields = append(fields, cpuser.FieldCreateTime)
}
@@ -7109,6 +7249,9 @@ func (m *CpUserMutation) Fields() []string {
if m.cookie != nil {
fields = append(fields, cpuser.FieldCookie)
}
if m.tgc != nil {
fields = append(fields, cpuser.FieldTgc)
}
if m.mobile_type != nil {
fields = append(fields, cpuser.FieldMobileType)
}
@@ -7141,6 +7284,8 @@ func (m *CpUserMutation) Field(name string) (ent.Value, bool) {
return m.MobileCode()
case cpuser.FieldCookie:
return m.Cookie()
case cpuser.FieldTgc:
return m.Tgc()
case cpuser.FieldMobileType:
return m.MobileType()
case cpuser.FieldLoginType:
@@ -7172,6 +7317,8 @@ func (m *CpUserMutation) OldField(ctx context.Context, name string) (ent.Value,
return m.OldMobileCode(ctx)
case cpuser.FieldCookie:
return m.OldCookie(ctx)
case cpuser.FieldTgc:
return m.OldTgc(ctx)
case cpuser.FieldMobileType:
return m.OldMobileType(ctx)
case cpuser.FieldLoginType:
@@ -7248,6 +7395,13 @@ func (m *CpUserMutation) SetField(name string, value ent.Value) error {
}
m.SetCookie(v)
return nil
case cpuser.FieldTgc:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetTgc(v)
return nil
case cpuser.FieldMobileType:
v, ok := value.(cpuser.MobileType)
if !ok {
@@ -7307,6 +7461,9 @@ func (m *CpUserMutation) ClearedFields() []string {
if m.FieldCleared(cpuser.FieldCookie) {
fields = append(fields, cpuser.FieldCookie)
}
if m.FieldCleared(cpuser.FieldTgc) {
fields = append(fields, cpuser.FieldTgc)
}
return fields
}
@@ -7333,6 +7490,9 @@ func (m *CpUserMutation) ClearField(name string) error {
case cpuser.FieldCookie:
m.ClearCookie()
return nil
case cpuser.FieldTgc:
m.ClearTgc()
return nil
}
return fmt.Errorf("unknown CpUser nullable field %s", name)
}
@@ -7368,6 +7528,9 @@ func (m *CpUserMutation) ResetField(name string) error {
case cpuser.FieldCookie:
m.ResetCookie()
return nil
case cpuser.FieldTgc:
m.ResetTgc()
return nil
case cpuser.FieldMobileType:
m.ResetMobileType()
return nil
+1 -1
View File
@@ -21,7 +21,7 @@ func (CpLeave) Fields() []ent.Field {
field.String("leave_id").NotEmpty(),
field.String("title").NotEmpty(),
field.Enum("status").Values("done", "fail"),
field.Enum("type").Values("report"),
field.Enum("type").Values("report", "apply"),
field.Text("desc").Optional(),
}
}
+1
View File
@@ -31,6 +31,7 @@ func (CpTask) Fields() []ent.Field {
field.Int("execute_hour").Optional().Nillable(),
field.Int("execute_minute").Optional().Nillable(),
field.Int64("next_execute_time").Optional().Nillable(),
field.JSON("execute_days", []int{}).Optional().Comment("执行星期 1-7,1=周一,7=周日"),
}
}
+2 -1
View File
@@ -24,7 +24,8 @@ func (CpUser) Fields() []ent.Field {
field.String("email").Optional(),
field.String("mobile_code").Optional(),
field.Text("cookie").Optional(),
field.Enum("mobile_type").Values("random", "authorize"),
field.Text("tgc").Optional(),
field.Enum("mobile_type").Values("random", "authorize", "manual"),
field.Enum("login_type").Values("account", "phone").Default("account"),
}
}
+80
View File
@@ -0,0 +1,80 @@
package logger
import (
"os"
"path/filepath"
"strings"
"github.com/shirou/gopsutil/v3/process"
)
func IsDebug() bool {
return isGolandDebug() || isDebugByPath()
}
func isDebugByPath() bool {
exePath := strings.ToLower(os.Args[0])
// 获取绝对路径
absPath, err := filepath.Abs(exePath)
if err != nil {
absPath = exePath
}
// 检查路径中是否包含常见的临时目录或"go-build"字样
tmpDir := strings.ToLower(getTempDir())
isTemp := strings.Contains(absPath, tmpDir) || strings.Contains(absPath, "go-build") || strings.Contains(absPath, "go_build_")
return isTemp
}
func getTempDir() string {
tempDir := os.TempDir()
// 尝试转换为长路径
if longPath, err := filepath.EvalSymlinks(tempDir); err == nil {
if absPath, err := filepath.Abs(longPath); err == nil {
return absPath
}
return longPath
}
return filepath.Dir(tempDir)
}
func isGolandDebug() bool {
// 获取当前进程ID
pid := os.Getpid()
// 获取当前进程信息
currentProcess, err := process.NewProcess(int32(pid))
if err != nil {
return false
}
// 获取父进程
parent, err := currentProcess.Parent()
if err != nil {
return false
}
// 获取父进程名称
name, err := parent.Name()
if err != nil {
return false
}
// 检查父进程是否是dlv(Go调试器)或GoLand相关进程
name = strings.ToLower(name)
debugProcessNames := []string{
"dlv.exe", "dlv",
"goland.exe", "goland64.exe",
"goland", "idea.exe",
}
for _, debugName := range debugProcessNames {
if strings.Contains(name, debugName) {
return true
}
}
return false
}
+5
View File
@@ -13,6 +13,11 @@ var Logger *zap.Logger
func init() {
// Create a production config and customize the time encoder
config := zap.NewProductionConfig()
if IsDebug() {
config.Level = zap.NewAtomicLevelAt(zap.DebugLevel) // Enable debug logs
} else {
config.Level = zap.NewAtomicLevelAt(zap.InfoLevel)
}
config.EncoderConfig.EncodeTime = BeijingTimeEncoder
// Build the logger with the custom config
+16 -5
View File
@@ -1,13 +1,24 @@
package email
type SendPayload struct {
To string
Subject string
Body string
FromName string
FromEmail string
To string
Subject string
Body string
AuthMode string
FromName string
FromEmail string
SMTPHost string
SMTPPort int
SMTPUsername string
SMTPPassword string
OAuthTenant string
OAuthClientID string
OAuthClientSecret string
OAuthRedirectURL string
OAuthRefreshToken string
OAuthScope string
}
+36 -10
View File
@@ -4,10 +4,12 @@ import (
"bytes"
"context"
"encoding/gob"
"likedream-goframe/internal/application/cpdaily/imageuploader"
"likedream-goframe/internal/application/cpdaily/login"
"likedream-goframe/internal/application/cpdaily/school"
task2 "likedream-goframe/internal/application/cpdaily/task"
"likedream-goframe/internal/application/cpdaily/task/formtype"
"likedream-goframe/internal/application/cpdaily/types"
"likedream-goframe/internal/common/db"
errors2 "likedream-goframe/internal/common/errors"
"likedream-goframe/internal/ent"
@@ -22,6 +24,7 @@ import (
"time"
"github.com/bytedance/sonic"
"github.com/google/uuid"
"github.com/hibiken/asynq"
"github.com/samber/oops"
"go.uber.org/zap"
@@ -43,15 +46,18 @@ func (c *CpdailyTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task)
// 先注册错误处理,确保任何阶段的错误都能被捕获并重置状态
var targetTask *ent.CpTask
defer utils.HandleError(func(err error) {
// 使用独立的 context,避免 asynq 超时 cancel ctx 后数据库操作失败导致状态无法更新
bgCtx := context.Background()
// 如果 targetTask 还没查询到,只能重置状态为 not_start,让任务重新入队
if targetTask == nil {
db.Global.CpTask.UpdateOneID(taskPayload.CpTaskID).SetTaskStatus(cptask.TaskStatusNotStart).Exec(ctx)
db.Global.CpTask.UpdateOneID(taskPayload.CpTaskID).SetTaskStatus(cptask.TaskStatusNotStart).Exec(bgCtx)
panic(err)
}
// 执行失败应该更新下次执行时间
executeTime := utils.GetCpdailyNextExecuteTime(targetTask)
e := db.Global.CpTask.UpdateOneID(taskPayload.CpTaskID).SetNextExecuteTime(executeTime).SetTaskStatus(cptask.TaskStatusFail).Exec(ctx)
e := db.Global.CpTask.UpdateOneID(taskPayload.CpTaskID).SetNextExecuteTime(executeTime).SetTaskStatus(cptask.TaskStatusFail).Exec(bgCtx)
utils.LogError(ctx, "设置任务状态失败", oops.With("task_id", taskPayload.CpTaskID).Wrap(e))
var errorMessage string
@@ -97,15 +103,19 @@ func (c *CpdailyTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task)
// 查询学校详情
schoolDetail := school.GetSchoolDetail(ctx, targetTask.Edges.CpUser.SchoolID)
cookie := targetTask.Edges.CpUser.Cookie
restyClient := login.GetClient(ctx, cookie)
if !login.CookieEnabled(ctx, schoolDetail.WecloudUrl, restyClient, targetTask.Edges.CpUser.LoginType) {
loginExecutor := login.GetCpdailyLoginBySchoolInfo(ctx, &schoolDetail)
loginExecutor.Login(ctx, &schoolDetail, targetTask.Edges.CpUser.Username, targetTask.Edges.CpUser.Password)
cookie = loginExecutor.GetCookieString()
db.Global.CpUser.UpdateOneID(targetTask.Edges.CpUser.ID).SetCookie(cookie).ExecX(ctx)
restyClient = login.GetClient(ctx, cookie)
refreshResult := login.EnsureCookieValid(ctx, &login.RefreshCookieParams{
ID: targetTask.Edges.CpUser.ID,
Cookie: targetTask.Edges.CpUser.Cookie,
Tgc: targetTask.Edges.CpUser.Tgc,
LoginType: targetTask.Edges.CpUser.LoginType,
Username: targetTask.Edges.CpUser.Username,
Password: targetTask.Edges.CpUser.Password,
SchoolID: targetTask.Edges.CpUser.SchoolID,
}, &schoolDetail)
if refreshResult.Cookie != targetTask.Edges.CpUser.Cookie {
db.Global.CpUser.UpdateOneID(targetTask.Edges.CpUser.ID).SetCookie(refreshResult.Cookie).SetTgc(refreshResult.Tgc).ExecX(ctx)
}
restyClient := refreshResult.Client
// 获取任务执行handler
taskExecutor := task2.GetCpdailyTaskService(ctx, targetTask.TaskType, restyClient, schoolDetail)
@@ -142,6 +152,22 @@ func (c *CpdailyTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task)
deviceID := targetTask.Edges.CpUser.MobileCode
// 固定获取图片id
imgId := GetNextImgId(customFormItems, targetTask.UsedImg)
// 兼容旧数据:旧数据存的是 S3 的 UUID,新数据存的是今日校园 previewUrl
if imgId != "" && !strings.Contains(imgId, "/") && !strings.Contains(imgId, ":") {
// 不含 / 和 : 说明不是 URL,可能是旧的 UUID,尝试从 S3 上传到今日校园
if parsedUUID, uuidErr := uuid.Parse(imgId); uuidErr == nil {
var imgTaskType types.TaskType
switch targetTask.TaskType {
case cptask.TaskTypeSign:
imgTaskType = types.TaskTypeSign
case cptask.TaskTypeSleepCheck:
imgTaskType = types.TaskTypeSleepCheck
case cptask.TaskTypeCollection:
imgTaskType = types.TaskTypeCollection
}
imgId = imageuploader.UploadImageFromStorage(ctx, schoolDetail.WecloudUrl, parsedUUID, imgTaskType, restyClient)
}
}
submitForm := taskExecutor.FillCustomForm(ctx, cpdailyRealTask, customFormItems, deviceID, imgId)
taskExecutor.Submit(ctx, submitForm, targetTask.Edges.CpUser.Username, deviceID)
+19 -36
View File
@@ -4,58 +4,41 @@ import (
"bytes"
"context"
"encoding/gob"
"likedream-goframe/internal/framework/logger"
"likedream-goframe/internal/framework/model/email"
payloadmodel "likedream-goframe/internal/framework/model/email"
mailservice "likedream-goframe/internal/service/email"
"likedream-goframe/internal/utils"
"time"
"github.com/hibiken/asynq"
"github.com/samber/oops"
"github.com/wneessen/go-mail"
"go.uber.org/zap"
)
type EmailTaskHandler struct {
}
type EmailTaskHandler struct{}
func (h *EmailTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task) (err error) {
defer utils.Pass(&err)
var payload email.SendPayload
e := gob.NewDecoder(bytes.NewReader(task.Payload())).Decode(&payload)
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
logger.Logger.Info("开始处理邮件发送", zap.String("邮箱", payload.To))
var payload payloadmodel.SendPayload
if e := gob.NewDecoder(bytes.NewReader(task.Payload())).Decode(&payload); e != nil {
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
}
logger.Logger.Info("start sending email", zap.String("to", payload.To))
defer func() {
if r := recover(); r != nil {
// 打印日志
logger.Logger.Error("邮件发送失败", zap.String("邮箱", payload.To), zap.Error(r.(error)))
// 重新抛出相同的 panic
logger.Logger.Error("send email failed", zap.String("to", payload.To), zap.Error(r.(error)))
panic(r)
}
}()
message := mail.NewMsg()
svc := mailservice.NewV1()
cfg := mailservice.ConfigFromPayload(payload)
if e := svc.SendMessageNow(ctx, cfg, payload.To, payload.Subject, payload.Body); e != nil {
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
}
e = message.FromFormat(payload.FromName, payload.FromEmail)
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
e = message.AddTo(payload.To)
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
message.Subject(payload.Subject)
message.SetBodyString(mail.TypeTextHTML, payload.Body)
client, e := mail.NewClient(payload.SMTPHost, mail.WithPort(payload.SMTPPort),
mail.WithSMTPAuth(mail.SMTPAuthLogin), // Outlook 使用 LOGIN 认证
mail.WithTLSPortPolicy(mail.TLSMandatory),
mail.WithTimeout(30*time.Second), // 连接超时 30 秒
mail.WithUsername(payload.SMTPUsername), mail.WithPassword(payload.SMTPPassword))
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
defer utils.LogError(ctx, "关闭邮件客户端失败", client.Close())
e = client.DialAndSendWithContext(ctx, message)
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
logger.Logger.Info("邮件发送完成", zap.String("邮箱", payload.To))
return
logger.Logger.Info("email sent", zap.String("to", payload.To))
return nil
}
+2 -1
View File
@@ -61,11 +61,12 @@ func cleanupStuckTasks(ctx context.Context) {
g.Log().Infof(ctx, "已清理 %d 个 archived 状态的 asynq 任务", len(archivedTasks))
}
// 重置数据库中卡住的任务状态
// 重置数据库中卡住的任务状态(queue 和 doing 都需要重置)
affected, err := db.Global.CpTask.Update().
Where(
cptask.Or(
cptask.TaskStatusEQ(cptask.TaskStatusQueue),
cptask.TaskStatusEQ(cptask.TaskStatusDoing),
),
).
SetTaskStatus(cptask.TaskStatusNotStart).
+9 -1
View File
@@ -36,7 +36,6 @@ type periodicTaskConfigProvider struct{}
func (p *periodicTaskConfigProvider) GetConfigs() ([]*asynq.PeriodicTaskConfig, error) {
return []*asynq.PeriodicTaskConfig{
{
//Cronspec: "0 1 * * *", // 每天凌晨1点执行
Cronspec: "0 1 * * * ",
Task: asynq.NewTask(cst.TaskResetDaily, nil),
Opts: []asynq.Option{
@@ -45,5 +44,14 @@ func (p *periodicTaskConfigProvider) GetConfigs() ([]*asynq.PeriodicTaskConfig,
asynq.Timeout(0), // 使用默认超时
},
},
{
Cronspec: "0 14 * * *",
Task: asynq.NewTask(cst.TaskRefreshSchoolDetail, nil),
Opts: []asynq.Option{
asynq.Queue(cst.TaskQueueDefault),
asynq.MaxRetry(1),
asynq.Timeout(0),
},
},
}, nil
}
+17 -2
View File
@@ -112,12 +112,27 @@ func (ts *TaskScanner) scanCpdailyTask() {
func(selector *sql.Selector) {
selector.Where(sql.ExprP("success_count < frequency"))
}).
Select(cptask.FieldID, cptask.FieldExecuteHour, cptask.FieldExecuteMinute, cptask.FieldNextExecuteTime).
Select(cptask.FieldID, cptask.FieldExecuteHour, cptask.FieldExecuteMinute, cptask.FieldNextExecuteTime, cptask.FieldExecuteDays).
AllX(ts.ctx)
var failedIDS []int64
// 当前星期几(ISO: 周一=1, 周日=7)
todayWeekday := int(currentTime.Weekday())
if todayWeekday == 0 {
todayWeekday = 7
}
for _, task := range allTasks {
// 检查今天是否在执行日内
if len(task.ExecuteDays) > 0 && !slices.Contains(task.ExecuteDays, todayWeekday) {
// 今天不执行,重置到下一个执行日
nextExecuteAt := utils.GetCpdailyNextExecuteTime(task)
db.Global.CpTask.UpdateOneID(task.ID).Where(cptask.NextExecuteTimeNotNil()).SetNextExecuteTime(nextExecuteAt).Exec(ts.ctx)
failedIDS = append(failedIDS, task.ID)
continue
}
// 30分钟之前的任务不再执行,但是要重置下次执行时间
if *task.NextExecuteTime < minExecuteTime {
originExecuteTime := time.Unix(*task.NextExecuteTime, 0).In(cst2.TimeZoneShangHai).Format("2006-01-02 15:04:05")
@@ -276,7 +291,7 @@ func (ts *TaskScanner) scanXuexitongTask() {
utils.LogError(ts.ctx, "扫描学习通任务时,入列失败", oops.With("course_id", course.ID).Wrap(e))
}
} else {
g.Log().Debugf(ts.ctx, "学习通课程 %s (ID: %d) 已加入队列: %s", course.CourseName, course.ID, queueName)
//g.Log().Debugf(ts.ctx, "学习通课程 %s (ID: %d) 已加入队列: %s", course.CourseName, course.ID, queueName)
}
}
}
@@ -0,0 +1,30 @@
package schedule
import (
"context"
"likedream-goframe/internal/application/cpdaily/school"
"likedream-goframe/internal/framework/logger"
"likedream-goframe/internal/utils"
"github.com/hibiken/asynq"
"go.uber.org/zap"
)
type RefreshSchoolDetailTaskHandler struct{}
// ProcessTask 每天中午刷新一次全部学校详情缓存
func (h *RefreshSchoolDetailTaskHandler) ProcessTask(ctx context.Context, _ *asynq.Task) (err error) {
defer utils.Pass(&err)
logger.Logger.Info("开始批量刷新学校详情缓存信息")
schools := school.GetSchoolList(ctx)
for _, item := range schools {
func() {
defer utils.HandleError(func(err error) {
logger.Logger.Warn("刷新学校详情缓存失败", zap.String("school_id", item.Id), zap.Error(err))
})
school.GetSchoolDetail(ctx, item.Id)
}()
}
return
}
+1
View File
@@ -68,6 +68,7 @@ func initServer(ctx context.Context) {
auxMux := asynq.NewServeMux()
auxMux.Handle(cst2.TaskEmail, &EmailTaskHandler{})
auxMux.Handle(cst2.TaskResetDaily, &ResetTaskHandler{})
auxMux.Handle(cst2.TaskRefreshSchoolDetail, &RefreshSchoolDetailTaskHandler{})
e := globalServer.Start(mainMux)
utils.OkOrPanic(e)

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