Compare commits

...

30 Commits

Author SHA1 Message Date
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
sugarBecomer 16692177d4 🐛 学习通列表 2025-12-10 15:10:39 +08:00
sugarBecomer 5f81a7ba5e 🐛 学习通刷课修复 2025-12-09 23:36:13 +08:00
sugarBecomer 58fb282fe8 🐛 学习通优化 2025-12-09 22:06:07 +08:00
sugarBecomer f64b4b5498 ✨ 配置单价 2025-12-09 16:19:45 +08:00
sugarBecomer 84620989ad ✨ 四川教师刷课 2025-12-09 13:59:12 +08:00
sugarBecomer af7fcc4cb4 🐛 销假 2025-12-08 17:54:05 +08:00
sugarBecomer c206a0863e ✨ 学习通优化 & 注册开关 2025-12-08 14:11:49 +08:00
sugarBecomer b068e178da 🐛 邮件只重试一次 2025-12-08 10:47:57 +08:00
sugarBecomer 02737ef743 🐛 销假 2025-12-08 10:44:29 +08:00
sugarBecomer 74a1134cf1 🐛 邮件调整 2025-12-08 10:03:21 +08:00
sugarBecomer b6f777f997 🐛 调整任务执行 2025-12-08 09:55:50 +08:00
sugarBecomer 2dc233631e 🐛 调整任务执行 2025-12-08 09:55:27 +08:00
sugarBecomer 0c60eb5ae4 🐛 销假 2025-12-07 22:37:28 +08:00
sugarBecomer 450c026a6f 🐛 销假 2025-12-06 21:58:51 +08:00
sugarBecomer d9d6acee5f 🐛 销假 2025-12-06 15:12:43 +08:00
sugarBecomer 164d8956de 🐛 销假 & 学习通 2025-12-06 15:10:47 +08:00
sugarBecomer 3f1a08374e 🐛 日志格式 & 邮箱错误处理 2025-12-06 12:53:52 +08:00
sugarBecomer 334d597f9a 🐛 邮箱发送失败 2025-12-06 12:34:12 +08:00
sugarBecomer 98eb15f4e4 ✨ 手机号登录 2025-12-06 02:16:20 +08:00
sugarBecomer 3a2e679b85 ✨ 学习通 & 销假 2025-12-05 22:09:47 +08:00
sugarBecomer 921192a914 ✅ 查寝获取近期任务信息 2025-12-05 10:08:43 +08:00
sugarBecomer 107b472cf7 🐛 限制注册 2025-12-04 22:53:47 +08:00
103 changed files with 7783 additions and 2948 deletions
+1 -1
View File
@@ -2,4 +2,4 @@ ent-new:
cd internal;go run -mod=mod entgo.io/ent/cmd/ent new $(Name)
ent-gen:
go run . g
go run main.go g
+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)
+7 -5
View File
@@ -29,10 +29,10 @@ url = "redis://:jhkdjhkjdhsIUTYURTU_pEGmFP@database.out:56379"
smtpHost = "smtp-mail.outlook.com"
smtpPort = 587
# 发件人邮箱和密码(需要使用授权码,不是登录密码)
smtpUsername = "demo@likedream.xyz"
smtpUsername = "reply@likedream.xyz"
smtpPassword = "baca122..."
# 发件人邮箱和显示名称
fromEmail = "demo@likedream.xyz"
fromEmail = "reply@likedream.xyz"
fromName = "若梦系统"
@@ -43,7 +43,7 @@ baseUrl = "http://s3.out"
bucket = "likedream"
[cpdaily]
appVersion = "9.7.2"
appVersion = "9.8.8"
systemVersion = "iOS 17"
calVersion = "firstv"
sliderIds = ["1018764144934965", "sdhyxy", "hqdx"]
@@ -51,6 +51,8 @@ sliderIds = ["1018764144934965", "sdhyxy", "hqdx"]
[basePoint]
cpdaily = "0.5"
cpdailyTask = "0.5"
cpdailyLeave = "4"
cpdailyPhone = "0.25"
[task]
concurrency = 20
concurrency = 80
+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 | 服务器内部错误 |
-403
View File
@@ -1,403 +0,0 @@
# 刷课接口文档
## 接口列表
### 1. 获取刷课列表
**接口地址**: `GET /course`
**请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| type | string | 否 | 类型,可选值:`xxt`(学习通) |
| username | string | 否 | 账号(模糊搜索) |
| status | string | 否 | 状态,可选值:`not_start`(未开始)、`waiting`(等待中)、`doing`(进行中)、`done`(已完成)、`fail`(失败)、`pause`(暂停) |
| page | int | 否 | 页码,默认 1 |
| size | int | 否 | 每页数量,默认 10 |
**返回示例**:
```json
{
"code": 200,
"message": "success",
"data": [
{
"id": 1,
"user_id": 1,
"username": "学习通账号",
"password": "",
"type": "xxt",
"course_name": "大学英语",
"course_id": "123456",
"need": "task",
"status": "waiting",
"task_count": 10,
"success_count": 5,
"exam_state": "not_start",
"violence": "no",
"desc": "备注信息",
"create_time": "2024-01-01T00:00:00Z",
"update_time": "2024-01-01T00:00:00Z"
}
],
"total": 100
}
```
**字段说明**:
- `type`: 类型 - `xxt`(学习通)
- `need`: 需要刷的内容 - `task`(任务点)、`exam`(考试)、`all`(全部)
- `status`: 状态 - `not_start`(未开始)、`waiting`(等待中)、`doing`(进行中)、`done`(已完成)、`fail`(失败)、`pause`(暂停)
- `exam_state`: 考试状态 - `not_start`(未开始)、`waiting`(等待中)、`doing`(进行中)、`done`(已完成)、`fail`(失败)
- `violence`: 是否暴力刷课 - `no`(否)、`yes`(是)
---
### 2. 查询课程列表
**接口地址**: `POST /course/query`
**说明**: 通过学习通账号密码查询当前可选的课程列表,用于在添加刷课前选择课程。
**请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| username | string | 是 | 学习通账号 |
| password | string | 是 | 学习通密码 |
**请求示例**:
```json
{
"username": "学习通账号",
"password": "学习通密码"
}
```
**返回示例**:
```json
{
"code": 200,
"message": "success",
"data": [
{
"cpi": 123456,
"key": "234567",
"courseId": "345678",
"chatId": "456789",
"courseTeacher": "张老师",
"courseName": "大学英语",
"courseImage": "https://example.com/image.jpg",
"courseImageBase64": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAA...",
"courseDataId": 567890,
"ContentID": 678901,
"isstart": true,
"state": 0
}
]
}
```
**字段说明**:
- `cpi`: 用户唯一标识
- `key`: 课程班级ID(classId)
- `courseId`: 课程ID(**添加刷课时需要使用**)
- `chatId`: 聊天ID
- `courseTeacher`: 授课教师
- `courseName`: 课程名称(**添加刷课时需要使用**)
- `courseImage`: 课程封面图片URL
- `courseImageBase64`: 课程封面图片Base64编码(包含Data URL前缀,可直接用于img标签的src属性)
- `courseDataId`: 课程数据ID
- `ContentID`: 内容ID
- `isstart`: 是否已开课(true:已开课,false:未开课)
- `state`: 课程状态(0:正常,1:已结束)
**注意事项**:
- `courseImageBase64` 字段已经包含了 Data URL 前缀(如 `data:image/jpeg;base64,`),可以直接设置到 `<img>` 标签的 `src` 属性
- 如果图片下载失败,`courseImageBase64` 字段将为空字符串,此时可以使用 `courseImage` URL 作为备用
**错误示例**:
```json
{
"code": 400,
"message": "登录失败,请检查账号密码",
"data": null
}
```
---
### 3. 添加刷课
**接口地址**: `POST /course`
**请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| username | string | 是 | 学习通账号 |
| password | string | 是 | 学习通密码 |
| type | string | 是 | 类型,目前只支持 `xxt`(学习通) |
| course_name | string | 是 | 课程名称 |
| course_id | string | 是 | 课程ID |
| need | string | 是 | 需要刷的内容,可选值:`task`(任务点)、`exam`(考试)、`all`(全部) |
| violence | string | 否 | 是否暴力刷课,可选值:`no`(否,默认)、`yes`(是) |
| desc | string | 否 | 备注信息 |
**请求示例**:
```json
{
"username": "学习通账号",
"password": "学习通密码",
"type": "xxt",
"course_name": "大学英语",
"course_id": "123456",
"need": "task",
"violence": "no",
"desc": "备注信息"
}
```
**返回示例**:
```json
{
"code": 200,
"message": "添加成功",
"data": null
}
```
**错误示例**:
```json
{
"code": 400,
"message": "该课程已存在刷课任务",
"data": null
}
```
---
### 4. 修改刷课密码
**接口地址**: `PUT /course/password/{id}`
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | int64 | 是 | 刷课任务ID |
**请求参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| password | string | 是 | 新密码 |
**请求示例**:
```json
{
"password": "新密码"
}
```
**返回示例**:
```json
{
"code": 200,
"message": "密码修改成功",
"data": null
}
```
**错误示例**:
```json
{
"code": 400,
"message": "课程不存在或无权限操作",
"data": null
}
```
---
### 5. 重刷课程
**接口地址**: `POST /course/restart/{id}`
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | int64 | 是 | 刷课任务ID |
**说明**: 只有状态为 `done`(已完成)的课程才能重刷,重刷后会重置课程状态、任务数量、成功数量和考试状态。
**返回示例**:
```json
{
"code": 200,
"message": "重刷成功",
"data": null
}
```
**错误示例**:
```json
{
"code": 400,
"message": "只有已完成的课程才能重刷",
"data": null
}
```
---
### 6. 暂停/恢复刷课
**接口地址**: `POST /course/pause/{id}`
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | int64 | 是 | 刷课任务ID |
**说明**: 该接口会在暂停和恢复之间切换:
- 如果当前状态是 `pause`(暂停),则恢复为 `waiting`(等待中)
- 如果当前状态不是暂停,则设置为 `pause`(暂停)
**返回示例(暂停)**:
```json
{
"code": 200,
"message": "已暂停刷课",
"data": null
}
```
**返回示例(恢复)**:
```json
{
"code": 200,
"message": "已恢复刷课",
"data": null
}
```
---
### 7. 删除刷课
**接口地址**: `DELETE /course/{id}`
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | int64 | 是 | 刷课任务ID |
**返回示例**:
```json
{
"code": 200,
"message": "删除成功",
"data": null
}
```
**错误示例**:
```json
{
"code": 400,
"message": "课程不存在或无权限操作",
"data": null
}
```
---
## 枚举值说明
### 类型 (type)
| 值 | 说明 |
|-----|------|
| xxt | 学习通 |
### 需要刷的内容 (need)
| 值 | 说明 |
|-----|------|
| task | 任务点 |
| exam | 考试 |
| all | 全部 |
### 状态 (status)
| 值 | 说明 |
|-----|------|
| not_start | 未开始 |
| waiting | 等待中 |
| doing | 进行中 |
| done | 已完成 |
| fail | 失败 |
| pause | 暂停 |
### 考试状态 (exam_state)
| 值 | 说明 |
|-----|------|
| not_start | 未开始 |
| waiting | 等待中 |
| doing | 进行中 |
| done | 已完成 |
| fail | 失败 |
### 是否暴力刷课 (violence)
| 值 | 说明 |
|-----|------|
| no | 否 |
| yes | 是 |
---
## 通用说明
### 认证
所有接口都需要用户认证,需要在请求头中携带认证信息(具体认证方式根据项目配置)。
### 权限
用户只能操作自己创建的刷课任务,无法访问或修改其他用户的数据。
### 错误码
| 错误码 | 说明 |
|--------|------|
| 200 | 成功 |
| 400 | 业务错误(如参数错误、权限不足等) |
| 401 | 未认证 |
| 500 | 服务器错误 |
+204
View File
@@ -0,0 +1,204 @@
# 四川智慧教育刷课 API 文档
## 基础信息
- 基础路径: `/v1/course`
- 需要认证: 是 (Bearer Token)
- Content-Type: `application/json`
---
## 1. 查询课程列表
登录四川智慧教育平台,获取用户的课程列表。
**请求**
```
POST /v1/course/smartedu-sc/query
```
**请求参数**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| username | string | 是 | 登录账号 |
| password | string | 是 | 登录密码 |
**请求示例**
```json
{
"username": "13800138000",
"password": "your_password"
}
```
**响应示例**
```json
{
"code": 200,
"msg": "success",
"data": [
{
"id": "course_id_1",
"courseName": "课程名称1",
"image": "https://xxx.com/image.jpg"
},
{
"id": "course_id_2",
"courseName": "课程名称2",
"image": "https://xxx.com/image2.jpg"
}
]
}
```
---
## 2. 添加刷课任务
添加一个新的刷课任务,提交后立即开始秒刷。
**请求**
```
POST /v1/course/smartedu-sc
```
**请求参数**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| username | string | 是 | 登录账号 |
| password | string | 是 | 登录密码 |
| course_name | string | 是 | 课程名称 |
| course_id | string | 是 | 课程ID(从查询接口获取) |
| desc | string | 否 | 备注说明 |
**请求示例**
```json
{
"username": "13800138000",
"password": "your_password",
"course_name": "教师培训课程",
"course_id": "abc123",
"desc": "2024年培训"
}
```
**响应示例**
```json
{
"code": 200,
"msg": "任务已提交,正在刷课中"
}
```
---
## 3. 获取刷课任务列表
获取当前用户的四川智慧教育刷课任务列表。
**请求**
```
GET /v1/course/smartedu-sc
```
**请求参数**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| username | string | 否 | 按账号筛选 |
| status | string | 否 | 按状态筛选: not_start/waiting/doing/done/fail/pause |
| page | int | 否 | 页码,默认1 |
| size | int | 否 | 每页数量,默认10 |
**响应示例**
```json
{
"code": 200,
"msg": "success",
"data": [
{
"id": 1,
"createTime": "2024-12-09 10:00:00",
"updateTime": "2024-12-09 10:05:00",
"username": "13800138000",
"type": "smartedu_sc",
"course_name": "教师培训课程",
"course_id": "abc123",
"need": "task",
"status": "done",
"task_count": 20,
"success_count": 20,
"violence": "yes",
"desc": "2024年培训"
}
],
"total": 1
}
```
**状态说明**
| 状态值 | 说明 |
|--------|------|
| not_start | 未开始 |
| waiting | 等待中 |
| doing | 进行中 |
| done | 已完成 |
| fail | 失败 |
| pause | 已暂停 |
---
## 4. 删除刷课任务
删除指定的刷课任务。
**请求**
```
DELETE /v1/course/smartedu-sc/{id}
```
**路径参数**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | int64 | 是 | 任务ID |
**响应示例**
```json
{
"code": 200,
"msg": "删除成功"
}
```
---
## 错误码说明
| 错误码 | 说明 |
|--------|------|
| 200 | 成功 |
| 400 | 请求参数错误 |
| 401 | 未授权 |
| 403 | 无权限 |
| 500 | 服务器内部错误 |
## 使用流程
1. 调用 **查询课程列表** 接口,获取用户在四川智慧教育平台的课程
2. 选择需要刷的课程,调用 **添加刷课任务** 接口
3. 任务提交后自动开始秒刷,可通过 **获取刷课任务列表** 查看进度
4. 刷课完成后状态变为 `done`
+1 -1
View File
@@ -31,6 +31,7 @@ require (
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.45.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
resty.dev/v3 v3.0.0-beta.3
@@ -106,7 +107,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
+3
View File
@@ -44,6 +44,7 @@ func StartServer(ctx context.Context) {
systemGroup.Bind(menu.NewV1())
systemGroup.Bind(user.NewUserCtrlV1())
systemGroup.Bind(config.NewConfigCtrlV1())
systemGroup.Bind(config.NewAIConfigCtrlV1())
//systemGroup.Bind(agent.NewV1())
// 用户信息路由
userGroup := group.Group("/user").Middleware(middleware.AuthHandler, middleware.PermissionHandler)
@@ -59,12 +60,14 @@ func StartServer(ctx context.Context) {
cpdailyGroup.Bind(cpdaily.NewCpTaskCtrlV1())
cpdailyGroup.Bind(cpdaily.NewCpImageCtrlV1())
cpdailyGroup.Bind(cpdaily.NewCpTaskLogCtrlV1())
cpdailyGroup.Bind(cpdaily.NewCpLeaveCtrlV1())
// 娱乐
musicGroup := group.Group("/music").Middleware(middleware.AuthHandler, middleware.PermissionHandler)
musicGroup.Bind(music.NewMusicCtrlV1())
// 网课
courseGroup := group.Group("/course").Middleware(middleware.AuthHandler, middleware.PermissionHandler)
courseGroup.Bind(course.NewXxtCtrlV1())
courseGroup.Bind(course.NewSmartEduSCCtrlV1())
})
s.SetPort(port.Int())
+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"`
}
+1 -1
View File
@@ -24,6 +24,6 @@ type ConfigListReq struct {
}
type ConfigPublicGetReq struct {
g.Meta `path:"/config/public/{key}" method:"get" tags:"Config" summary:"获取公开配置(无需认证)"`
g.Meta `path:"/config/public/{key}" method:"get" tags:"Config" summary:"获取公开配置(无需认证)" auth:"false"`
Key string `json:"key" v:"required#配置键不能为空"`
}
+15 -4
View File
@@ -2,6 +2,7 @@ package v1
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
type CpLeaveListReq struct {
@@ -19,8 +20,18 @@ type GetLeaveListFromSystemReq struct {
}
type CpLeaveReportReq struct {
g.Meta `path:"/leave/report" method:"post" tags:"CpLeave" summary:"执行销假"`
CpUserId int64 `json:"cpUserId" v:"required#今日校园账号ID不能为空"`
LeaveId string `json:"leaveId" v:"required#请假记录ID不能为空"`
Title string `json:"title" v:"required#请假记录标题不能为空"`
g.Meta `path:"/leave/report" method:"post" tags:"CpLeave" summary:"执行销假"`
CpUserId int64 `json:"cpUserId" v:"required#今日校园账号ID不能为空"`
LeaveId string `json:"leaveId" v:"required#请假记录ID不能为空"`
Title string `json:"title" v:"required#请假记录标题不能为空"`
Longitude string `json:"longitude"` // 经度(可选,不传则使用系统默认)
Latitude string `json:"latitude"` // 纬度(可选,不传则使用系统默认)
Address string `json:"address"` // 地址(可选,不传则使用系统默认)
ImageUrl string `json:"imageUrl"`
}
type CpLeaveUploadReq struct {
g.Meta `path:"/leave/upload" method:"post" tags:"CpLeave" summary:"上传销假图片"`
File *ghttp.UploadFile `json:"file"`
CpUserId int64 `json:"cpUserId"`
}
+3
View File
@@ -30,10 +30,13 @@ type LoginGetCode struct {
type AddByCode struct {
g.Meta `path:"/user/addByCode" method:"post" tags:"CpUser" summary:"手机号登录"`
Id int64 `json:"id"`
CaptchaKey string `json:"captchaKey" v:"required#验证码不能不能为空"`
Dots []captcha.ConfirmData `json:"dots" v:"required#验证码不能为空"`
Mobile string `json:"mobile" v:"required#手机号不能为空"`
SchoolId string `json:"school_id"`
Code string `json:"code"`
Email string `json:"email" v:"email|regex:^.*@qq\\.com$#邮箱格式不正确|必须是QQ邮箱"`
}
type CpUserConfirmAddReq struct {
+29
View File
@@ -0,0 +1,29 @@
package v1
import (
"github.com/gogf/gf/v2/frame/g"
)
// PriceConfigGetReq 获取所有费率配置
type PriceConfigGetReq struct {
g.Meta `path:"/config/price" method:"get" tags:"PriceConfig" summary:"获取所有费率配置"`
}
// PriceConfigUpdateReq 更新费率配置
type PriceConfigUpdateReq struct {
g.Meta `path:"/config/price" method:"put" tags:"PriceConfig" summary:"更新费率配置" permission:"sys:config:edit"`
CpdailyTask string `json:"cpdaily_task"` // 今日校园签到单价
CpdailyLeave string `json:"cpdaily_leave"` // 今日校园请假单价
CpdailyPhone string `json:"cpdaily_phone"` // 今日校园手机号单价
Xxt string `json:"xxt"` // 学习通单价
SmarteduSc string `json:"smartedu_sc"` // 四川智慧教育单价
}
// PriceConfigResp 费率配置响应
type PriceConfigResp struct {
CpdailyTask string `json:"cpdaily_task"`
CpdailyLeave string `json:"cpdaily_leave"`
CpdailyPhone string `json:"cpdaily_phone"`
Xxt string `json:"xxt"`
SmarteduSc string `json:"smartedu_sc"`
}
+37
View File
@@ -0,0 +1,37 @@
package v1
import (
"github.com/gogf/gf/v2/frame/g"
)
// SmartEduSCQueryCourseReq 查询四川智慧教育课程列表
type SmartEduSCQueryCourseReq struct {
g.Meta `path:"/smartedu-sc/query" method:"post" tags:"SmartEduSC" summary:"查询四川智慧教育课程列表"`
Username string `json:"username" v:"required#账号不能为空"`
Password string `json:"password" v:"required#密码不能为空"`
}
// SmartEduSCAddCourseReq 添加四川智慧教育刷课任务
type SmartEduSCAddCourseReq struct {
g.Meta `path:"/smartedu-sc" method:"post" tags:"SmartEduSC" summary:"添加四川智慧教育刷课任务"`
Username string `json:"username" v:"required#账号不能为空"`
Password string `json:"password" v:"required#密码不能为空"`
CourseName string `json:"course_name" v:"required#课程名称不能为空"`
CourseID string `json:"course_id" v:"required#课程ID不能为空"`
Desc string `json:"desc"`
}
// SmartEduSCListReq 获取四川智慧教育刷课列表
type SmartEduSCListReq struct {
g.Meta `path:"/smartedu-sc" method:"get" tags:"SmartEduSC" summary:"获取四川智慧教育刷课列表"`
Username string `json:"username"`
Status string `json:"status"`
Page int `json:"page" d:"1"`
Size int `json:"size" d:"10"`
}
// SmartEduSCDeleteReq 删除四川智慧教育刷课任务
type SmartEduSCDeleteReq struct {
g.Meta `path:"/smartedu-sc/{id}" method:"delete" tags:"SmartEduSC" summary:"删除四川智慧教育刷课任务"`
ID int64 `json:"id"`
}
@@ -7,9 +7,9 @@ import (
)
func TestAesDecrypt(t *testing.T) {
str := "6XkC1UAk07fK0uTaGPUu77i/+r7j/o1JQ/XygRxee2LMiX5H+w/BOqOB6e7W o9kHDZMb1pqZNN43k1tjl0ephbPLEi8g9C1EqBw0suzxX74j3dUdCf6dd/0W 8KvntNXwju96Vy5B2oD2ZosLmUdB1m0xX5rxAdLJ//cTft87XDWoLM0fuasu fGvR4XXXYZN071x8F03+qqR52fD1UQGgn0uGkSa/nrUho32Sg7DNJzF/JBCk 6/z0lu0XmIPo1SDIVjOtYAmyUDMZGl/ZvSAu2w=="
str := "gTIjjNBTwN0Vdv2o5ZlTZ6gHbFcIA0MV0caRqvv/50tptfoSO+VASLxpQZWy Xa86lY21QtmXhwlvTsfd3XR819O0IUi2FynPOM6s0pDsSndM2JhhqoRNXBTK 4WqfMgt0RCQJ38KP8XtM5O+Ktkj1YHHM/OVEGqOxRcUh2M++JJCheJbSs+aQ 2T3va13/GWJH7PEd8JVEJgBiO3wXLOb9+CvvWuU/Frc9nNNxQL7LrgZTdQAv C9Yt+5/a2n41reOeOs7Bu5C4FFlHQSs71Rom/HtIhL67x1ecIre1rbgK9xxz 1AhTEHY6nhdZmktPGJD5CfxvLVWIeLv09CjKqdSaNodkP4e9YTScNEZ2s8TK y8Xhv8L0r6gllC9B3nla4GRe+lqMvhDPDZx7PFu7kxFspf9yoM4eOpgWfCr9 xbDwxO9cXphiktfWsQiX28W4g2jCFFEazh01WBtpjM9LsLfbBgZA+z3jgfYa AXZuOvFQMJuUnLyOIGPtsaSTwXFDlJwNRgEw+Ctl22JSJbbu1yOoM9S6Ge27 rUVMhbAS5snVbNbLhgAymQLQQVLRwfsMLbtUfLTZuCC8zO8d16eOYw56+DqR QWNG1EXQz3CLeFDs2hFqcNWrAt773qANQ3zLPA65"
str = strings.ReplaceAll(str, " ", "")
decodeString, _ := base64.StdEncoding.DecodeString(str)
decrypt := DesDecrypt(decodeString, Cat)
decrypt := AesDecryptForCat(decodeString)
t.Log(string(decrypt))
}
@@ -12,6 +12,7 @@ import (
"likedream-goframe/internal/utils"
"github.com/bytedance/sonic"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/google/uuid"
"github.com/samber/oops"
"resty.dev/v3"
@@ -80,11 +81,54 @@ func UploadImageFromStorage(ctx context.Context, host string, imageId uuid.UUID,
return previewUrl
}
func UploadImageFromFile(ctx context.Context, host string, file *ghttp.UploadFile, taskType types.TaskType, client *resty.Client) string {
imageUploadPolicy := GetUploadPolicyAsync(ctx, host, taskType, client)
open, _ := file.Open()
resp, e := client.R().
SetMultipartFields(&resty.MultipartField{
Name: "x-obs-acl",
Values: []string{"public-read"},
}, &resty.MultipartField{
Name: "AccessKeyId",
Values: []string{imageUploadPolicy.AccessId},
}, &resty.MultipartField{
Name: "key",
Values: []string{imageUploadPolicy.FileName},
}, &resty.MultipartField{
Name: "signature",
Values: []string{imageUploadPolicy.Signature},
}, &resty.MultipartField{
Name: "policy",
Values: []string{imageUploadPolicy.Policy},
}, &resty.MultipartField{
Name: "content-type",
Values: []string{file.Header.Get("Content-Type")},
}, &resty.MultipartField{
Name: "file",
FileName: "file",
Reader: open,
}).
Post(imageUploadPolicy.Host)
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
if resp.StatusCode() > 299 {
utils.OkOrPanic(errors.NewBusinessError(503, "上传图片失败: "+utils.ReadString(resp.Body)), oops.WithContext(ctx).With("response", utils.ReadString(resp.Body)).Wrap)
}
previewUrl := GetPreviewUrlAsync(ctx, host, imageUploadPolicy.FileName, taskType, client)
return previewUrl
}
// GetPreviewUrlAsync 用于获取图片预览地址
func GetPreviewUrlAsync(ctx context.Context, host string, fileKey string, taskType types.TaskType, client *resty.Client) string {
var apiPath string
if taskType == types.TaskTypeSleepCheck {
apiPath = "/previewAttachment"
} else if taskType == types.TaskTypeLeave {
apiPath = "/obs/previewAttachment"
} else {
apiPath = "/sign/previewAttachment"
}
@@ -164,6 +208,8 @@ func GetImageUploadUrlPrefix(ctx context.Context, taskType types.TaskType) strin
return "/wec-counselor-sign-apps/stu"
case types.TaskTypeCollection:
return "/wec-counselor-collector-apps/student/collector"
case types.TaskTypeLeave:
return "/wec-counselor-leave-apps/leave"
default:
utils.OkOrPanic(errors.NewBusinessError(503, "未知的任务类型"), oops.WithContext(ctx).Wrap)
panic("unreachable")
@@ -0,0 +1,6 @@
package model
type Attach struct {
Type int `json:"type"`
Url string `json:"url"`
}
+53 -15
View File
@@ -3,14 +3,19 @@ package leave
import (
"context"
"fmt"
v1 "likedream-goframe/internal/api/v1"
"likedream-goframe/internal/application/cpdaily"
"likedream-goframe/internal/application/cpdaily/cryptoutil"
"likedream-goframe/internal/application/cpdaily/imageuploader"
"likedream-goframe/internal/application/cpdaily/leave/model"
"likedream-goframe/internal/application/cpdaily/task/leavetype"
"likedream-goframe/internal/application/cpdaily/taskhelper"
"likedream-goframe/internal/application/cpdaily/types"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/utils"
"github.com/gogf/gf/v2/container/gvar"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/google/uuid"
"github.com/samber/oops"
"github.com/tidwall/gjson"
@@ -62,13 +67,29 @@ func (c *CpdailyReportApp) GetLeaveList() []leavetype.LeaveListOne {
var listList []leavetype.LeaveListOne
for _, row := range rows.Array() {
leaveOne := leavetype.LeaveListOne{
Id: row.Get("id").String(),
LeaveType: row.Get("leaveType").String(),
CreateTime: row.Get("createTime").String(),
Reason: row.Get("reason").String(),
AceEndTime: row.Get("aceEndTime").String(),
// 已完成
if row.Get("status").String() == "4" {
continue
}
id := row.Get("id").String()
detail := c.LeaveDetail(id)
leaveOne := leavetype.LeaveListOne{
Id: row.Get("id").String(),
LeaveType: row.Get("leaveType").String(),
CreateTime: row.Get("createTime").String(),
Reason: row.Get("leaveReason").String(),
AceEndTime: row.Get("actEndTime").String(),
Status: row.Get("status").String(),
ChooseAttendance: detail.Get("leaveType.chooseAttendance").Int(),
//Longitude: detail.Get("detail.applyLocation.longitude").String(),
//Latitude: detail.Get("detail.applyLocation.latitude").String(),
//Address: detail.Get("detail.applyLocation.address").String(),
Longitude: detail.Get("leaveExtends.0.longitude").String(),
Latitude: detail.Get("leaveExtends.0.latitude").String(),
Address: detail.Get("leaveExtends.0.address").String(),
}
listList = append(listList, leaveOne)
}
return listList
@@ -99,16 +120,33 @@ func (c *CpdailyReportApp) LeaveDetail(leaveId string) gjson.Result {
return datas
}
func (c *CpdailyReportApp) FillReportForm(detail gjson.Result) map[string]*gvar.Var {
func (c *CpdailyReportApp) UploadImage(ctx context.Context, file *ghttp.UploadFile) string {
return imageuploader.UploadImageFromFile(ctx, c.host, file, types.TaskTypeLeave, c.client)
}
func (c *CpdailyReportApp) FillReportForm(detail gjson.Result, req *v1.CpLeaveReportReq) map[string]*gvar.Var {
resultForm := make(map[string]*gvar.Var)
resultForm["id"] = gvar.New(detail.Get("detail.id").String())
resultForm["needApproval"] = gvar.New(1)
resultForm["leaveType"] = gvar.New(detail.Get("leaveType.code").Int())
resultForm["reportLocation"] = gvar.New(detail.Get("detail.applyLocation.address").String())
resultForm["leaveType"] = gvar.New(detail.Get("leaveType.code").String())
if req.Address == "" {
resultForm["reportLocation"] = gvar.New(detail.Get("leaveExtends.0.address").String())
} else {
resultForm["reportLocation"] = gvar.New(req.Address)
}
resultForm["approverWid"] = gvar.New("")
resultForm["reportApplyExplain"] = gvar.New(nil)
resultForm["reportAttach"] = gvar.New(detail.Get("detail.applyAttach").Array())
resultForm["uaIsCpadaily"] = gvar.New(true)
if req.ImageUrl != "" {
var attach []model.Attach
attach = append(attach, model.Attach{
Type: 1,
Url: req.ImageUrl,
})
resultForm["reportAttach"] = gvar.New(attach)
} else {
resultForm["reportAttach"] = gvar.New([]string{})
}
return resultForm
}
@@ -136,11 +174,11 @@ func (c *CpdailyReportApp) SubmitReport(ctx context.Context, form map[string]*gv
bodyString := taskhelper.GetBodyString(ctx, form)
// 构造提交数据
submitData := map[string]*gvar.Var{
"lon": gvar.New(lon),
"version": gvar.New(cryptoutil.EncryptVersion),
"calVersion": gvar.New(cpdailyInfo.CalVersion),
"deviceId": extensionForm["deviceId"],
//"userId": gvar.New(username),
"lon": gvar.New(lon),
"version": gvar.New(cryptoutil.EncryptVersion),
"calVersion": gvar.New(cpdailyInfo.CalVersion),
"deviceId": extensionForm["deviceId"],
"userId": gvar.New(username),
"systemName": gvar.New("android"),
"bodyString": gvar.New(bodyString),
"lat": gvar.New(lat),
File diff suppressed because one or more lines are too long
+7 -2
View File
@@ -6,6 +6,7 @@ import (
"likedream-goframe/internal/application/cookiejar"
"likedream-goframe/internal/application/httpclient"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/ent/cpuser"
"likedream-goframe/internal/utils"
"strings"
@@ -86,7 +87,7 @@ func ActivateSession(ctx context.Context, host string, client *resty.Client) {
client.SetRedirectPolicy(resty.NoRedirectPolicy())
}
func CookieEnabled(ctx context.Context, host string, client *resty.Client) bool {
func CookieEnabled(ctx context.Context, host string, client *resty.Client, loginType cpuser.LoginType) bool {
client.SetRedirectPolicy(resty.NoRedirectPolicy())
host = strings.TrimSuffix(host, "/")
sessionResponse, err := client.R().
@@ -95,7 +96,11 @@ func CookieEnabled(ctx context.Context, host string, client *resty.Client) bool
Post(fmt.Sprintf("%s/wec-counselor-attendance-apps/student/attendance/getStuAttendacesInOneDay", host))
utils.OkOrPanic(err)
return sessionResponse.StatusCode() != 302
needLogin := sessionResponse.StatusCode() == 302
if needLogin && loginType == cpuser.LoginTypePhone {
utils.OkOrPanic(errors.NewBusinessError(500, "登录信息失效,请重新通过短信验证码登录"))
}
return !needLogin
}
func GetClient(ctx context.Context, cookie string) *resty.Client {
+14 -9
View File
@@ -75,15 +75,15 @@ func NewSmsWithoutDeviceID() *SmsLoginHandler {
type SmsLoginHandler struct {
deviceID string
client *resty.Client
}
func (s *SmsLoginHandler) SendSms(ctx context.Context, phone string) *CpdailySmsSendSmsData {
jar := cookiejar.NewPersistCookieJar()
phoneEncrypted := base64.StdEncoding.EncodeToString(cryptoutil.AesEncrypt([]byte(phone)))
req := httpclient.
NewCpdailyClient().
s.client = httpclient.NewCpdailyClient()
req := s.client.
SetCookieJar(jar).
R().
SetContext(utils.NewCpdailySmsContextBuilder().
@@ -126,8 +126,9 @@ func (s *SmsLoginHandler) LoginByCode(ctx context.Context, phone, code string, j
"a": encodedPayload,
"b": cryptoutil.EncryptVersion,
}
req := httpclient.NewCpdailyClient().SetCookieJar(jar).R().
s.client = httpclient.NewCpdailyClient()
s.client.SetCookieJar(jar)
req := s.client.R().
SetBody(payload).
SetContext(utils.NewCpdailySmsContextBuilder().
WithDeviceID(s.deviceID).
@@ -198,10 +199,8 @@ func (s *SmsLoginHandler) SetLoginCookie(ctx context.Context, jar http.CookieJar
jar.SetCookies(targetSchoolUrl, []*http.Cookie{cookie})
jar.SetCookies(targetApiUrl, []*http.Cookie{cookie})
}
req := httpclient.NewCpdailyClient().
SetRedirectPolicy(resty.FlexibleRedirectPolicy(50)).
SetCookieJar(jar).R().
s.client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(50))
req := s.client.R().
SetHeader("AmpCookies", ampToken).
SetContext(ctx)
@@ -273,3 +272,9 @@ func setSmsRequestHeaders(ctx context.Context, request *resty.Request) {
"CpdailyInfo": encodedCpdailyInfo,
})
}
func (s *SmsLoginHandler) GetCookieString() string {
cookieJar := s.client.CookieJar()
serializableJar := cookieJar.(*cookiejar.PersistCookieJar)
return serializableJar.Serialize()
}
@@ -6,4 +6,10 @@ type LeaveListOne struct {
CreateTime string `json:"createTime"`
AceEndTime string `json:"aceEndTime"`
Reason string `json:"reason"`
Status string `json:"status"`
// 销假时填充的位置信息
Longitude string `json:"longitude"` // 经度
Latitude string `json:"latitude"` // 纬度
Address string `json:"address"` // 地址
ChooseAttendance int64 `json:"chooseAttendance"` // 是否需要图片 1需要 0 不需要
}
@@ -693,3 +693,18 @@ func (c *CpdailySleepCheckTask) Submit(ctx context.Context, form map[string]*gva
utils.OkOrPanic(errors.NewBusinessError(503, responseJSON.Get("message").String()), oops.WithContext(ctx).Wrap)
}
}
func (c *CpdailySleepCheckTask) GetYearAndMonth(ctx context.Context) (string, string) {
// Get current time in Shanghai timezone
timeNow := time.Now().In(cst.TimeZoneShangHai)
// Check if current day is within first 2 days of the month
if timeNow.Day() <= 2 {
// Return previous month
prevTime := timeNow.AddDate(0, -1, 0)
return strconv.Itoa(prevTime.Year()), strconv.Itoa(int(prevTime.Month()))
} else {
// Return current month
return strconv.Itoa(timeNow.Year()), strconv.Itoa(int(timeNow.Month()))
}
}
@@ -1,135 +0,0 @@
package task
import (
"context"
"likedream-goframe/internal/testutil"
"testing"
"github.com/joomcode/errorx"
)
func TestCpdailySleepCheckTask_QueryFormByTitle(t *testing.T) {
loginClient, schoolDetail := testutil.LoginTestUser()
task := NewSleepCheckTask(loginClient, schoolDetail.WecloudUrl)
tasks := task.QueryFormByTitle(context.Background(), "查寝")
if len(tasks) == 0 {
t.Error("未查询到查寝任务")
} else {
t.Logf("查询到查寝任务: %v", tasks)
}
}
func TestCpdailySleepCheckTask_GetRecentTaskTitle(t *testing.T) {
loginClient, schoolDetail := testutil.LoginTestUser()
task := NewSleepCheckTask(loginClient, schoolDetail.WecloudUrl)
titles := task.GetRecentTaskTitle(context.Background())
if len(titles) == 0 {
t.Error("未查询到查寝任务标题")
} else {
t.Logf("查询到查寝任务标题: %v", titles)
}
}
func TestCpdailySleepCheckTask_QueryFormByYearMonth(t *testing.T) {
loginClient, schoolDetail := testutil.LoginTestUser()
task := NewSleepCheckTask(loginClient, schoolDetail.WecloudUrl)
tests := []struct {
name string
titleGlob string
year string
month string
wantErr bool
}{
{
name: "查询当前月份查寝任务",
titleGlob: "查寝",
year: "2025",
month: "6",
wantErr: false,
},
{
name: "查询单数字月份查寝任务",
titleGlob: "查寝",
year: "2025",
month: "6", // 将会被格式化为 "06"
wantErr: false,
},
{
name: "使用通配符模式匹配",
titleGlob: "*查寝*",
year: "2025",
month: "6",
wantErr: false,
},
{
name: "查询不存在的任务",
titleGlob: "不存在的任务",
year: "2025",
month: "6",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
oopsError, _ := errorx.ErrorFromPanic(r)
if !tt.wantErr {
t.Errorf("QueryFormByYearMonth() 意外的panic: %+v", oopsError)
} else {
t.Logf("预期的错误: %+v", oopsError)
}
}
}()
form := task.QueryFormByYearMonth(context.Background(), tt.titleGlob, tt.year, tt.month)
if tt.wantErr {
t.Error("预期应该出现错误,但没有出现")
return
}
if form == nil {
t.Error("未查询到查寝任务表单")
return
}
// 验证返回结果的结构
if realTitle := form["realTitle"].String(); realTitle == "" {
t.Error("返回结果中缺少 realTitle 或 realTitle 为空")
} else {
t.Logf("任务真实标题: %s", realTitle)
}
if taskInfo := form["taskInfo"].Map(); taskInfo == nil {
t.Error("返回结果中缺少 taskInfo 或 taskInfo 为空")
} else {
t.Logf("任务信息包含字段: %d", len(taskInfo))
// 验证关键字段是否存在
requiredFields := []string{"taskName", "signWid", "signInstanceWid"}
for _, field := range requiredFields {
if _, exists := taskInfo[field]; !exists {
t.Errorf("taskInfo 中缺少必要字段: %s", field)
}
}
}
if rows := form["rows"].Map(); rows == nil {
t.Error("返回结果中缺少 rows 或 rows 为空")
} else {
t.Logf("表单数据包含字段: %d", len(rows))
}
t.Logf("查询到查寝任务表单,标题: %v", form["realTitle"])
})
}
}
@@ -0,0 +1,19 @@
package task
import (
"context"
"likedream-goframe/internal/testutil"
"testing"
"github.com/bytedance/sonic"
)
func TestGetHistoryForm(t *testing.T) {
ctx := context.Background()
loginClient, schoolDetail := testutil.LoginTestUser()
task := NewSleepCheckTask(loginClient, schoolDetail.WecloudUrl)
y, m := task.GetYearAndMonth(ctx)
month := task.QueryFormByYearMonth(ctx, "晚查寝", y, m)
marshal, _ := sonic.Marshal(month)
t.Log(string(marshal))
}
@@ -6,4 +6,5 @@ const (
TaskTypeCollection = TaskType(iota)
TaskTypeSign
TaskTypeSleepCheck
TaskTypeLeave
)
@@ -0,0 +1,49 @@
package httpclient
import (
"crypto/tls"
"likedream-goframe/internal/application/cookiejar"
"likedream-goframe/internal/utils"
"time"
"resty.dev/v3"
)
var SmartEdu = resty.New().
SetCookieJar(cookiejar.NewAllSavePersistCookieJar()).
SetRedirectPolicy(resty.FlexibleRedirectPolicy(20)).
SetTimeout(30 * time.Second).
SetTLSClientConfig(&tls.Config{
InsecureSkipVerify: true,
})
func init() {
if !utils.IsDebug() {
Xuexitong.SetDebug(false)
} else {
Xuexitong.SetProxy("http://127.0.0.1:9000")
}
}
// NewSmartEdu 创建一个全新的独立 client 实例,避免 Clone 可能的浅拷贝问题
// 每个实例都是完全独立的,适用于并发场景,不会共享状态
func NewSmartEdu() *resty.Client {
client := resty.New().
SetCookieJar(nil).
SetTimeout(30*time.Second).
SetDoNotParseResponse(false).
SetTLSClientConfig(&tls.Config{
InsecureSkipVerify: true,
}).
SetHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36")
// 根据调试模式设置代理和调试选项
if !utils.IsDebug() {
client.SetDebug(false)
} else {
client.SetProxy("http://127.0.0.1:9000")
}
jar := cookiejar.NewAllSavePersistCookieJar()
client.SetCookieJar(jar)
return client
}
@@ -0,0 +1,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"` // 填空题答案
}
@@ -0,0 +1,78 @@
package cryptoutil
import (
"crypto/aes"
"crypto/cipher"
"encoding/base64"
)
const (
scAESKey = "m1xp9slgfa|su@m2"
scAESIV = "pa!st17m@04h5u#0"
)
// EncryptSC AES-CBC加密,使用固定的key和iv
func EncryptSC(plaintext string) string {
key := []byte(scAESKey)
iv := []byte(scAESIV)
block, err := aes.NewCipher(key)
if err != nil {
return ""
}
// PKCS7填充
blockSize := block.BlockSize()
padding := blockSize - len(plaintext)%blockSize
padText := make([]byte, padding)
for i := range padText {
padText[i] = byte(padding)
}
plaintextBytes := append([]byte(plaintext), padText...)
// CBC加密
ciphertext := make([]byte, len(plaintextBytes))
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(ciphertext, plaintextBytes)
// Base64编码
return base64.StdEncoding.EncodeToString(ciphertext)
}
// DecryptSC AES-CBC解密,使用固定的key和iv
func DecryptSC(ciphertext string) string {
key := []byte(scAESKey)
iv := []byte(scAESIV)
// Base64解码
ciphertextBytes, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return ""
}
block, err := aes.NewCipher(key)
if err != nil {
return ""
}
// CBC解密
blockSize := block.BlockSize()
if len(ciphertextBytes)%blockSize != 0 {
return ""
}
plaintext := make([]byte, len(ciphertextBytes))
mode := cipher.NewCBCDecrypter(block, iv)
mode.CryptBlocks(plaintext, ciphertextBytes)
// 去除PKCS7填充
if len(plaintext) == 0 {
return ""
}
padding := int(plaintext[len(plaintext)-1])
if padding <= 0 || padding > blockSize || padding > len(plaintext) {
return ""
}
return string(plaintext[:len(plaintext)-padding])
}
@@ -0,0 +1,322 @@
package cryptoutil
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"math/big"
"net/url"
"strings"
)
// EncData 实现与JavaScript版本相同的加密功能
// clientToken参数用于替代JavaScript中的cookie值
func EncData(data interface{}, clientToken string) map[string]string {
// 检查数据是否为空
if data == nil || data == "" || data == "{}" || data == " " || data == "null" {
return map[string]string{
"ciphertext": "",
}
}
// 获取axes_max
axesMax := len(clientToken)
if axesMax < 4 {
axesMax = 4
}
// 创建axes
axes := creatAxes(axesMax)
// 替换逗号为连字符
aesaxes := strings.ReplaceAll(axes, ",", "-")
// 生成AES密钥
aesKey := encryptKey(axes, clientToken)
// 将数据转换为JSON字符串
var dataStr string
switch v := data.(type) {
case string:
dataStr = v
default:
jsonBytes, err := json.Marshal(data)
if err != nil {
return map[string]string{
"ciphertext": "",
}
}
dataStr = string(jsonBytes)
}
// 加密数据
encdata := Encrypt(dataStr, aesKey)
// 构造密文
ciphertext := encdata + "," + aesaxes
return map[string]string{
"ciphertext": ciphertext,
}
}
// DescData 实现与JavaScript版本相同的解密功能
// clientToken参数用于替代JavaScript中的cookie值
func DescData(JR map[string]interface{}, clientToken string) map[string]interface{} {
result := map[string]interface{}{
"flag": false,
"data": "",
}
if JR == nil {
return result
}
// 获取flag
flag, ok := JR["flag"].(bool)
if !ok {
flag = false
}
result["flag"] = flag
// 如果flag为false,直接返回data
if !flag {
if data, ok := JR["data"]; ok {
result["data"] = data
}
return result
}
// 检查key是否存在
key, keyExists := JR["key"].(string)
if !keyExists || key == "" {
if data, ok := JR["data"]; ok {
result["data"] = data
}
return result
}
// 处理解密
axes := key
aesaxes := strings.ReplaceAll(axes, "-", ",")
aesKey := encryptKey(aesaxes, clientToken)
// 获取加密数据
outdata, ok := JR["data"].(string)
if !ok {
result["data"] = ""
return result
}
// URL解码(与JS版本的decodeURIComponent一致)
decodedData, err := url.QueryUnescape(outdata)
if err != nil {
// 如果URL解码失败,使用原始数据
decodedData = outdata
}
// 解密数据
decryptedData := Decrypt(decodedData, aesKey)
result["data"] = decryptedData
return result
}
// encryptKey 根据axes生成加密密钥
func encryptKey(axes string, clientToken string) string {
aaxes := strings.Split(axes, ",")
key := ""
for _, axis := range aaxes {
axisInt := parseInt(axis)
// 确保axisInt不会导致越界
if axisInt >= 0 && axisInt < len(clientToken) {
// 与JavaScript版本一致:substring(aaxes[i], aaxes[i]*1+4)
end := axisInt + 4
if end <= len(clientToken) {
key += clientToken[axisInt:end]
} else {
// 如果end超出范围,只取到末尾
key += clientToken[axisInt:]
// 填充到4个字符
for len(key)%4 != 0 {
key += "A"
}
}
} else {
// 如果起始位置越界,使用默认值
key += "abcd"
}
}
// 确保密钥长度至少为16字节,如果超过则截断
if len(key) < 16 {
// 用'A'填充到16字节
for len(key) < 16 {
key += "A"
}
} else if len(key) > 16 {
// 截断到16字节
key = key[:16]
}
return key
}
// creatAxes 创建随机axes
func creatAxes(max int) string {
if max < 4 {
max = 4
}
axes := ""
for i := 0; i < 4; i++ {
// 生成0到max-4之间的随机数
n, err := rand.Int(rand.Reader, big.NewInt(int64(max-4)))
if err != nil {
// 出错时使用默认值
axes += "0,"
continue
}
axes += fmt.Sprintf("%d,", n.Int64())
}
// 移除末尾的逗号
if len(axes) > 0 {
axes = axes[:len(axes)-1]
}
return axes
}
// parseInt 简单的字符串转整数函数
func parseInt(s string) int {
var result int
fmt.Sscanf(s, "%d", &result)
return result
}
// Encrypt AES加密函数
func Encrypt(data, key string) string {
// 确保密钥长度为16字节
keyBytes := make([]byte, 16)
if len(key) >= 16 {
copy(keyBytes, []byte(key)[:16])
} else {
copy(keyBytes, []byte(key))
// 如果密钥不足16字节,用'A'填充
for i := len(key); i < 16; i++ {
keyBytes[i] = 'A'
}
}
// 创建AES密码块
block, err := aes.NewCipher(keyBytes)
if err != nil {
return ""
}
// PKCS7填充
blockSize := block.BlockSize()
padding := blockSize - len(data)%blockSize
padText := make([]byte, padding)
for i := range padText {
padText[i] = byte(padding)
}
plaintext := append([]byte(data), padText...)
// CBC模式加密
ciphertext := make([]byte, len(plaintext))
// 使用固定的IV(与JavaScript版本一致,使用相同的key作为IV)
iv := make([]byte, blockSize)
copy(iv, keyBytes[:blockSize])
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(ciphertext, plaintext)
// Base64编码(使用URL编码兼容CryptoJS)
return base64.StdEncoding.EncodeToString(ciphertext)
}
// Decrypt AES解密函数
func Decrypt(data, key string) string {
// Base64解码
ciphertext, err := base64.StdEncoding.DecodeString(data)
if err != nil {
// 尝试URL解码
ciphertext, err = base64.URLEncoding.DecodeString(data)
if err != nil {
return ""
}
}
// 确保密钥长度为16字节
keyBytes := make([]byte, 16)
if len(key) >= 16 {
copy(keyBytes, []byte(key)[:16])
} else {
copy(keyBytes, []byte(key))
// 如果密钥不足16字节,用'A'填充
for i := len(key); i < 16; i++ {
keyBytes[i] = 'A'
}
}
// 创建AES密码块
block, err := aes.NewCipher(keyBytes)
if err != nil {
return ""
}
// CBC模式解密
blockSize := block.BlockSize()
if len(ciphertext)%blockSize != 0 {
return ""
}
// 使用固定的IV(与JavaScript版本一致,使用相同的key作为IV)
iv := make([]byte, blockSize)
copy(iv, keyBytes[:blockSize])
mode := cipher.NewCBCDecrypter(block, iv)
plaintext := make([]byte, len(ciphertext))
mode.CryptBlocks(plaintext, ciphertext)
// 去除PKCS7填充
if len(plaintext) == 0 {
return ""
}
padding := int(plaintext[len(plaintext)-1])
// 更宽松的填充检查
if padding <= 0 || padding > blockSize || padding > len(plaintext) {
// 如果填充不合法,返回原始数据
return string(plaintext)
}
// 验证填充是否正确(更宽松的检查)
validPadding := true
for i := len(plaintext) - padding; i < len(plaintext); i++ {
if plaintext[i] != byte(padding) {
validPadding = false
break
}
}
if !validPadding {
// 填充验证失败,返回尽可能多的有效数据
return string(plaintext)
}
// 正常去除填充
if len(plaintext) <= padding {
return ""
}
return string(plaintext[:len(plaintext)-padding])
}
@@ -0,0 +1,162 @@
var SAFETOCKEN="Safe-Tocken";//for RSA key cookie
var encryptAxes = function(axes){
RSAUtils.setMaxDigits(1024);
var safeTocken = getCookie(SAFETOCKEN);
if(safeTocken=="" || safeTocken ==''){
throw new Error("Cannot read Cookie '"+SAFETOCKEN+"' values,System need run RSAData() again!");
return ;
}
var safe_tocken = safeTocken.split(",");
var pubexp = safe_tocken[1];
var priexp = safe_tocken[0];
var modulus = safe_tocken[2];
var key = RSAUtils.getKeyPair(safe_tocken[1], safe_tocken[0], safe_tocken[2]);
axes = axes.split("").reverse().join("");
return RSAUtils.encryptedString(key,axes);
}
var axesMax = function(){
var client_tocken = getCookie(CLIENTTOCKEN);
if(client_tocken=="" || client_tocken==''){
throw new Error("Cannot read Cookie '"+CLIENTTOCKEN+"' values,System need run RSAData() again!");
return ;
}
return client_tocken.length;
}
/**
* decrypt axes use ras key form http back
*/
var decryptAxes = function(rsakey){
RSAUtils.setMaxDigits(1024);
var safeTocken = getCookie(SAFETOCKEN);
if(safeTocken=="" || safeTocken ==''){
throw new Error("Cannot read Cookie '"+SAFETOCKEN+"' values,System need run RSAData() again!");
return ;
}
var safe_tocken = safeTocken.split(",");
var pubexp = safe_tocken[1];
var priexp = safe_tocken[0];
var modulus = safe_tocken[2];
var key = RSAUtils.getKeyPair(safe_tocken[1], safe_tocken[0], safe_tocken[2]);
return RSAUtils.decryptedString(key,rsakey);
}
var CLIENTTOCKEN = "Client-Tocken";
/**
* Get String key as axes
*/
var encryptKey = function(axes){
//1.get client-tocken
//2. realse this axes get this encryptKey
var key="";
var client_tocken = getCookie(CLIENTTOCKEN);
if(client_tocken=="" || client_tocken==''){
throw new Error("Cannot read Cookie '"+CLIENTTOCKEN+"' values,System need run RSAData() again!");
return ;
}
var aaxes = axes.split(",");
for(var i=0;i<aaxes.length;i++){
key += client_tocken.substring(aaxes[i],aaxes[i]*1+4);
}
return key;
}
var getCookie = function(name){
var arr,reg=new RegExp("(^| )"+name+"=([^;]*)(;|$)");
if(arr=document.cookie.match(reg))
return decodeURIComponent(arr[2]);
else
return "";
}
/**
* get a random axes
*/
var creatAxes = function(max){
var axes = "";
for(var i=0;i<4;i++ ){
axes += parseInt(Math.random()*(max-4),10)+",";
}
axes = axes.substring(0,axes.length-1);
return axes;
}
/**
* just encdata for Ajax
*/
var EncData = function(data){
if(data=="" || data =="{}" || data==" " || data==null || data=="null"){
ciphertext="";
}else{//存在数据
//获取aeskey
var axes_max = axesMax();
var axes = creatAxes(axes_max);
var reg=new RegExp(",","g");
var aesaxes = axes.replace(reg,'-');
var aesKey = encryptKey(axes);
data = JSON.stringify(data);
encdata = Encrypt(data,aesKey);
ciphertext = encdata+","+aesaxes;
}
return {'ciphertext':ciphertext};
}
/**
* get data from back http
*/
var DescData = function(JR){
var _data="";
if(JR){//data is not empty
var flag = JR.flag;
if(flag){//成功
if(JR.key=="" || JR.key==''){
_data = JR.data;
}else{//解密数据
var axes = JR.key;
var reg=new RegExp("-","g");
var aesaxes = axes.replace(reg,',');
var aesKey = encryptKey(aesaxes);
var outdata=decodeURIComponent(JR.data);
_data = Decrypt(outdata,aesKey);
}
}else{
_data = JR.data;
}
}
return {'flag':JR.flag,'data':_data};
}
/**
* post data use as AJAX
*/
var SubData = function(data,URL){
var result="";
var ciphertext = EncData(data);
$.ajax({
type: "POST",
dataType: "json",
cache : false,
async:false,
url: URL,//传入后台的地址/方法
data: ciphertext,//参数,这里是一个json语句
success: function (RS) {
result = DescData(RS);
},
error: function (err) {
throw new Error("Post data was Error!");
return ;
},
fail:function(){
throw new Error("Post data was fail,website need NetWork!");
return ;
}
});
return result;
}
var Encrypt = function(data,key){
var aeskey = CryptoJS.enc.Utf8.parse(key);
var aesiv = CryptoJS.enc.Utf8.parse(key);
var srcs = CryptoJS.enc.Utf8.parse(data);
return CryptoJS.AES.encrypt(srcs, aeskey, { iv: aesiv,mode:CryptoJS.mode.CBC}).toString();
}
function Decrypt(word,key){
var aeskey = CryptoJS.enc.Utf8.parse(key);
var aesiv = CryptoJS.enc.Utf8.parse(key);
var decrypt = CryptoJS.AES.decrypt(word, aeskey, { iv: aesiv,mode:CryptoJS.mode.CBC});
var decryptedStr = decrypt.toString(CryptoJS.enc.Utf8);
return decryptedStr.toString();
}
@@ -0,0 +1,228 @@
package cryptoutil
import (
"fmt"
"strings"
"testing"
)
func TestEncdata(t *testing.T) {
// 使用与JavaScript版本相同的clientToken
clientToken := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
data := map[string]interface{}{
"username": "admin",
"password": "123456",
}
encData := EncData(data, clientToken)
fmt.Println("EncData result:", encData)
// 创建一个模拟的JR对象来测试DescData
jr := map[string]interface{}{
"flag": true,
"key": getKeyFromCiphertext(encData["ciphertext"]),
"data": getDataFromCiphertext(encData["ciphertext"]),
}
descResult := DescData(jr, clientToken)
fmt.Println("DescData result:", descResult)
}
func TestDecdata(t *testing.T) {
// 使用与JavaScript版本相同的clientToken
clientToken := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
data := map[string]interface{}{
"flag": true,
"data": "YN4tHIF4S3OQgfhTICpFqR425z2sHwCEmlsGdVYt5m6ssyIagtqEvf0hEXdzhc0u",
"key": "120-54-61-117",
}
// 调试信息
fmt.Println("Input data:", data)
// 手动测试密钥生成
axes := "120-54-61-117"
aesaxes := strings.ReplaceAll(axes, "-", ",")
aesKey := encryptKey(aesaxes, clientToken)
fmt.Println("Generated key:", aesKey)
fmt.Println("Key length:", len(aesKey))
// 直接测试解密
encryptedData := "YN4tHIF4S3OQgfhTICpFqR425z2sHwCEmlsGdVYt5m6ssyIagtqEvf0hEXdzhc0u"
decrypted := Decrypt(encryptedData, aesKey)
fmt.Println("Direct decrypt result:", decrypted)
fmt.Printf("Direct decrypt result (bytes): %v\n", []byte(decrypted))
result := DescData(data, clientToken)
fmt.Println("TestDecdata result:", result)
// 检查结果
if result["flag"] != true {
t.Errorf("Expected flag to be true, got %v", result["flag"])
}
}
// getKeyFromCiphertext 从ciphertext中提取key部分
func getKeyFromCiphertext(ciphertext string) string {
parts := strings.Split(ciphertext, ",")
if len(parts) > 1 {
return parts[1]
}
return ""
}
// getDataFromCiphertext 从ciphertext中提取data部分
func getDataFromCiphertext(ciphertext string) string {
parts := strings.Split(ciphertext, ",")
if len(parts) > 0 {
return parts[0]
}
return ""
}
// 添加一个简单的加密/解密测试
func TestEncryptDecrypt(t *testing.T) {
originalData := `{"username":"admin","password":"123456"}`
key := "abcdabcdabcdabcd" // 16字节密钥
// 加密
encrypted := Encrypt(originalData, key)
fmt.Println("Encrypted:", encrypted)
// 解密
decrypted := Decrypt(encrypted, key)
fmt.Println("Decrypted:", decrypted)
if decrypted != originalData {
t.Errorf("Decrypt failed. Expected: %s, Got: %s", originalData, decrypted)
}
}
// 测试特定的axes生成密钥
func TestEncryptKey(t *testing.T) {
// 使用与JavaScript版本相同的clientToken
clientToken := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
// 测试提供的axes
axes := "120,54,61,117"
key := encryptKey(axes, clientToken)
fmt.Println("Test axes:", axes)
fmt.Println("Generated key:", key)
fmt.Println("Key length:", len(key))
// 验证每个部分
fmt.Println("Client token length:", len(clientToken))
// 分别计算每个部分
parts := strings.Split(axes, ",")
for i, part := range parts {
axisInt := parseInt(part)
fmt.Printf("Part %d: %s -> %d\n", i, part, axisInt)
if axisInt >= 0 && axisInt < len(clientToken) {
end := axisInt + 4
if end <= len(clientToken) {
fmt.Printf(" Extracted: %s\n", clientToken[axisInt:end])
} else {
fmt.Printf(" Extracted: %s (partial)\n", clientToken[axisInt:])
}
} else {
fmt.Printf(" Out of range, using default\n")
}
}
}
// 测试具体案例
func TestSpecificCase(t *testing.T) {
// 使用与JavaScript版本相同的clientToken
clientToken := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
// 使用你提供的具体数据
axes := "120,54,61,117"
key := encryptKey(axes, clientToken)
fmt.Println("=== Specific Case Test ===")
fmt.Printf("Axes: %s\n", axes)
fmt.Printf("Generated Key: %s\n", key)
fmt.Printf("Key Length: %d\n", len(key))
// 测试加密
testData := "test data"
encrypted := Encrypt(testData, key)
fmt.Printf("Encrypted: %s\n", encrypted)
// 测试解密
decrypted := Decrypt(encrypted, key)
fmt.Printf("Decrypted: %s\n", decrypted)
if testData != decrypted {
fmt.Println("WARNING: Encryption/Decryption mismatch!")
} else {
fmt.Println("SUCCESS: Encryption/Decryption works correctly!")
}
}
// 测试你提供的实际加密数据
func TestActualEncryptedData(t *testing.T) {
// 使用与JavaScript版本相同的clientToken
clientToken := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
// 你提供的实际加密数据
encryptedData := "YN4tHIF4S3OQgfhTICpFqR425z2sHwCEmlsGdVYt5m6ssyIagtqEvf0hEXdzhc0u"
// 根据axes "120,54,61,117" 生成密钥
axes := "120,54,61,117"
key := encryptKey(axes, clientToken)
fmt.Println("=== Actual Encrypted Data Test ===")
fmt.Printf("Encrypted Data: %s\n", encryptedData)
fmt.Printf("Key: %s\n", key)
// 尝试解密
decrypted := Decrypt(encryptedData, key)
fmt.Printf("Decrypted Result: '%s'\n", decrypted)
fmt.Printf("Decrypted Length: %d\n", len(decrypted))
fmt.Printf("Decrypted Bytes: %v\n", []byte(decrypted))
// 检查是否是有效的JSON
if len(decrypted) > 0 {
fmt.Println("Possible JSON data detected")
} else {
fmt.Println("No data returned from decryption")
}
}
// 最终验证测试
func TestFinalVerification(t *testing.T) {
// 使用与JavaScript版本相同的clientToken
clientToken := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
fmt.Println("=== Final Verification Test ===")
// 使用你提供的axes生成密钥
axes := "120,54,61,117"
key := encryptKey(axes, clientToken)
fmt.Printf("Generated key for axes '%s': '%s'\n", axes, key)
// 你提供的加密数据
encryptedData := "YN4tHIF4S3OQgfhTICpFqR425z2sHwCEmlsGdVYt5m6ssyIagtqEvf0hEXdzhc0u"
// 尝试解密
decrypted := Decrypt(encryptedData, key)
fmt.Printf("Decrypted result: '%s'\n", decrypted)
// 如果解密结果为空,尝试不同的方法
if len(decrypted) == 0 {
fmt.Println("Trying alternative decryption approaches...")
// 尝试不同的填充处理
fmt.Println("Testing raw decryption without padding removal...")
// 这里我们可以添加更多的调试信息
// 输出一些调试信息
fmt.Printf("Key bytes: %v\n", []byte(key))
fmt.Printf("Encrypted data length: %d\n", len(encryptedData))
}
}
@@ -0,0 +1,32 @@
package model
type Course struct {
Id string `json:"id"`
CourseName string `json:"courseName"`
Image string `json:"image"`
}
type Chapter struct {
Id string `json:"id"`
ChapterName string `json:"chapterName"`
SubSection []SubSection `json:"studySubsections"`
}
type SubSection struct {
Id string `json:"id"`
SubsectionName string `json:"subsectionName"`
VideoTime string `json:"videoTime"`
StudyFiles StudyFile `json:"studyFiles"`
}
type StudyFile struct {
Id string `json:"id"`
FileName string `json:"fileName"`
FileSize string `json:"fileSize"`
FileTime string `json:"fileTime"`
FileType string `json:"fileType"`
FilePath string `json:"filePath"`
VideoTime string `json:"videoTime"`
StudyTime string `json:"studyTime"`
State string `json:"state"` // 2是学习中 1是未开始 3是已完成
}
+185
View File
@@ -0,0 +1,185 @@
package sc
import (
"context"
"likedream-goframe/internal/application/smartedu/model"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/framework/logger"
"likedream-goframe/internal/utils"
"regexp"
"strconv"
"strings"
"github.com/samber/oops"
"github.com/tidwall/gjson"
"go.uber.org/zap"
"resty.dev/v3"
)
const (
HomeUrl = "https://basic.sc.smartedu.cn/hd/teacherTraining/home"
CourseDetailUrl = "https://basic.sc.smartedu.cn/hd/teacherTraining/api/studyCourse/getCourseDetails"
RecordProcessUrl = "https://basic.sc.smartedu.cn/hd/teacherTraining/api/studyCourseUser/recordProcess"
RefresProcessUrl = "https://basic.sc.smartedu.cn/hd/teacherTraining/api/studyCourseUser/refreshProcess"
ApiCourseFinishedHour = "https://basic.sc.smartedu.cn/hd/teacherTraining/api/studyCourse/getCourseFinishedHour"
)
type Course struct {
Client *resty.Client
Token string
}
func NewCourse(token string, client *resty.Client) *Course {
return &Course{
Token: token,
Client: client,
}
}
func (c *Course) GetCourseList(ctx context.Context) []model.Course {
jsUrl := c.GetJsUrl(ctx, HomeUrl)
get, err := c.Client.R().Get(jsUrl)
utils.OkOrPanic(err, oops.WithContext(ctx).With("jsUrl", jsUrl).Wrap)
jsonString := ParseJsonString(get.String())
returnData := gjson.Get(jsonString, "returnData").Array()
var courseList []model.Course
for _, one := range returnData {
courseList = append(courseList, model.Course{
Id: one.Get("id").String(),
CourseName: one.Get("courseName").String(),
Image: one.Get("studyFiles.filePath").String(),
})
}
return courseList
}
func (c *Course) GetChapterList(ctx context.Context, courseId string) []model.Chapter {
get, err := c.Client.R().SetQueryParam("courseId", courseId).SetHeader("X-Token", c.Token).Get(CourseDetailUrl)
utils.OkOrPanic(err, oops.WithContext(ctx).With("GetChapterList", courseId).Wrap)
chapterJson := gjson.Get(get.String(), "returnData.chapters").Array()
var chapterList []model.Chapter
for _, ch := range chapterJson {
chapter := model.Chapter{
Id: ch.Get("id").String(),
ChapterName: ch.Get("chapterName").String(),
}
subSectionJson := ch.Get("studySubsections").Array()
var subSectionList []model.SubSection
for _, sec := range subSectionJson {
studyFileJson := sec.Get("studyFiles")
studyFile := model.StudyFile{
Id: studyFileJson.Get("id").String(),
FileName: studyFileJson.Get("fileName").String(),
FileSize: studyFileJson.Get("fileSize").String(),
FileTime: studyFileJson.Get("fileTime").String(),
FileType: studyFileJson.Get("fileType").String(),
FilePath: studyFileJson.Get("filePath").String(),
VideoTime: studyFileJson.Get("videoTime").String(),
State: studyFileJson.Get("state").String(),
StudyTime: studyFileJson.Get("studyTime").String(),
}
subSection := model.SubSection{
Id: sec.Get("id").String(),
SubsectionName: sec.Get("subsectionName").String(),
VideoTime: sec.Get("videoTime").String(),
StudyFiles: studyFile,
}
subSectionList = append(subSectionList, subSection)
}
chapter.SubSection = subSectionList
chapterList = append(chapterList, chapter)
}
return chapterList
}
func ParseJsonString(str string) string {
// eval("module.exports = /*#__PURE__*/JSON.parse('{...}');\n\n//# sourceURL=...
re := regexp.MustCompile(`#__PURE__\*/JSON\.parse\('(.+?)'\);`)
matches := re.FindStringSubmatch(str)
if len(matches) < 2 {
return ""
}
// 处理转义的引号
return strings.ReplaceAll(matches[1], `\"`, `"`)
}
func (c *Course) GetJsUrl(ctx context.Context, url string) string {
get, err := c.Client.R().Get(url)
utils.OkOrPanic(err, oops.WithContext(ctx).With("GetJsUrl", url).Wrap)
return ParseJsUrl(get.String())
}
func (c *Course) Study(ctx context.Context, c2 model.Course, ch model.Chapter, sec model.SubSection) {
// 如果已完成
if sec.StudyFiles.State == "3" {
return
}
form := map[string]any{
"chapterId": ch.Id,
"courseId": c2.Id,
"subsectionId": sec.Id,
}
if sec.StudyFiles.State == "1" {
form["state"] = "1"
c.Refresh(ctx, c2, ch, sec)
form["studyTime"] = 5
// 如果state为
post, err := c.Client.R().SetHeader("X-Token", c.Token).SetBody(form).Post(RecordProcessUrl)
utils.OkOrPanic(err, oops.WithContext(ctx).With("Study", form).Wrap)
str := post.String()
if gjson.Get(str, "returnCode").String() != "200" {
utils.OkOrPanic(errors.NewBusinessError(503, "学习失败,"+gjson.Get(str, "returnMessage").String()))
}
logger.Logger.Info("学习成功", zap.String("课程名", c2.CourseName), zap.String("章节名", ch.ChapterName), zap.String("小节名", sec.SubsectionName))
}
c.Refresh(ctx, c2, ch, sec)
atoi, _ := strconv.Atoi(sec.StudyFiles.VideoTime)
form["state"] = "3"
form["studyTime"] = atoi
// 如果state为
post, err := c.Client.R().SetHeader("X-Token", c.Token).SetBody(form).Post(RecordProcessUrl)
utils.OkOrPanic(err, oops.WithContext(ctx).With("Study", form).Wrap)
str := post.String()
if gjson.Get(str, "returnCode").String() != "200" {
utils.OkOrPanic(errors.NewBusinessError(503, "学习失败,"+gjson.Get(str, "returnMessage").String()))
}
logger.Logger.Info("学习成功", zap.String("课程名", c2.CourseName), zap.String("章节名", ch.ChapterName), zap.String("小节名", sec.SubsectionName))
}
func (c *Course) Refresh(ctx context.Context, c2 model.Course, ch model.Chapter, sec model.SubSection) {
form := map[string]any{
"chapterId": ch.Id,
"courseId": c2.Id,
"subsectionId": sec.Id,
"studyTime": 0,
}
post, err := c.Client.R().SetHeader("X-Token", c.Token).SetBody(form).Post(RefresProcessUrl)
utils.OkOrPanic(err, oops.WithContext(ctx).With("Refresh", form).Wrap)
str := post.String()
if gjson.Get(str, "returnCode").String() != "200" {
utils.OkOrPanic(errors.NewBusinessError(503, "刷新嫉妒失败,"+gjson.Get(str, "returnMessage").String()))
}
logger.Logger.Info("刷新成功", zap.String("课程名", c2.CourseName), zap.String("章节名", ch.ChapterName), zap.String("小节名", sec.SubsectionName))
}
func ParseJsUrl(str string) string {
// <script defer src="https://cdnbasic.scbdc.edu.cn/%E7%9C%81%E7%BA%A7%E8%B5%84%E6%BA%90/hd/chunk-vendors.5e79f5ce155849.js">
re := regexp.MustCompile(`https://[^"'\s]+chunk-vendors[^"'\s]+\.js`)
match := re.FindString(str)
if match == "" {
return ""
}
return strings.Replace(match, "chunk-vendors", "src_views_home_index_vue", 1)
}
func (c *Course) GetCourseProcees(ctx context.Context, courseId string) (finished string, total string) {
post, err := c.Client.R().SetHeader("X-Token", c.Token).SetBody(map[string]any{
"courseIds": []string{courseId},
}).Post(ApiCourseFinishedHour)
utils.OkOrPanic(err, oops.WithContext(ctx).With("GetCourseProcees", courseId).Wrap)
str := gjson.Get(post.String(), "returnData.finishedHours").String()
split := strings.Split(str, ",")
return split[0], split[1]
}
+95
View File
@@ -0,0 +1,95 @@
package sc
import (
"context"
"likedream-goframe/internal/application/cookiejar"
"likedream-goframe/internal/application/httpclient"
"likedream-goframe/internal/application/smartedu/cryptoutil"
"likedream-goframe/internal/common/errors"
"likedream-goframe/internal/utils"
"strings"
"github.com/PuerkitoBio/goquery"
"github.com/gogf/gf/v2/container/gvar"
"github.com/samber/oops"
"resty.dev/v3"
)
const (
LoginPageUrl = "https://basic.sc.smartedu.cn/ThirdPortalService/user/otherlogin!login.ac?appkey=C56DA16ECBC56FBEEC908DA09E45C72C917A80118F057FA1F0B5BAE41CC9CC9DECD5BDB7133FE17C328C5D37B37CA8E7&pkey=5D79CA42E45C5273DF8532D09E1F158B15E25919CDB958940F84D5E63F5F53A1ECD5BDB7133FE17C328C5D37B37CA8E7&params=718F83A5347CBFDB7D1A9065FA090FE949D92330BB9A3351FE0715C5B8A3E86F37916C1004E835C7C7F964E3F301477F7D37F04485FA8707845DAAA23356236ED1D326CF5A5E3C263470516EE9B4A2ED"
LoginActionUrl = "https://basic.sc.smartedu.cn/ThirdPortalService/login!login.ac"
LoginTicketUrl = "https://basic.sc.smartedu.cn/ThirdPortalService/login!getTicket.ac"
LoginOOSUrl = "https://basic.sc.smartedu.cn/hd/teacherTraining/api/sso/oauth/token"
)
type Login struct {
Username string `json:"username"`
Password string `json:"password"`
Client *resty.Client
}
func NewLogin(username, password string) *Login {
return &Login{
Username: username,
Password: password,
Client: httpclient.NewSmartEdu(),
}
}
func (l *Login) GetCookieValue(key string) string {
cookies := l.Client.CookieJar().(*cookiejar.AllSavePersistCookieJar)
for _, cookie := range cookies.Cookies(nil) {
if cookie.Name == key {
return cookie.Value
}
}
return ""
}
func (l *Login) GetClient() *resty.Client {
return l.Client
}
func (l *Login) Login(ctx context.Context) string {
form := l.getLoginInput(ctx)
form["loginName"] = gvar.New(l.Username)
form["password"] = gvar.New(l.Password)
token := l.GetCookieValue("Client-Tocken")
data := cryptoutil.EncData(form, token)
post, err := l.Client.R().SetFormData(data).Post(LoginActionUrl)
utils.OkOrPanic(err, oops.WithContext(ctx).With("LoginActionUrl", LoginActionUrl).Wrap)
descData := cryptoutil.DescData(utils.ReadJson[map[string]interface{}](post.Body), token)
result := descData["data"].(string)
if !strings.Contains(result, `"Flag":0`) {
utils.OkOrPanic(errors.NewBusinessError(503, "登录失败"))
}
get, err := l.Client.R().Get(LoginTicketUrl)
utils.OkOrPanic(err, oops.WithContext(ctx).With("LoginTicketUrl", LoginTicketUrl).Wrap)
return get.String()
}
func (l *Login) OOSLogin(ctx context.Context, ticket string) string {
post, err := l.Client.R().SetQueryParam("ticket", ticket).Post(LoginOOSUrl)
utils.OkOrPanic(err, oops.WithContext(ctx).With("LoginOOSUrl", LoginOOSUrl).Wrap)
defer post.Body.Close()
json := utils.ReadJson[map[string]string](post.Body)
if json["returnCode"] != "200" {
utils.OkOrPanic(errors.NewBusinessError(503, json["returnMessage"]))
}
return json["returnData"]
}
func (l *Login) getLoginInput(ctx context.Context) map[string]*gvar.Var {
get, err := l.Client.R().WithContext(ctx).Get(LoginPageUrl)
utils.OkOrPanic(err, oops.WithContext(ctx).With("LoginPageUrl", LoginPageUrl).Wrap)
defer get.Body.Close()
reader, err := goquery.NewDocumentFromReader(get.Body)
utils.OkOrPanic(err, oops.WithContext(ctx).With("LoginPageUrl", LoginPageUrl).Wrap)
find := reader.Find("#loginContent input")
var inputMap = make(map[string]*gvar.Var)
find.Each(func(i int, input *goquery.Selection) {
key, exists := input.Attr("name")
if exists {
val, _ := input.Attr("value")
inputMap[key] = gvar.New(val)
}
})
return inputMap
}
@@ -0,0 +1,43 @@
package sc
import (
"context"
"testing"
)
func TestLogin(t *testing.T) {
login := NewLogin("13551503012", "XIAOzhi520")
login.Login(context.Background())
login.GetClient()
}
func TestStudy(t *testing.T) {
login := NewLogin("13551503012", "XIAOzhi520")
//ticket := login.Login(context.Background())
//token := login.OOSLogin(context.Background(), ticket)
client := login.GetClient()
course := NewCourse("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJkZXZpY2VUeXBlIjoid2ViIiwibG9naW5UeXBlIjoidXNlcnJlYWxtIiwiZXhwIjoxNzY1MjU3NDI1LCJ1c2VySWQiOiIxOTk4MDI1NjQ1NzU5ODM2MTYwIn0.PL5Roy0t9v0mK-ucK73IZg2qTZfw0YhW1sVxG1SYetU", client)
courseList := course.GetCourseList(context.Background())
for _, c := range courseList {
chapterList := course.GetChapterList(context.Background(), c.Id)
for _, ch := range chapterList {
for _, sec := range ch.SubSection {
course.Study(context.Background(), c, ch, sec)
}
}
}
}
func TestGetProcess(t *testing.T) {
login := NewLogin("13551503012", "XIAOzhi520")
//ticket := login.Login(context.Background())
//token := login.OOSLogin(context.Background(), ticket)
client := login.GetClient()
course := NewCourse("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJkZXZpY2VUeXBlIjoid2ViIiwibG9naW5UeXBlIjoidXNlcnJlYWxtIiwiZXhwIjoxNzY1MjU3NDI1LCJ1c2VySWQiOiIxOTk4MDI1NjQ1NzU5ODM2MTYwIn0.PL5Roy0t9v0mK-ucK73IZg2qTZfw0YhW1sVxG1SYetU", client)
courseList := course.GetCourseList(context.Background())
for _, c := range courseList {
finished, total := course.GetCourseProcees(context.Background(), c.Id)
t.Log(finished, total)
}
}
+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
}
@@ -62,44 +62,33 @@ type XueXiTCourse struct {
CourseID string `json:"courseId"` // 课程ID
ChatID string `json:"chatId"`
CourseTeacher string `json:"courseTeacher"` // 课程老师
CourseName string `json:"courseName"` //课程名
CourseName string `json:"courseName"` // 课程名
CourseImage string `json:"courseImage"`
// 两个标识 暂时不知道有什么用
CourseDataID int `json:"courseDataId"`
ContentID int `json:"ContentID"`
IsStart bool `json:"isstart"` //是否开课了,开课了为true,没开课为false。一般来说开课才能刷,不然刷不了的
State int `json:"state"` //课程状态,0为正常,1为课程已经结束
IsStart bool `json:"isstart"` // 是否开课了,开课了为true,没开课为false
State int `json:"state"` // 课程状态,0为正常,1为课程已经结束
}
// XueXiTCourseChapter 课程章节信息
type XueXiTCourseChapter struct {
ID string `json:"id"` // 章节ID
ID int `json:"id"` // 章节ID
Name string `json:"name"` // 章节名称
ParentNodeID string `json:"parentnodeid"` // 父节点ID
ParentNodeID int `json:"parentnodeid"` // 父节点ID
Layer int `json:"layer"` // 层级
Label string `json:"label"` // 标签
IndexOrder int `json:"indexOrder"` // 排序索引
JobCount int `json:"jobcount"` // 任务点数量
Status int `json:"status"` // 状态
Status string `json:"status"` // 状态
IsReview int `json:"isReview"` // 是否复习
BeginTime int64 `json:"begintime"` // 开始时间
EndTime int64 `json:"endtime"` // 结束时间
Attachment []XueXiTAttachment `json:"attachment"` // 附件列表
PointTotal int `json:"pointTotal"` // 总任务点
PointFinish int `json:"pointFinish"` // 已完成任务点
Knowledge []XueXiTCourseChapter `json:"knowledge"` // 子章节列表
}
// XueXiTAttachment 附件信息
type XueXiTAttachment struct {
ID string `json:"id"` // 附件ID
Type string `json:"type"` // 附件类型(video/document等)
ObjectID string `json:"objectid"` // 对象ID
Extension string `json:"extension"` // 扩展信息
JobID string `json:"jobid"` // 任务ID
Property string `json:"property"` // 属性
Name string `json:"name"` // 附件名称
IsPassed bool `json:"isPassed"` // 是否已完成
}
// ChapterData 章节数据响应
type ChapterData struct {
Data []struct {
@@ -133,20 +122,7 @@ type ChapterData struct {
Belongschoolid string `json:"belongschoolid"`
Objectid string `json:"objectid"`
Knowledge struct {
Data []struct {
Jobcount int `json:"jobcount"`
Isreview int `json:"isreview"`
Attachment struct {
Data []interface{} `json:"data"`
} `json:"attachment"`
Indexorder int `json:"indexorder"`
Name string `json:"name"`
ID int `json:"id"`
Label string `json:"label"`
Layer int `json:"layer"`
Parentnodeid int `json:"parentnodeid"`
Status string `json:"status"`
} `json:"data"`
Data []KnowledgeData `json:"data"`
} `json:"knowledge"`
} `json:"data"`
} `json:"course"`
@@ -157,152 +133,15 @@ type ChapterData struct {
} `json:"data"`
}
// Card 任务点卡片信息
type Card struct {
ID string `json:"id"` // 卡片ID
Title string `json:"title"` // 标题
Description string `json:"description"` // 描述
KnowledgeID string `json:"knowledgeid"` // 知识点ID
Type string `json:"type"` // 类型(video/document/work等)
IsPassed bool `json:"isPassed"` // 是否完成
ObjectID string `json:"objectid"` // 对象ID
JobID string `json:"jobid"` // 任务ID
ResourceID string `json:"resourceid"` // 资源ID
Attachment *IframeAttributes `json:"attachment,omitempty"` // iframe属性
}
// IframeAttributes iframe属性
type IframeAttributes struct {
Src string `json:"src"` // iframe地址
Module string `json:"module"` // 模块名
ModToken string `json:"modtoken"` // 模块token
Width string `json:"width"` // 宽度
Height string `json:"height"` // 高度
Scrolling string `json:"scrolling"` // 滚动
ClassID string `json:"classid"` // 课程ID
ObjectID string `json:"objectid"` // 对象ID
Enc string `json:"enc"` // 加密参数
UtEnc string `json:"utenc"` // ut加密参数
OtherInfo string `json:"otherinfo"` // 其他信息
JobID string `json:"jobid"` // 任务ID
KnowledgeID string `json:"knowledgeid"` // 知识点ID
CourseFaceCheck int `json:"coursefacecheck"` // 人脸识别
}
// DataItem 任务点数据项
type DataItem struct {
ID string `json:"id"`
IndexOrder int `json:"indexorder"`
KnowledgeID string `json:"knowledgeid"`
Attachment struct {
ObjectID string `json:"objectid"`
Property struct {
Name string `json:"name"`
IsPassed bool `json:"ispassed"`
JobID string `json:"jobid"`
} `json:"property"`
Type string `json:"type"`
} `json:"attachment"`
PointList []interface{} `json:"pointList"`
JobCount int `json:"jobcount"`
Duration int64 `json:"duration"`
ClazzID string `json:"clazzid"`
CourseID string `json:"courseid"`
Module string `json:"module"`
}
// VideoDto 视频信息
type VideoDto struct {
VideoPath string `json:"videoPath"` // 视频路径
ObjectID string `json:"objectid"` // 对象ID
OtherInfo string `json:"otherInfo"` // 其他信息
JobID string `json:"jobid"` // 任务ID
Duration int `json:"duration"` // 时长(秒)
DToken string `json:"dtoken"` // dtoken
IsAudio bool `json:"isAudio"` // 是否是音频
IsPassed bool `json:"isPassed"` // 是否已完成
}
// DocumentDto 文档信息
type DocumentDto struct {
ObjectID string `json:"objectid"` // 对象ID
JobID string `json:"jobid"` // 任务ID
OtherInfo string `json:"otherInfo"` // 其他信息
JToken string `json:"jtoken"` // jtoken
PageCount int `json:"pageCount"` // 页数
IsPassed bool `json:"isPassed"` // 是否已完成
}
// FaceQRCodeResponse 人脸识别二维码响应
type FaceQRCodeResponse struct {
Status bool `json:"status"`
Msg string `json:"msg"`
QR string `json:"qr"` // 二维码base64数据
Enc string `json:"enc"` // 加密参数
}
// FaceTokenResponse 人脸识别上传令牌响应
type FaceTokenResponse struct {
Result int `json:"result"`
UploadToken string `json:"uploadtoken"`
}
// FaceUploadResponse 人脸识别上传响应
type FaceUploadResponse struct {
Result bool `json:"result"`
Msg string `json:"msg"`
Data struct {
FileID string `json:"fileId"`
} `json:"data"`
}
// FaceVerifyResponse 人脸识别验证响应
type FaceVerifyResponse struct {
Status bool `json:"status"`
Msg string `json:"msg"`
}
// WorkDto 作业信息
type WorkDto struct {
WorkID string `json:"workid"` // 作业ID
CourseID string `json:"courseid"` // 课程ID
ClassID string `json:"classid"` // 班级ID
KnowledgeID string `json:"knowledgeid"` // 知识点ID
JobID string `json:"jobid"` // 任务ID
Enc string `json:"enc"` // 加密参数
UTEnc string `json:"utenc"` // UT加密参数
Title string `json:"title"` // 作业标题
IsPassed bool `json:"isPassed"` // 是否已完成
Questions []WorkQuestion `json:"questions"` // 题目列表
}
// WorkQuestion 作业题目
type WorkQuestion struct {
QuestionID string `json:"questionId"` // 题目ID
Type int `json:"type"` // 题目类型: 0单选 1多选 2判断 3填空 4简答
Title string `json:"title"` // 题目标题
Description string `json:"description"` // 题目描述
Options []string `json:"options"` // 选项列表(单选/多选/判断)
Answer string `json:"answer"` // 答案
Score float64 `json:"score"` // 分值
}
// WorkSubmitRequest 作业提交请求
type WorkSubmitRequest struct {
WorkID string `json:"workid"`
TotalScore string `json:"totalScore"`
AnswerList []WorkAnswerItem `json:"answerlist"`
UserId string `json:"userId"`
ClassId string `json:"classId"`
CourseId string `json:"courseId"`
KnowledgeId string `json:"knowledgeid"`
JobId string `json:"jobid"`
Enc string `json:"enc"`
UTEnc string `json:"utenc"`
}
// WorkAnswerItem 作业答案项
type WorkAnswerItem struct {
QuestionID string `json:"questionId"`
Answer string `json:"answer"`
// KnowledgeData 章节知识点数据
type KnowledgeData struct {
Jobcount int `json:"jobcount"`
Isreview int `json:"isreview"`
Indexorder int `json:"indexorder"`
Name string `json:"name"`
ID int `json:"id"`
Label string `json:"label"`
Layer int `json:"layer"`
Parentnodeid int `json:"parentnodeid"`
Status string `json:"status"`
}
@@ -0,0 +1,293 @@
package entity
// PointType 任务点类型
type PointType string
const (
PointTypeVideo PointType = "insertvideo"
PointTypeDocument PointType = "insertdoc"
PointTypeDocument2 PointType = "document"
PointTypeBook PointType = "insertbook"
PointTypeWork PointType = "insertwork"
PointTypeHyperlink PointType = "insertlink"
PointTypeLive PointType = "insertlive"
)
// PointVideoDto 视频任务点
type PointVideoDto struct {
CardIndex int
CourseID string
ClassID string
KnowledgeID int
Cpi string
ObjectID string
// 从SSR视图中获取
IsPassed bool
FID int
DToken string
PlayTime int
Duration int
JobID string
OtherInfo string
Title string
RT float64
VideoFaceCaptureEnc string
RandomCaptureTime string
AttDurationEnc string
Enc string
Mid string
}
// PointDocumentDto 文档查看任务点
type PointDocumentDto struct {
CardIndex int
CourseID string
ClassID string
KnowledgeID int
Cpi string
ObjectID string
Title string
JobID string
Jtoken string
IsJob bool // 是否是任务点
Type string // document / insertbook
}
// PointWorkDto 测验/作业任务点
type PointWorkDto struct {
CardIndex int
CourseID string
ClassID string
KnowledgeID int
Cpi string
WorkID string
SchoolID string
JobID string
PUID string
KToken string
Enc string
Title string
IsPassed bool
}
// PointHyperlinkDto 外链任务点
type PointHyperlinkDto struct {
CardIndex int
CourseID string
ClassID string
KnowledgeID int
Cpi string
ObjectID string
Title string
JobID string
Jtoken string
LinkType int // 外链类型
IsJob bool // 是否是任务点
}
// PointLiveDto 直播任务点
type PointLiveDto struct {
CardIndex int
CourseID string
ClassID string
KnowledgeID int
Cpi string
UserId string // 用户ID
Live bool
LiveId string // 直播ID
Vdoid string
Mid string
Title string // 直播标题
JobID string
StreamName string // 直播流名称
LiveStatusStr string // 直播状态文字
LiveStatusCode int // 直播状态码 0=未开始
VideoDuration int // 视频时长
Aid string
Module string // 类型
IsJob bool // 是否为任务点
AuthEnc string
LiveDragEnc string
LiveSetEnc string
OtherInfo string
Enc string
LiveSwDsEnc string
VideoCompletePercent float64 // 观看进度
}
// Card 任务点卡片信息
type Card struct {
ID int `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
CardOrder int `json:"cardorder"`
KnowledgeID int `json:"knowledgeid"`
}
// CardAPIResponse 卡片API响应
type CardAPIResponse struct {
Data []CardDataItem `json:"data"`
}
// CardDataItem 卡片数据项
type CardDataItem struct {
ClickCount int `json:"clickcount"`
Createtime int64 `json:"createtime"`
OpenLock int `json:"openlock"`
IndexOrder int `json:"indexorder"`
Name string `json:"name"`
LastModifyTime int64 `json:"lastmodifytime"`
ID int `json:"id"`
Label string `json:"label"`
Layer int `json:"layer"`
Card struct {
Data []Card `json:"data"`
} `json:"card"`
ParentNodeID int `json:"parentnodeid"`
Status string `json:"status"`
}
// IframeAttributes iframe 的属性
type IframeAttributes struct {
Data map[string]interface{} `json:"data"`
Other map[string]string
HasData bool
}
// ChapterPointStatus 章节任务点状态
type ChapterPointStatus map[string]struct {
ClickCount int `json:"clickcount"`
FinishCount int `json:"finishcount"`
TotalCount int `json:"totalcount"`
OpenLock int `json:"openlock"`
UnFinishCount int `json:"unfinishcount"`
}
// AttachmentSetting 附件设置(从页面JS中解析)
type AttachmentSetting struct {
Attachments []map[string]interface{} `json:"attachments"`
Defaults map[string]interface{} `json:"defaults"`
}
// ChapterPoints 章节所有任务点集合(旧版,保留兼容)
type ChapterPoints struct {
Videos []PointVideoDto
Documents []PointDocumentDto
Works []PointWorkDto
Hyperlinks []PointHyperlinkDto
Lives []PointLiveDto
}
// ChapterPointType 任务点类型枚举
type ChapterPointType int
const (
ChapterPointTypeVideo ChapterPointType = 1 // 视频
ChapterPointTypeDocument ChapterPointType = 2 // 文档
ChapterPointTypeWork ChapterPointType = 3 // 作业
ChapterPointTypeHyperlink ChapterPointType = 4 // 外链
ChapterPointTypeLive ChapterPointType = 5 // 直播
)
// ChapterPoint 统一的章节任务点结构(与Card一一对应)
type ChapterPoint struct {
CardIndex int // 对应的卡片索引
Type ChapterPointType // 任务点类型
KnowledgeID int // 知识点ID
CourseID string // 课程ID
ClassID string // 班级ID
Cpi string // cpi
JobID string // 任务ID
ObjectID string // 对象ID
Title string // 标题
IsJob bool // 是否是任务点
IsPassed bool // 是否已完成
// 视频相关字段
FID int
DToken string
PlayTime int
Duration int
OtherInfo string
RT float64
VideoFaceCaptureEnc string
RandomCaptureTime string
AttDurationEnc string
Enc string
Mid string
// 文档相关字段
Jtoken string
DocumentType string // document / insertbook
// 作业相关字段
WorkID string
SchoolID string
PUID string
KToken string
// 外链相关字段
LinkType int
// 直播相关字段
UserId string
Live bool
LiveId string
Vdoid string
StreamName string
LiveStatusStr string
LiveStatusCode int
VideoDuration int
Aid string
Module string
AuthEnc string
LiveDragEnc string
LiveSetEnc string
LiveSwDsEnc string
VideoCompletePercent float64
}
// QuestionType 题目类型
type QuestionType int
const (
QuestionTypeSingleChoice QuestionType = 0 // 单选题
QuestionTypeMultipleChoice QuestionType = 1 // 多选题
QuestionTypeFillBlank QuestionType = 2 // 填空题
QuestionTypeTrueFalse QuestionType = 3 // 判断题
QuestionTypeShortAnswer QuestionType = 4 // 简答题
)
// WorkQuestion 作业题目
type WorkQuestion struct {
Qid string `json:"qid"` // 题目ID
Type QuestionType `json:"type"` // 题目类型
Text string `json:"text"` // 题目内容
Options map[string]string `json:"options"` // 选项 (A: xxx, B: xxx)
Answers []string `json:"answers"` // 答案
BlankCount int `json:"blankCount"` // 填空题空格数量
BlankAnswer map[string]string `json:"blankAnswer"` // 填空题答案 (第1空: xxx)
}
// WorkDto 作业详情
type WorkDto struct {
Title string `json:"title"` // 作业标题
IsPassed bool `json:"isPassed"`
Cpi string `json:"cpi"` // cpi
JobId string `json:"jobId"` // 任务ID
WorkId string `json:"workId"` // 作业ID
ClassId string `json:"classId"` // 班级ID
CourseId string `json:"courseId"` // 课程ID
KnowledgeId string `json:"knowledgeId"` // 知识点ID
UserId string `json:"userId"` // 用户ID
FullScore string `json:"fullScore"` // 满分
OldWorkId string `json:"oldWorkId"` // 原始作业ID
OldSchoolId string `json:"oldSchoolId"` // 原始学校ID
Api string `json:"api"` // API值
WorkRelationId string `json:"workRelationId"` // 作业关联ID
EncWork string `json:"encWork"` // 加密参数
WorkAnswerId string `json:"workAnswerId"` // 作业答案ID
AnswerId string `json:"answerId"` // 答案ID
TotalQuestionNum string `json:"totalQuestionNum"` // 题目总数
Questions []WorkQuestion `json:"questions"` // 题目列表
}
@@ -0,0 +1,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
}
@@ -9,10 +9,8 @@ import (
"resty.dev/v3"
)
var randChar []string = []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f", "A", "B", "C", "D", "E", "F"}
// ApiVerifyCodeBytes 获取学习通验证码图片
func ApiVerifyCodeBytes(client *resty.Client) ([]byte, error) {
urlStr := "https://mooc1-api.chaoxing.com/processVerifyPng.ac?t=" + fmt.Sprintf("%d", time.Now().UnixMilli())
req := client.R()
@@ -32,22 +30,24 @@ func ApiVerifyCodeBytes(client *resty.Client) ([]byte, error) {
return all, nil
}
func ApiPassVerifyCode(client *resty.Client, code string, retry int) bool {
if retry < 0 {
// ApiPassVerifyCode 提交学习通验证码
func ApiPassVerifyCode(client *resty.Client, code string, retryCount int) bool {
if retryCount < 0 {
return false
}
urlStr := "https://mooc1-api.chaoxing.com/html/processVerify.ac"
//urlStr := "https://mooc1-api.chaoxing.com/html/processVerify.ac"
urlStr := "https://mooc1-api.chaoxing.com/json/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, retry-1)
return ApiPassVerifyCode(client, code, retryCount-1)
}
client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
return res.StatusCode() == 302
@@ -0,0 +1,762 @@
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
}
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, 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, knowledgeID int) (map[string]interface{}, error) {
urlStr := fmt.Sprintf("%s?clazzid=%s&courseid=%s&knowledgeid=%d&num=0&ut=s&cpi=%d&v=2025-0424-1038-3&mooc2=1&isMicroCourse=false&editorPreview=0",
ApiChapterCards2, course.Key, course.CourseID, knowledgeID, 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
}
+71 -134
View File
@@ -2,11 +2,68 @@ package xuexitong
import (
"context"
"likedream-goframe/internal/application/xuexitong/entity"
"likedream-goframe/internal/application/quesbank"
"likedream-goframe/internal/common/db"
"likedream-goframe/internal/ent/sysconfig"
"likedream-goframe/internal/framework/errors"
"likedream-goframe/internal/framework/logger"
"likedream-goframe/internal/utils"
"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)
utils.OkOrPanic(err)
client := xuexitongLogin.GetClient()
courseService := NewXuexitongCourse(client)
// 人人必修的人工智能课
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, "258109701", &AutoStudyOptions{
AIConfig: config,
AutoSubmit: true, // 默认不自动提交,仅保存
Thread: 1,
})
if err != nil {
utils.OkOrPanic(errors.NewBusinessErrorWithDetail(503, "刷课失败", err))
}
}
func TestXuexitongLogin(t *testing.T) {
xuexitongLogin := NewXuexitongLogin(context.Background())
err := xuexitongLogin.Login(context.Background(), "13551503012", "xiaozhi520", 5)
@@ -39,7 +96,7 @@ func TestPullChapter(t *testing.T) {
}
// 获取第一个课程的章节
chapters := courseAction.PullCourseChapterList(context.Background(), courseList[0].CourseID)
chapters := courseAction.PullCourseChapterList(context.Background(), courseList[0])
t.Logf("课程 %s 的章节数量: %d", courseList[0].CourseName, len(chapters))
for _, chapter := range chapters {
@@ -64,149 +121,23 @@ func TestFetchChapterCards(t *testing.T) {
course := courseList[0]
// 获取章节列表
chapters := courseAction.PullCourseChapterList(context.Background(), course.CourseID)
chapters := courseAction.PullCourseChapterList(context.Background(), course)
if len(chapters) == 0 {
t.Fatal("没有找到章节")
}
// 找一个有任务点的章节
var targetChapter *entity.XueXiTCourseChapter
for i := range chapters {
if chapters[i].JobCount > 0 {
targetChapter = &chapters[i]
var targetChapterID int
for _, ch := range chapters {
if ch.JobCount > 0 {
targetChapterID = ch.ID
break
}
}
if targetChapter == nil {
if targetChapterID == 0 {
t.Fatal("没有找到有任务点的章节")
}
// 获取任务点卡片
cards, err := courseAction.FetchChapterCards(context.Background(), course, targetChapter.ID, targetChapter.ID)
utils.OkOrPanic(err)
t.Logf("章节 %s 的任务点数量: %d", targetChapter.Name, len(cards))
for _, card := range cards {
t.Logf("任务点: 类型=%s, JobID=%s, ObjectID=%s", card.Type, card.JobID, card.ObjectID)
}
}
func TestAutoPlayVideo(t *testing.T) {
xuexitongLogin := NewXuexitongLogin(context.Background())
err := xuexitongLogin.Login(context.Background(), "13551503012", "xiaozhi520", 5)
utils.OkOrPanic(err)
client := xuexitongLogin.GetClient()
courseAction := NewXuexitongCourse(client)
// 获取课程列表
courseList := courseAction.PullCourseList(context.Background())
if len(courseList) == 0 {
t.Fatal("没有找到课程")
}
course := courseList[0]
// 获取章节列表
chapters := courseAction.PullCourseChapterList(context.Background(), course.CourseID)
if len(chapters) == 0 {
t.Fatal("没有找到章节")
}
// 找一个有任务点的章节
var targetChapter *entity.XueXiTCourseChapter
for i := range chapters {
if chapters[i].JobCount > 0 {
targetChapter = &chapters[i]
break
}
}
if targetChapter == nil {
t.Fatal("没有找到有任务点的章节")
}
// 获取任务点卡片
cards, err := courseAction.FetchChapterCards(context.Background(), course, targetChapter.ID, targetChapter.ID)
utils.OkOrPanic(err)
// 找视频任务点
for _, card := range cards {
if card.Type == "video" {
t.Logf("开始处理视频任务点: %s", card.JobID)
// 获取视频信息
videoDto, err := courseAction.FetchVideoDto(context.Background(), card.ObjectID, "", card.JobID)
utils.OkOrPanic(err)
// 自动播放视频
err = courseAction.AutoPlayVideo(context.Background(), course, card, videoDto)
utils.OkOrPanic(err)
t.Logf("视频任务点完成: %s", card.JobID)
break // 只测试一个视频
}
}
}
func TestAutoReadDocument(t *testing.T) {
xuexitongLogin := NewXuexitongLogin(context.Background())
err := xuexitongLogin.Login(context.Background(), "13551503012", "xiaozhi520", 5)
utils.OkOrPanic(err)
client := xuexitongLogin.GetClient()
courseAction := NewXuexitongCourse(client)
// 获取课程列表
courseList := courseAction.PullCourseList(context.Background())
if len(courseList) == 0 {
t.Fatal("没有找到课程")
}
course := courseList[0]
// 获取章节列表
chapters := courseAction.PullCourseChapterList(context.Background(), course.CourseID)
if len(chapters) == 0 {
t.Fatal("没有找到章节")
}
// 找一个有任务点的章节
var targetChapter *entity.XueXiTCourseChapter
for i := range chapters {
if chapters[i].JobCount > 0 {
targetChapter = &chapters[i]
break
}
}
if targetChapter == nil {
t.Fatal("没有找到有任务点的章节")
}
// 获取任务点卡片
cards, err := courseAction.FetchChapterCards(context.Background(), course, targetChapter.ID, targetChapter.ID)
utils.OkOrPanic(err)
// 找文档任务点
for _, card := range cards {
if card.Type == "document" {
t.Logf("开始处理文档任务点: %s", card.JobID)
// 获取文档信息
documentDto, err := courseAction.FetchDocumentDto(context.Background(), card.ObjectID, "", card.JobID)
utils.OkOrPanic(err)
// 自动阅读文档
err = courseAction.AutoReadDocument(context.Background(), course, card, documentDto)
utils.OkOrPanic(err)
t.Logf("文档任务点完成: %s", card.JobID)
break // 只测试一个文档
}
}
}
// TestAutoStudyByCourseID 测试通过课程ID刷课(定时任务使用)
@@ -251,3 +182,9 @@ func TestAutoStudyAllCourses(t *testing.T) {
t.Log("所有课程刷课完成")
}
func TestSplitWords(t *testing.T) {
str := "ABCD"
split := strings.Split(str, "")
t.Log(split)
}
@@ -2,9 +2,9 @@ package xuexitong
import (
"crypto/md5"
crand "crypto/rand"
"encoding/hex"
"fmt"
"math/rand"
"strings"
)
@@ -34,7 +34,7 @@ func GetUA(uaType string) string {
return strings.Join([]string{
fmt.Sprintf("Mozilla/5.0 (Linux; %s; %s Build/OPM1.171019.019; wv)", ANDROID_VERSION, DEVICE_VENDOR),
fmt.Sprintf("AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/71.0.3578.99 Mobile Safari/537.36"),
"AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/71.0.3578.99 Mobile Safari/537.36",
fmt.Sprintf("(schild:%s)", MobileUASign(DEVICE_VENDOR, "zh_CN", APP_VERSION, BUILD, IMEI)),
fmt.Sprintf("(device:%s)", DEVICE_VENDOR),
"Language/zh_CN",
@@ -48,15 +48,14 @@ func GetUA(uaType string) string {
}
}
// 用于生成IMEI
func TokenHex(len int) string {
b := make([]byte, 16)
_, err := rand.Read(b)
// TokenHex 用于生成IMEI
func TokenHex(length int) string {
b := make([]byte, length)
_, err := crand.Read(b)
if err != nil {
panic(err)
}
token := hex.EncodeToString(b)
return token
return hex.EncodeToString(b)
}
func MobileUASign(model, locale, version, build, imei string) string {
@@ -0,0 +1,455 @@
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
}
-5
View File
@@ -1,5 +0,0 @@
package cst
const (
BasePointCpdaily = 1
)
+12 -1
View File
@@ -37,7 +37,18 @@ const (
ConfigEmailFromName = "email.fromName"
// 基础积分配置
ConfigBasePointCpdaily = "basePoint.cpdaily"
ConfigBasePointCpdailyTask = "basePoint.cpdailyTask"
ConfigBasePointCpdailyLeave = "basePoint.cpdailyLeave"
ConfigBasePointCpdailyPhone = "basePoint.cpdailyPhone"
// 系统配置
ConfigSystemAllowRegister = "system.allowRegister" // 是否允许注册
ConfigRedisCpdailyTask = "price.cpdaily_task"
ConfigRedisCpdailyLeave = "price.cpdaily_leave"
ConfigRedisCpdailyPhone = "price.cpdaily_phone"
ConfigRedisXxt = "price.xxt"
ConfigRedisSmarteduSc = "price.smartedu_sc"
)
const (
@@ -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
}
@@ -31,6 +31,18 @@ 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
}
func NewConfigCtrlV1() IConfigCtrlV1 {
return &ConfigCtrlV1{}
}
@@ -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)
}
@@ -12,4 +12,6 @@ type IConfigCtrlV1 interface {
PublicGet(ctx context.Context, req *v1.ConfigPublicGetReq) (*utils.Response[*ent.SysConfig], error)
List(ctx context.Context, req *v1.ConfigListReq) (*utils.Response[[]*ent.SysConfig], error)
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)
}
@@ -0,0 +1,16 @@
package course
import (
"context"
v1 "likedream-goframe/internal/api/v1"
"likedream-goframe/internal/application/smartedu/model"
"likedream-goframe/internal/ent"
"likedream-goframe/internal/utils"
)
type ISmartEduSCCtrlV1 interface {
List(ctx context.Context, req *v1.SmartEduSCListReq) (*utils.Response[[]*ent.CourseEntity], error)
QueryCourses(ctx context.Context, req *v1.SmartEduSCQueryCourseReq) (*utils.Response[[]model.Course], error)
AddCourse(ctx context.Context, req *v1.SmartEduSCAddCourseReq) (*utils.Response[any], error)
DeleteCourse(ctx context.Context, req *v1.SmartEduSCDeleteReq) (*utils.Response[any], error)
}
@@ -0,0 +1,41 @@
package course
import (
"context"
v1 "likedream-goframe/internal/api/v1"
"likedream-goframe/internal/application/smartedu/model"
"likedream-goframe/internal/ent"
"likedream-goframe/internal/service"
"likedream-goframe/internal/utils"
)
type SmartEduSCCtrlV1 struct {
}
// List 获取四川智慧教育刷课列表
func (c SmartEduSCCtrlV1) List(ctx context.Context, req *v1.SmartEduSCListReq) (res *utils.Response[[]*ent.CourseEntity], err error) {
defer utils.Pass(&err)
return service.Global.SmartEduSC.List(ctx, req), nil
}
// QueryCourses 查询四川智慧教育课程列表
func (c SmartEduSCCtrlV1) QueryCourses(ctx context.Context, req *v1.SmartEduSCQueryCourseReq) (res *utils.Response[[]model.Course], err error) {
defer utils.Pass(&err)
return service.Global.SmartEduSC.QueryCourses(ctx, req), nil
}
// AddCourse 添加四川智慧教育刷课任务
func (c SmartEduSCCtrlV1) AddCourse(ctx context.Context, req *v1.SmartEduSCAddCourseReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.SmartEduSC.AddCourse(ctx, req), nil
}
// DeleteCourse 删除四川智慧教育刷课任务
func (c SmartEduSCCtrlV1) DeleteCourse(ctx context.Context, req *v1.SmartEduSCDeleteReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.SmartEduSC.DeleteCourse(ctx, req), nil
}
func NewSmartEduSCCtrlV1() ISmartEduSCCtrlV1 {
return &SmartEduSCCtrlV1{}
}
@@ -26,6 +26,11 @@ func (c CpLeaveCtrlV1) Report(ctx context.Context, req *v1.CpLeaveReportReq) (re
return service.Global.CpLeave.Report(ctx, req), nil
}
func (c CpLeaveCtrlV1) Upload(ctx context.Context, req *v1.CpLeaveUploadReq) (res *utils.Response[string], err error) {
defer utils.Pass(&err)
return service.Global.CpLeave.Upload(ctx, req), nil
}
func NewCpLeaveCtrlV1() ICpLeaveCtrlV1 {
return &CpLeaveCtrlV1{}
}
@@ -4,8 +4,12 @@ import (
"context"
v1 "likedream-goframe/internal/api/v1"
"likedream-goframe/internal/ent"
"likedream-goframe/internal/framework/captcha"
"likedream-goframe/internal/framework/redis"
"likedream-goframe/internal/service"
"likedream-goframe/internal/utils"
"github.com/bytedance/sonic"
)
type CpUserCtrlV1 struct {
@@ -68,10 +72,32 @@ func (c CpUserCtrlV1) AuthorizeRandom(ctx context.Context, req *v1.CpUserAuthori
}
func (c CpUserCtrlV1) LoginGetCode(ctx context.Context, req *v1.LoginGetCode) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
client := redis.GetClient()
dotsStr := client.GetDel(ctx, req.CaptchaKey).Val()
if dotsStr == "" {
return utils.Fail(10003, "验证码错误"), nil
}
var dots captcha.ConfirmData
err = sonic.Unmarshal([]byte(dotsStr), &dots)
utils.OkOrPanic(err)
if !captcha.VerifyCaptcha(dots, req.Dots) {
return utils.Fail(10003, "验证码错误"), nil
}
return service.Global.CpUser.LoginGetCode(ctx, req), nil
}
func (c CpUserCtrlV1) AddByCode(ctx context.Context, req *v1.AddByCode) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
client := redis.GetClient()
dotsStr := client.GetDel(ctx, req.CaptchaKey).Val()
if dotsStr == "" {
return utils.Fail(10003, "验证码错误"), nil
}
var dots captcha.ConfirmData
err = sonic.Unmarshal([]byte(dotsStr), &dots)
utils.OkOrPanic(err)
if !captcha.VerifyCaptcha(dots, req.Dots) {
return utils.Fail(10003, "验证码错误"), nil
}
return service.Global.CpUser.AddByCode(ctx, req), nil
}
@@ -11,4 +11,5 @@ type ICpLeaveCtrlV1 interface {
GetLeaveListFromSystem(ctx context.Context, req *v1.GetLeaveListFromSystemReq) (*utils.Response[any], error)
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)
}
@@ -5,7 +5,6 @@ import (
"encoding/json"
"likedream-goframe/internal/api/v1"
"likedream-goframe/internal/framework/captcha"
"likedream-goframe/internal/framework/errors"
"likedream-goframe/internal/framework/redis"
"likedream-goframe/internal/service"
"likedream-goframe/internal/utils"
@@ -47,7 +46,7 @@ func (c *UserLoginV1) Login(ctx context.Context, req *v1.UserLoginReq) (res *uti
if !captcha.VerifyCaptcha(dots, req.Dots) {
return utils.Fail(10003, "验证码错误"), nil
}
utils.OkOrPanic(errors.NewBusinessError(400, "注册功能暂时关闭,请等待重新开放"))
return service.Global.UserLogin.Login(ctx, req), nil
}
+4 -6
View File
@@ -47,9 +47,9 @@ type CourseEntity struct {
// Violence holds the value of the "violence" field.
Violence courseentity.Violence `json:"violence,omitempty"`
// Nodes holds the value of the "nodes" field.
Nodes string `json:"nodes,omitempty"`
Nodes string `json:"-"`
// CompletedNodes holds the value of the "completed_nodes" field.
CompletedNodes string `json:"completed_nodes,omitempty"`
CompletedNodes string `json:"-"`
// Desc holds the value of the "desc" field.
Desc string `json:"desc,omitempty"`
// Edges holds the relations/edges for other nodes in the graph.
@@ -294,11 +294,9 @@ func (_m *CourseEntity) String() string {
builder.WriteString("violence=")
builder.WriteString(fmt.Sprintf("%v", _m.Violence))
builder.WriteString(", ")
builder.WriteString("nodes=")
builder.WriteString(_m.Nodes)
builder.WriteString("nodes=<sensitive>")
builder.WriteString(", ")
builder.WriteString("completed_nodes=")
builder.WriteString(_m.CompletedNodes)
builder.WriteString("completed_nodes=<sensitive>")
builder.WriteString(", ")
builder.WriteString("desc=")
builder.WriteString(_m.Desc)
+3 -2
View File
@@ -117,7 +117,8 @@ type Type string
// Type values.
const (
TypeXxt Type = "xxt"
TypeXxt Type = "xxt"
TypeSmarteduSc Type = "smartedu_sc"
)
func (_type Type) String() string {
@@ -127,7 +128,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 TypeXxt:
case TypeXxt, TypeSmarteduSc:
return nil
default:
return fmt.Errorf("courseentity: invalid enum value for type field: %q", _type)
+12 -1
View File
@@ -39,6 +39,8 @@ type CpUser struct {
Cookie string `json:"cookie,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.
LoginType cpuser.LoginType `json:"login_type,omitempty"`
// Edges holds the relations/edges for other nodes in the graph.
// The values are being populated by the CpUserQuery when eager-loading is set.
Edges CpUserEdges `json:"edges"`
@@ -123,7 +125,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:
case cpuser.FieldUsername, cpuser.FieldPassword, cpuser.FieldSchoolID, cpuser.FieldEmail, cpuser.FieldMobileCode, cpuser.FieldCookie, cpuser.FieldMobileType, cpuser.FieldLoginType:
values[i] = new(sql.NullString)
default:
values[i] = new(sql.UnknownType)
@@ -206,6 +208,12 @@ func (_m *CpUser) assignValues(columns []string, values []any) error {
} else if value.Valid {
_m.MobileType = cpuser.MobileType(value.String)
}
case cpuser.FieldLoginType:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field login_type", values[i])
} else if value.Valid {
_m.LoginType = cpuser.LoginType(value.String)
}
default:
_m.selectValues.Set(columns[i], values[i])
}
@@ -296,6 +304,9 @@ func (_m *CpUser) String() string {
builder.WriteString(", ")
builder.WriteString("mobile_type=")
builder.WriteString(fmt.Sprintf("%v", _m.MobileType))
builder.WriteString(", ")
builder.WriteString("login_type=")
builder.WriteString(fmt.Sprintf("%v", _m.LoginType))
builder.WriteByte(')')
return builder.String()
}
+34 -2
View File
@@ -36,6 +36,8 @@ const (
FieldCookie = "cookie"
// 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.
FieldLoginType = "login_type"
// EdgeCptasks holds the string denoting the cptasks edge name in mutations.
EdgeCptasks = "cptasks"
// EdgeCpLeave holds the string denoting the cp_leave edge name in mutations.
@@ -98,6 +100,7 @@ var Columns = []string{
FieldMobileCode,
FieldCookie,
FieldMobileType,
FieldLoginType,
}
// ValidColumn reports if the column name is valid (part of the table columns).
@@ -123,8 +126,6 @@ var (
DefaultUpdateTime func() *ent2.CustomTime
// UsernameValidator is a validator for the "username" field. It is called by the builders before save.
UsernameValidator func(string) error
// PasswordValidator is a validator for the "password" field. It is called by the builders before save.
PasswordValidator func(string) error
// SchoolIDValidator is a validator for the "school_id" field. It is called by the builders before save.
SchoolIDValidator func(string) error
)
@@ -152,6 +153,32 @@ func MobileTypeValidator(mt MobileType) error {
}
}
// LoginType defines the type for the "login_type" enum field.
type LoginType string
// LoginTypeAccount is the default value of the LoginType enum.
const DefaultLoginType = LoginTypeAccount
// LoginType values.
const (
LoginTypeAccount LoginType = "account"
LoginTypePhone LoginType = "phone"
)
func (lt LoginType) String() string {
return string(lt)
}
// LoginTypeValidator is a validator for the "login_type" field enum values. It is called by the builders before save.
func LoginTypeValidator(lt LoginType) error {
switch lt {
case LoginTypeAccount, LoginTypePhone:
return nil
default:
return fmt.Errorf("cpuser: invalid enum value for login_type field: %q", lt)
}
}
// OrderOption defines the ordering options for the CpUser queries.
type OrderOption func(*sql.Selector)
@@ -210,6 +237,11 @@ func ByMobileType(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldMobileType, opts...).ToFunc()
}
// ByLoginType orders the results by the login_type field.
func ByLoginType(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldLoginType, opts...).ToFunc()
}
// ByCptasksCount orders the results by cptasks count.
func ByCptasksCount(opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
+30
View File
@@ -381,6 +381,16 @@ func PasswordHasSuffix(v string) predicate.CpUser {
return predicate.CpUser(sql.FieldHasSuffix(FieldPassword, v))
}
// PasswordIsNil applies the IsNil predicate on the "password" field.
func PasswordIsNil() predicate.CpUser {
return predicate.CpUser(sql.FieldIsNull(FieldPassword))
}
// PasswordNotNil applies the NotNil predicate on the "password" field.
func PasswordNotNil() predicate.CpUser {
return predicate.CpUser(sql.FieldNotNull(FieldPassword))
}
// PasswordEqualFold applies the EqualFold predicate on the "password" field.
func PasswordEqualFold(v string) predicate.CpUser {
return predicate.CpUser(sql.FieldEqualFold(FieldPassword, v))
@@ -701,6 +711,26 @@ func MobileTypeNotIn(vs ...MobileType) predicate.CpUser {
return predicate.CpUser(sql.FieldNotIn(FieldMobileType, vs...))
}
// LoginTypeEQ applies the EQ predicate on the "login_type" field.
func LoginTypeEQ(v LoginType) predicate.CpUser {
return predicate.CpUser(sql.FieldEQ(FieldLoginType, v))
}
// LoginTypeNEQ applies the NEQ predicate on the "login_type" field.
func LoginTypeNEQ(v LoginType) predicate.CpUser {
return predicate.CpUser(sql.FieldNEQ(FieldLoginType, v))
}
// LoginTypeIn applies the In predicate on the "login_type" field.
func LoginTypeIn(vs ...LoginType) predicate.CpUser {
return predicate.CpUser(sql.FieldIn(FieldLoginType, vs...))
}
// LoginTypeNotIn applies the NotIn predicate on the "login_type" field.
func LoginTypeNotIn(vs ...LoginType) predicate.CpUser {
return predicate.CpUser(sql.FieldNotIn(FieldLoginType, vs...))
}
// HasCptasks applies the HasEdge predicate on the "cptasks" edge.
func HasCptasks() predicate.CpUser {
return predicate.CpUser(func(s *sql.Selector) {
+98 -8
View File
@@ -57,6 +57,14 @@ func (_c *CpUserCreate) SetPassword(v string) *CpUserCreate {
return _c
}
// SetNillablePassword sets the "password" field if the given value is not nil.
func (_c *CpUserCreate) SetNillablePassword(v *string) *CpUserCreate {
if v != nil {
_c.SetPassword(*v)
}
return _c
}
// SetSchoolID sets the "school_id" field.
func (_c *CpUserCreate) SetSchoolID(v string) *CpUserCreate {
_c.mutation.SetSchoolID(v)
@@ -111,6 +119,20 @@ func (_c *CpUserCreate) SetMobileType(v cpuser.MobileType) *CpUserCreate {
return _c
}
// SetLoginType sets the "login_type" field.
func (_c *CpUserCreate) SetLoginType(v cpuser.LoginType) *CpUserCreate {
_c.mutation.SetLoginType(v)
return _c
}
// SetNillableLoginType sets the "login_type" field if the given value is not nil.
func (_c *CpUserCreate) SetNillableLoginType(v *cpuser.LoginType) *CpUserCreate {
if v != nil {
_c.SetLoginType(*v)
}
return _c
}
// AddCptaskIDs adds the "cptasks" edge to the CpTask entity by IDs.
func (_c *CpUserCreate) AddCptaskIDs(ids ...int64) *CpUserCreate {
_c.mutation.AddCptaskIDs(ids...)
@@ -223,6 +245,10 @@ func (_c *CpUserCreate) defaults() error {
v := cpuser.DefaultUpdateTime()
_c.mutation.SetUpdateTime(v)
}
if _, ok := _c.mutation.LoginType(); !ok {
v := cpuser.DefaultLoginType
_c.mutation.SetLoginType(v)
}
return nil
}
@@ -245,14 +271,6 @@ func (_c *CpUserCreate) check() error {
return &ValidationError{Name: "username", err: fmt.Errorf(`ent: validator failed for field "CpUser.username": %w`, err)}
}
}
if _, ok := _c.mutation.Password(); !ok {
return &ValidationError{Name: "password", err: errors.New(`ent: missing required field "CpUser.password"`)}
}
if v, ok := _c.mutation.Password(); ok {
if err := cpuser.PasswordValidator(v); err != nil {
return &ValidationError{Name: "password", err: fmt.Errorf(`ent: validator failed for field "CpUser.password": %w`, err)}
}
}
if _, ok := _c.mutation.SchoolID(); !ok {
return &ValidationError{Name: "school_id", err: errors.New(`ent: missing required field "CpUser.school_id"`)}
}
@@ -269,6 +287,14 @@ func (_c *CpUserCreate) check() error {
return &ValidationError{Name: "mobile_type", err: fmt.Errorf(`ent: validator failed for field "CpUser.mobile_type": %w`, err)}
}
}
if _, ok := _c.mutation.LoginType(); !ok {
return &ValidationError{Name: "login_type", err: errors.New(`ent: missing required field "CpUser.login_type"`)}
}
if v, ok := _c.mutation.LoginType(); ok {
if err := cpuser.LoginTypeValidator(v); err != nil {
return &ValidationError{Name: "login_type", err: fmt.Errorf(`ent: validator failed for field "CpUser.login_type": %w`, err)}
}
}
if len(_c.mutation.SysuserIDs()) == 0 {
return &ValidationError{Name: "sysuser", err: errors.New(`ent: missing required edge "CpUser.sysuser"`)}
}
@@ -335,6 +361,10 @@ func (_c *CpUserCreate) createSpec() (*CpUser, *sqlgraph.CreateSpec) {
_spec.SetField(cpuser.FieldMobileType, field.TypeEnum, value)
_node.MobileType = value
}
if value, ok := _c.mutation.LoginType(); ok {
_spec.SetField(cpuser.FieldLoginType, field.TypeEnum, value)
_node.LoginType = value
}
if nodes := _c.mutation.CptasksIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
@@ -522,6 +552,12 @@ func (u *CpUserUpsert) UpdatePassword() *CpUserUpsert {
return u
}
// ClearPassword clears the value of the "password" field.
func (u *CpUserUpsert) ClearPassword() *CpUserUpsert {
u.SetNull(cpuser.FieldPassword)
return u
}
// SetSchoolID sets the "school_id" field.
func (u *CpUserUpsert) SetSchoolID(v string) *CpUserUpsert {
u.Set(cpuser.FieldSchoolID, v)
@@ -600,6 +636,18 @@ func (u *CpUserUpsert) UpdateMobileType() *CpUserUpsert {
return u
}
// SetLoginType sets the "login_type" field.
func (u *CpUserUpsert) SetLoginType(v cpuser.LoginType) *CpUserUpsert {
u.Set(cpuser.FieldLoginType, v)
return u
}
// UpdateLoginType sets the "login_type" field to the value that was provided on create.
func (u *CpUserUpsert) UpdateLoginType() *CpUserUpsert {
u.SetExcluded(cpuser.FieldLoginType)
return u
}
// UpdateNewValues updates the mutable fields using the new values that were set on create.
// Using this option is equivalent to using:
//
@@ -701,6 +749,13 @@ func (u *CpUserUpsertOne) UpdatePassword() *CpUserUpsertOne {
})
}
// ClearPassword clears the value of the "password" field.
func (u *CpUserUpsertOne) ClearPassword() *CpUserUpsertOne {
return u.Update(func(s *CpUserUpsert) {
s.ClearPassword()
})
}
// SetSchoolID sets the "school_id" field.
func (u *CpUserUpsertOne) SetSchoolID(v string) *CpUserUpsertOne {
return u.Update(func(s *CpUserUpsert) {
@@ -792,6 +847,20 @@ func (u *CpUserUpsertOne) UpdateMobileType() *CpUserUpsertOne {
})
}
// SetLoginType sets the "login_type" field.
func (u *CpUserUpsertOne) SetLoginType(v cpuser.LoginType) *CpUserUpsertOne {
return u.Update(func(s *CpUserUpsert) {
s.SetLoginType(v)
})
}
// UpdateLoginType sets the "login_type" field to the value that was provided on create.
func (u *CpUserUpsertOne) UpdateLoginType() *CpUserUpsertOne {
return u.Update(func(s *CpUserUpsert) {
s.UpdateLoginType()
})
}
// Exec executes the query.
func (u *CpUserUpsertOne) Exec(ctx context.Context) error {
if len(u.create.conflict) == 0 {
@@ -1059,6 +1128,13 @@ func (u *CpUserUpsertBulk) UpdatePassword() *CpUserUpsertBulk {
})
}
// ClearPassword clears the value of the "password" field.
func (u *CpUserUpsertBulk) ClearPassword() *CpUserUpsertBulk {
return u.Update(func(s *CpUserUpsert) {
s.ClearPassword()
})
}
// SetSchoolID sets the "school_id" field.
func (u *CpUserUpsertBulk) SetSchoolID(v string) *CpUserUpsertBulk {
return u.Update(func(s *CpUserUpsert) {
@@ -1150,6 +1226,20 @@ func (u *CpUserUpsertBulk) UpdateMobileType() *CpUserUpsertBulk {
})
}
// SetLoginType sets the "login_type" field.
func (u *CpUserUpsertBulk) SetLoginType(v cpuser.LoginType) *CpUserUpsertBulk {
return u.Update(func(s *CpUserUpsert) {
s.SetLoginType(v)
})
}
// UpdateLoginType sets the "login_type" field to the value that was provided on create.
func (u *CpUserUpsertBulk) UpdateLoginType() *CpUserUpsertBulk {
return u.Update(func(s *CpUserUpsert) {
s.UpdateLoginType()
})
}
// Exec executes the query.
func (u *CpUserUpsertBulk) Exec(ctx context.Context) error {
if u.create.err != nil {
+62 -10
View File
@@ -83,6 +83,12 @@ func (_u *CpUserUpdate) SetNillablePassword(v *string) *CpUserUpdate {
return _u
}
// ClearPassword clears the value of the "password" field.
func (_u *CpUserUpdate) ClearPassword() *CpUserUpdate {
_u.mutation.ClearPassword()
return _u
}
// SetSchoolID sets the "school_id" field.
func (_u *CpUserUpdate) SetSchoolID(v string) *CpUserUpdate {
_u.mutation.SetSchoolID(v)
@@ -171,6 +177,20 @@ func (_u *CpUserUpdate) SetNillableMobileType(v *cpuser.MobileType) *CpUserUpdat
return _u
}
// SetLoginType sets the "login_type" field.
func (_u *CpUserUpdate) SetLoginType(v cpuser.LoginType) *CpUserUpdate {
_u.mutation.SetLoginType(v)
return _u
}
// SetNillableLoginType sets the "login_type" field if the given value is not nil.
func (_u *CpUserUpdate) SetNillableLoginType(v *cpuser.LoginType) *CpUserUpdate {
if v != nil {
_u.SetLoginType(*v)
}
return _u
}
// AddCptaskIDs adds the "cptasks" edge to the CpTask entity by IDs.
func (_u *CpUserUpdate) AddCptaskIDs(ids ...int64) *CpUserUpdate {
_u.mutation.AddCptaskIDs(ids...)
@@ -346,11 +366,6 @@ func (_u *CpUserUpdate) check() error {
return &ValidationError{Name: "username", err: fmt.Errorf(`ent: validator failed for field "CpUser.username": %w`, err)}
}
}
if v, ok := _u.mutation.Password(); ok {
if err := cpuser.PasswordValidator(v); err != nil {
return &ValidationError{Name: "password", err: fmt.Errorf(`ent: validator failed for field "CpUser.password": %w`, err)}
}
}
if v, ok := _u.mutation.SchoolID(); ok {
if err := cpuser.SchoolIDValidator(v); err != nil {
return &ValidationError{Name: "school_id", err: fmt.Errorf(`ent: validator failed for field "CpUser.school_id": %w`, err)}
@@ -361,6 +376,11 @@ func (_u *CpUserUpdate) check() error {
return &ValidationError{Name: "mobile_type", err: fmt.Errorf(`ent: validator failed for field "CpUser.mobile_type": %w`, err)}
}
}
if v, ok := _u.mutation.LoginType(); ok {
if err := cpuser.LoginTypeValidator(v); err != nil {
return &ValidationError{Name: "login_type", err: fmt.Errorf(`ent: validator failed for field "CpUser.login_type": %w`, err)}
}
}
if _u.mutation.SysuserCleared() && len(_u.mutation.SysuserIDs()) > 0 {
return errors.New(`ent: clearing a required unique edge "CpUser.sysuser"`)
}
@@ -397,6 +417,9 @@ func (_u *CpUserUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if value, ok := _u.mutation.Password(); ok {
_spec.SetField(cpuser.FieldPassword, field.TypeString, value)
}
if _u.mutation.PasswordCleared() {
_spec.ClearField(cpuser.FieldPassword, field.TypeString)
}
if value, ok := _u.mutation.Email(); ok {
_spec.SetField(cpuser.FieldEmail, field.TypeString, value)
}
@@ -418,6 +441,9 @@ func (_u *CpUserUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if value, ok := _u.mutation.MobileType(); ok {
_spec.SetField(cpuser.FieldMobileType, field.TypeEnum, value)
}
if value, ok := _u.mutation.LoginType(); ok {
_spec.SetField(cpuser.FieldLoginType, field.TypeEnum, value)
}
if _u.mutation.CptasksCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
@@ -696,6 +722,12 @@ func (_u *CpUserUpdateOne) SetNillablePassword(v *string) *CpUserUpdateOne {
return _u
}
// ClearPassword clears the value of the "password" field.
func (_u *CpUserUpdateOne) ClearPassword() *CpUserUpdateOne {
_u.mutation.ClearPassword()
return _u
}
// SetSchoolID sets the "school_id" field.
func (_u *CpUserUpdateOne) SetSchoolID(v string) *CpUserUpdateOne {
_u.mutation.SetSchoolID(v)
@@ -784,6 +816,20 @@ func (_u *CpUserUpdateOne) SetNillableMobileType(v *cpuser.MobileType) *CpUserUp
return _u
}
// SetLoginType sets the "login_type" field.
func (_u *CpUserUpdateOne) SetLoginType(v cpuser.LoginType) *CpUserUpdateOne {
_u.mutation.SetLoginType(v)
return _u
}
// SetNillableLoginType sets the "login_type" field if the given value is not nil.
func (_u *CpUserUpdateOne) SetNillableLoginType(v *cpuser.LoginType) *CpUserUpdateOne {
if v != nil {
_u.SetLoginType(*v)
}
return _u
}
// AddCptaskIDs adds the "cptasks" edge to the CpTask entity by IDs.
func (_u *CpUserUpdateOne) AddCptaskIDs(ids ...int64) *CpUserUpdateOne {
_u.mutation.AddCptaskIDs(ids...)
@@ -972,11 +1018,6 @@ func (_u *CpUserUpdateOne) check() error {
return &ValidationError{Name: "username", err: fmt.Errorf(`ent: validator failed for field "CpUser.username": %w`, err)}
}
}
if v, ok := _u.mutation.Password(); ok {
if err := cpuser.PasswordValidator(v); err != nil {
return &ValidationError{Name: "password", err: fmt.Errorf(`ent: validator failed for field "CpUser.password": %w`, err)}
}
}
if v, ok := _u.mutation.SchoolID(); ok {
if err := cpuser.SchoolIDValidator(v); err != nil {
return &ValidationError{Name: "school_id", err: fmt.Errorf(`ent: validator failed for field "CpUser.school_id": %w`, err)}
@@ -987,6 +1028,11 @@ func (_u *CpUserUpdateOne) check() error {
return &ValidationError{Name: "mobile_type", err: fmt.Errorf(`ent: validator failed for field "CpUser.mobile_type": %w`, err)}
}
}
if v, ok := _u.mutation.LoginType(); ok {
if err := cpuser.LoginTypeValidator(v); err != nil {
return &ValidationError{Name: "login_type", err: fmt.Errorf(`ent: validator failed for field "CpUser.login_type": %w`, err)}
}
}
if _u.mutation.SysuserCleared() && len(_u.mutation.SysuserIDs()) > 0 {
return errors.New(`ent: clearing a required unique edge "CpUser.sysuser"`)
}
@@ -1040,6 +1086,9 @@ func (_u *CpUserUpdateOne) sqlSave(ctx context.Context) (_node *CpUser, err erro
if value, ok := _u.mutation.Password(); ok {
_spec.SetField(cpuser.FieldPassword, field.TypeString, value)
}
if _u.mutation.PasswordCleared() {
_spec.ClearField(cpuser.FieldPassword, field.TypeString)
}
if value, ok := _u.mutation.Email(); ok {
_spec.SetField(cpuser.FieldEmail, field.TypeString, value)
}
@@ -1061,6 +1110,9 @@ func (_u *CpUserUpdateOne) sqlSave(ctx context.Context) (_node *CpUser, err erro
if value, ok := _u.mutation.MobileType(); ok {
_spec.SetField(cpuser.FieldMobileType, field.TypeEnum, value)
}
if value, ok := _u.mutation.LoginType(); ok {
_spec.SetField(cpuser.FieldLoginType, field.TypeEnum, value)
}
if _u.mutation.CptasksCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
+6
View File
@@ -190,6 +190,7 @@ var schemaGraph = func() *sqlgraph.Schema {
cpuser.FieldMobileCode: {Type: field.TypeString, Column: cpuser.FieldMobileCode},
cpuser.FieldCookie: {Type: field.TypeString, Column: cpuser.FieldCookie},
cpuser.FieldMobileType: {Type: field.TypeEnum, Column: cpuser.FieldMobileType},
cpuser.FieldLoginType: {Type: field.TypeEnum, Column: cpuser.FieldLoginType},
},
}
graph.Nodes[7] = &sqlgraph.Node{
@@ -1634,6 +1635,11 @@ func (f *CpUserFilter) WhereMobileType(p entql.StringP) {
f.Where(p.Field(cpuser.FieldMobileType))
}
// WhereLoginType applies the entql string predicate on the login_type field.
func (f *CpUserFilter) WhereLoginType(p entql.StringP) {
f.Where(p.Field(cpuser.FieldLoginType))
}
// WhereHasCptasks applies a predicate to check if query has an edge cptasks.
func (f *CpUserFilter) WhereHasCptasks() {
f.Where(entql.HasEdge("cptasks"))
File diff suppressed because one or more lines are too long
+5 -4
View File
@@ -16,7 +16,7 @@ var (
{Name: "update_time", Type: field.TypeString, SchemaType: map[string]string{"mysql": "datetime", "postgres": "timestamp", "sqlite3": "datetime"}},
{Name: "username", Type: field.TypeString},
{Name: "password", Type: field.TypeString},
{Name: "type", Type: field.TypeEnum, Enums: []string{"xxt"}},
{Name: "type", Type: field.TypeEnum, Enums: []string{"xxt", "smartedu_sc"}},
{Name: "course_name", Type: field.TypeString},
{Name: "course_id", Type: field.TypeString},
{Name: "need", Type: field.TypeEnum, Enums: []string{"task", "exam", "all"}},
@@ -201,11 +201,12 @@ var (
{Name: "create_time", Type: field.TypeString, SchemaType: map[string]string{"mysql": "datetime", "postgres": "timestamp", "sqlite3": "datetime"}},
{Name: "update_time", Type: field.TypeString, SchemaType: map[string]string{"mysql": "datetime", "postgres": "timestamp", "sqlite3": "datetime"}},
{Name: "username", Type: field.TypeString},
{Name: "password", Type: field.TypeString},
{Name: "password", Type: field.TypeString, Nullable: true},
{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: "login_type", Type: field.TypeEnum, Enums: []string{"account", "phone"}, Default: "account"},
{Name: "school_id", Type: field.TypeString},
{Name: "user_id", Type: field.TypeInt64},
}
@@ -217,13 +218,13 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "cp_users_cp_schools_cpusers",
Columns: []*schema.Column{CpUsersColumns[9]},
Columns: []*schema.Column{CpUsersColumns[10]},
RefColumns: []*schema.Column{CpSchoolsColumns[0]},
OnDelete: schema.NoAction,
},
{
Symbol: "cp_users_sys_user_cpuser",
Columns: []*schema.Column{CpUsersColumns[10]},
Columns: []*schema.Column{CpUsersColumns[11]},
RefColumns: []*schema.Column{SysUserColumns[0]},
OnDelete: schema.Cascade,
},
+74 -1
View File
@@ -6252,6 +6252,7 @@ type CpUserMutation struct {
mobile_code *string
cookie *string
mobile_type *cpuser.MobileType
login_type *cpuser.LoginType
clearedFields map[string]struct{}
cptasks map[int64]struct{}
removedcptasks map[int64]struct{}
@@ -6544,9 +6545,22 @@ func (m *CpUserMutation) OldPassword(ctx context.Context) (v string, err error)
return oldValue.Password, nil
}
// ClearPassword clears the value of the "password" field.
func (m *CpUserMutation) ClearPassword() {
m.password = nil
m.clearedFields[cpuser.FieldPassword] = struct{}{}
}
// PasswordCleared returns if the "password" field was cleared in this mutation.
func (m *CpUserMutation) PasswordCleared() bool {
_, ok := m.clearedFields[cpuser.FieldPassword]
return ok
}
// ResetPassword resets all changes to the "password" field.
func (m *CpUserMutation) ResetPassword() {
m.password = nil
delete(m.clearedFields, cpuser.FieldPassword)
}
// SetSchoolID sets the "school_id" field.
@@ -6768,6 +6782,42 @@ func (m *CpUserMutation) ResetMobileType() {
m.mobile_type = nil
}
// SetLoginType sets the "login_type" field.
func (m *CpUserMutation) SetLoginType(ct cpuser.LoginType) {
m.login_type = &ct
}
// LoginType returns the value of the "login_type" field in the mutation.
func (m *CpUserMutation) LoginType() (r cpuser.LoginType, exists bool) {
v := m.login_type
if v == nil {
return
}
return *v, true
}
// OldLoginType returns the old "login_type" 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) OldLoginType(ctx context.Context) (v cpuser.LoginType, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldLoginType is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldLoginType requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldLoginType: %w", err)
}
return oldValue.LoginType, nil
}
// ResetLoginType resets all changes to the "login_type" field.
func (m *CpUserMutation) ResetLoginType() {
m.login_type = nil
}
// AddCptaskIDs adds the "cptasks" edge to the CpTask entity by ids.
func (m *CpUserMutation) AddCptaskIDs(ids ...int64) {
if m.cptasks == nil {
@@ -7031,7 +7081,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, 10)
fields := make([]string, 0, 11)
if m.create_time != nil {
fields = append(fields, cpuser.FieldCreateTime)
}
@@ -7062,6 +7112,9 @@ func (m *CpUserMutation) Fields() []string {
if m.mobile_type != nil {
fields = append(fields, cpuser.FieldMobileType)
}
if m.login_type != nil {
fields = append(fields, cpuser.FieldLoginType)
}
return fields
}
@@ -7090,6 +7143,8 @@ func (m *CpUserMutation) Field(name string) (ent.Value, bool) {
return m.Cookie()
case cpuser.FieldMobileType:
return m.MobileType()
case cpuser.FieldLoginType:
return m.LoginType()
}
return nil, false
}
@@ -7119,6 +7174,8 @@ func (m *CpUserMutation) OldField(ctx context.Context, name string) (ent.Value,
return m.OldCookie(ctx)
case cpuser.FieldMobileType:
return m.OldMobileType(ctx)
case cpuser.FieldLoginType:
return m.OldLoginType(ctx)
}
return nil, fmt.Errorf("unknown CpUser field %s", name)
}
@@ -7198,6 +7255,13 @@ func (m *CpUserMutation) SetField(name string, value ent.Value) error {
}
m.SetMobileType(v)
return nil
case cpuser.FieldLoginType:
v, ok := value.(cpuser.LoginType)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetLoginType(v)
return nil
}
return fmt.Errorf("unknown CpUser field %s", name)
}
@@ -7231,6 +7295,9 @@ func (m *CpUserMutation) AddField(name string, value ent.Value) error {
// mutation.
func (m *CpUserMutation) ClearedFields() []string {
var fields []string
if m.FieldCleared(cpuser.FieldPassword) {
fields = append(fields, cpuser.FieldPassword)
}
if m.FieldCleared(cpuser.FieldEmail) {
fields = append(fields, cpuser.FieldEmail)
}
@@ -7254,6 +7321,9 @@ func (m *CpUserMutation) FieldCleared(name string) bool {
// error if the field is not defined in the schema.
func (m *CpUserMutation) ClearField(name string) error {
switch name {
case cpuser.FieldPassword:
m.ClearPassword()
return nil
case cpuser.FieldEmail:
m.ClearEmail()
return nil
@@ -7301,6 +7371,9 @@ func (m *CpUserMutation) ResetField(name string) error {
case cpuser.FieldMobileType:
m.ResetMobileType()
return nil
case cpuser.FieldLoginType:
m.ResetLoginType()
return nil
}
return fmt.Errorf("unknown CpUser field %s", name)
}
-4
View File
@@ -185,10 +185,6 @@ func init() {
cpuserDescUsername := cpuserFields[1].Descriptor()
// cpuser.UsernameValidator is a validator for the "username" field. It is called by the builders before save.
cpuser.UsernameValidator = cpuserDescUsername.Validators[0].(func(string) error)
// cpuserDescPassword is the schema descriptor for password field.
cpuserDescPassword := cpuserFields[2].Descriptor()
// cpuser.PasswordValidator is a validator for the "password" field. It is called by the builders before save.
cpuser.PasswordValidator = cpuserDescPassword.Validators[0].(func(string) error)
// cpuserDescSchoolID is the schema descriptor for school_id field.
cpuserDescSchoolID := cpuserFields[3].Descriptor()
// cpuser.SchoolIDValidator is a validator for the "school_id" field. It is called by the builders before save.
+3 -3
View File
@@ -20,7 +20,7 @@ func (CourseEntity) Fields() []ent.Field {
field.Int64("user_id"),
field.String("username"), // 账号
field.String("password").Sensitive(), //密码
field.Enum("type").Values("xxt"), // 类型:目前只有学习通
field.Enum("type").Values("xxt", "smartedu_sc"), // 类型:学习通、四川智慧教育
field.String("course_name"), // 课程名称
field.String("course_id"), // 课程ID
field.Enum("need").Values("task", "exam", "all"), // 需要刷的内容:任务点、考试、全部
@@ -29,8 +29,8 @@ func (CourseEntity) Fields() []ent.Field {
field.Int("success_count").Default(0), // 成功数量
field.Enum("exam_state").Values("not_start", "waiting", "doing", "done", "fail").Default("not_start"), // 考试状态
field.Enum("violence").Values("no", "yes").Default("no"), // 是否暴力刷课
field.Text("nodes").Optional(), // 所有节点信息(JSON格式)
field.Text("completed_nodes").Optional(), // 已完成的节点ID列表(JSON格式)
field.Text("nodes").Optional().Sensitive(), // 所有节点信息(JSON格式)
field.Text("completed_nodes").Optional().Sensitive(), // 已完成的节点ID列表(JSON格式)
field.String("desc").Optional(),
}
}
+2 -1
View File
@@ -19,12 +19,13 @@ func (CpUser) Fields() []ent.Field {
return []ent.Field{
field.Int64("user_id"),
field.String("username").NotEmpty(),
field.String("password").NotEmpty(),
field.String("password").Optional(),
field.String("school_id").NotEmpty(),
field.String("email").Optional(),
field.String("mobile_code").Optional(),
field.Text("cookie").Optional(),
field.Enum("mobile_type").Values("random", "authorize"),
field.Enum("login_type").Values("account", "phone").Default("account"),
}
}
@@ -1,175 +0,0 @@
// ENT数据库文件,请勿编辑。
package sysannouncement
import (
"fmt"
"likedream-goframe/internal/framework/ent2"
"entgo.io/ent"
"entgo.io/ent/dialect/sql"
"entgo.io/ent/dialect/sql/sqlgraph"
)
const (
// Label holds the string label denoting the sysannouncement type in the database.
Label = "sys_announcement"
// FieldID holds the string denoting the id field in the database.
FieldID = "id"
// FieldCreateTime holds the string denoting the create_time field in the database.
FieldCreateTime = "create_time"
// FieldUpdateTime holds the string denoting the update_time field in the database.
FieldUpdateTime = "update_time"
// FieldTitle holds the string denoting the title field in the database.
FieldTitle = "title"
// FieldContent holds the string denoting the content field in the database.
FieldContent = "content"
// FieldStatus holds the string denoting the status field in the database.
FieldStatus = "status"
// FieldPinned holds the string denoting the pinned field in the database.
FieldPinned = "pinned"
// FieldOrder holds the string denoting the order field in the database.
FieldOrder = "order"
// FieldCreatorID holds the string denoting the creator_id field in the database.
FieldCreatorID = "creator_id"
// EdgeCreator holds the string denoting the creator edge name in mutations.
EdgeCreator = "creator"
// Table holds the table name of the sysannouncement in the database.
Table = "sys_announcement"
// CreatorTable is the table that holds the creator relation/edge.
CreatorTable = "sys_announcement"
// CreatorInverseTable is the table name for the SysUser entity.
// It exists in this package in order to avoid circular dependency with the "sysuser" package.
CreatorInverseTable = "sys_user"
// CreatorColumn is the table column denoting the creator relation/edge.
CreatorColumn = "creator_id"
)
// Columns holds all SQL columns for sysannouncement fields.
var Columns = []string{
FieldID,
FieldCreateTime,
FieldUpdateTime,
FieldTitle,
FieldContent,
FieldStatus,
FieldPinned,
FieldOrder,
FieldCreatorID,
}
// ValidColumn reports if the column name is valid (part of the table columns).
func ValidColumn(column string) bool {
for i := range Columns {
if column == Columns[i] {
return true
}
}
return false
}
// Note that the variables below are initialized by the runtime
// package on the initialization of the application. Therefore,
// it should be imported in the main as follows:
//
// import _ "likedream-goframe/internal/ent/runtime"
var (
Hooks [1]ent.Hook
// DefaultCreateTime holds the default value on creation for the "create_time" field.
DefaultCreateTime func() *ent2.CustomTime
// DefaultUpdateTime holds the default value on creation for the "update_time" field.
DefaultUpdateTime func() *ent2.CustomTime
// DefaultPinned holds the default value on creation for the "pinned" field.
DefaultPinned bool
// DefaultOrder holds the default value on creation for the "order" field.
DefaultOrder uint
)
// Status defines the type for the "status" enum field.
type Status string
// StatusDraft is the default value of the Status enum.
const DefaultStatus = StatusDraft
// Status values.
const (
StatusDraft Status = "draft"
StatusPublished Status = "published"
StatusArchived Status = "archived"
)
func (s Status) String() string {
return string(s)
}
// StatusValidator is a validator for the "status" field enum values. It is called by the builders before save.
func StatusValidator(s Status) error {
switch s {
case StatusDraft, StatusPublished, StatusArchived:
return nil
default:
return fmt.Errorf("sysannouncement: invalid enum value for status field: %q", s)
}
}
// OrderOption defines the ordering options for the SysAnnouncement queries.
type OrderOption func(*sql.Selector)
// ByID orders the results by the id field.
func ByID(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldID, opts...).ToFunc()
}
// ByCreateTime orders the results by the create_time field.
func ByCreateTime(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldCreateTime, opts...).ToFunc()
}
// ByUpdateTime orders the results by the update_time field.
func ByUpdateTime(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldUpdateTime, opts...).ToFunc()
}
// ByTitle orders the results by the title field.
func ByTitle(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldTitle, opts...).ToFunc()
}
// ByContent orders the results by the content field.
func ByContent(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldContent, opts...).ToFunc()
}
// ByStatus orders the results by the status field.
func ByStatus(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldStatus, opts...).ToFunc()
}
// ByPinned orders the results by the pinned field.
func ByPinned(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldPinned, opts...).ToFunc()
}
// ByOrder orders the results by the order field.
func ByOrder(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldOrder, opts...).ToFunc()
}
// ByCreatorID orders the results by the creator_id field.
func ByCreatorID(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldCreatorID, opts...).ToFunc()
}
// ByCreatorField orders the results by creator field.
func ByCreatorField(field string, opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
sqlgraph.OrderByNeighborTerms(s, newCreatorStep(), sql.OrderByField(field, opts...))
}
}
func newCreatorStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.To(CreatorInverseTable, FieldID),
sqlgraph.Edge(sqlgraph.M2O, true, CreatorTable, CreatorColumn),
)
}
-496
View File
@@ -1,496 +0,0 @@
// ENT数据库文件,请勿编辑。
package sysannouncement
import (
"likedream-goframe/internal/ent/internal"
"likedream-goframe/internal/ent/predicate"
"likedream-goframe/internal/framework/ent2"
"entgo.io/ent/dialect/sql"
"entgo.io/ent/dialect/sql/sqlgraph"
)
// ID filters vertices based on their ID field.
func ID(id int64) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldEQ(FieldID, id))
}
// IDEQ applies the EQ predicate on the ID field.
func IDEQ(id int64) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldEQ(FieldID, id))
}
// IDNEQ applies the NEQ predicate on the ID field.
func IDNEQ(id int64) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldNEQ(FieldID, id))
}
// IDIn applies the In predicate on the ID field.
func IDIn(ids ...int64) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldIn(FieldID, ids...))
}
// IDNotIn applies the NotIn predicate on the ID field.
func IDNotIn(ids ...int64) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldNotIn(FieldID, ids...))
}
// IDGT applies the GT predicate on the ID field.
func IDGT(id int64) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldGT(FieldID, id))
}
// IDGTE applies the GTE predicate on the ID field.
func IDGTE(id int64) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldGTE(FieldID, id))
}
// IDLT applies the LT predicate on the ID field.
func IDLT(id int64) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldLT(FieldID, id))
}
// IDLTE applies the LTE predicate on the ID field.
func IDLTE(id int64) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldLTE(FieldID, id))
}
// CreateTime applies equality check predicate on the "create_time" field. It's identical to CreateTimeEQ.
func CreateTime(v *ent2.CustomTime) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldEQ(FieldCreateTime, v))
}
// UpdateTime applies equality check predicate on the "update_time" field. It's identical to UpdateTimeEQ.
func UpdateTime(v *ent2.CustomTime) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldEQ(FieldUpdateTime, v))
}
// Title applies equality check predicate on the "title" field. It's identical to TitleEQ.
func Title(v string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldEQ(FieldTitle, v))
}
// Content applies equality check predicate on the "content" field. It's identical to ContentEQ.
func Content(v string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldEQ(FieldContent, v))
}
// Pinned applies equality check predicate on the "pinned" field. It's identical to PinnedEQ.
func Pinned(v bool) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldEQ(FieldPinned, v))
}
// Order applies equality check predicate on the "order" field. It's identical to OrderEQ.
func Order(v uint) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldEQ(FieldOrder, v))
}
// CreatorID applies equality check predicate on the "creator_id" field. It's identical to CreatorIDEQ.
func CreatorID(v int64) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldEQ(FieldCreatorID, v))
}
// CreateTimeEQ applies the EQ predicate on the "create_time" field.
func CreateTimeEQ(v *ent2.CustomTime) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldEQ(FieldCreateTime, v))
}
// CreateTimeNEQ applies the NEQ predicate on the "create_time" field.
func CreateTimeNEQ(v *ent2.CustomTime) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldNEQ(FieldCreateTime, v))
}
// CreateTimeIn applies the In predicate on the "create_time" field.
func CreateTimeIn(vs ...*ent2.CustomTime) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldIn(FieldCreateTime, vs...))
}
// CreateTimeNotIn applies the NotIn predicate on the "create_time" field.
func CreateTimeNotIn(vs ...*ent2.CustomTime) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldNotIn(FieldCreateTime, vs...))
}
// CreateTimeGT applies the GT predicate on the "create_time" field.
func CreateTimeGT(v *ent2.CustomTime) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldGT(FieldCreateTime, v))
}
// CreateTimeGTE applies the GTE predicate on the "create_time" field.
func CreateTimeGTE(v *ent2.CustomTime) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldGTE(FieldCreateTime, v))
}
// CreateTimeLT applies the LT predicate on the "create_time" field.
func CreateTimeLT(v *ent2.CustomTime) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldLT(FieldCreateTime, v))
}
// CreateTimeLTE applies the LTE predicate on the "create_time" field.
func CreateTimeLTE(v *ent2.CustomTime) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldLTE(FieldCreateTime, v))
}
// CreateTimeContains applies the Contains predicate on the "create_time" field.
func CreateTimeContains(v *ent2.CustomTime) predicate.SysAnnouncement {
vc := v.String()
return predicate.SysAnnouncement(sql.FieldContains(FieldCreateTime, vc))
}
// CreateTimeHasPrefix applies the HasPrefix predicate on the "create_time" field.
func CreateTimeHasPrefix(v *ent2.CustomTime) predicate.SysAnnouncement {
vc := v.String()
return predicate.SysAnnouncement(sql.FieldHasPrefix(FieldCreateTime, vc))
}
// CreateTimeHasSuffix applies the HasSuffix predicate on the "create_time" field.
func CreateTimeHasSuffix(v *ent2.CustomTime) predicate.SysAnnouncement {
vc := v.String()
return predicate.SysAnnouncement(sql.FieldHasSuffix(FieldCreateTime, vc))
}
// CreateTimeEqualFold applies the EqualFold predicate on the "create_time" field.
func CreateTimeEqualFold(v *ent2.CustomTime) predicate.SysAnnouncement {
vc := v.String()
return predicate.SysAnnouncement(sql.FieldEqualFold(FieldCreateTime, vc))
}
// CreateTimeContainsFold applies the ContainsFold predicate on the "create_time" field.
func CreateTimeContainsFold(v *ent2.CustomTime) predicate.SysAnnouncement {
vc := v.String()
return predicate.SysAnnouncement(sql.FieldContainsFold(FieldCreateTime, vc))
}
// UpdateTimeEQ applies the EQ predicate on the "update_time" field.
func UpdateTimeEQ(v *ent2.CustomTime) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldEQ(FieldUpdateTime, v))
}
// UpdateTimeNEQ applies the NEQ predicate on the "update_time" field.
func UpdateTimeNEQ(v *ent2.CustomTime) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldNEQ(FieldUpdateTime, v))
}
// UpdateTimeIn applies the In predicate on the "update_time" field.
func UpdateTimeIn(vs ...*ent2.CustomTime) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldIn(FieldUpdateTime, vs...))
}
// UpdateTimeNotIn applies the NotIn predicate on the "update_time" field.
func UpdateTimeNotIn(vs ...*ent2.CustomTime) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldNotIn(FieldUpdateTime, vs...))
}
// UpdateTimeGT applies the GT predicate on the "update_time" field.
func UpdateTimeGT(v *ent2.CustomTime) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldGT(FieldUpdateTime, v))
}
// UpdateTimeGTE applies the GTE predicate on the "update_time" field.
func UpdateTimeGTE(v *ent2.CustomTime) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldGTE(FieldUpdateTime, v))
}
// UpdateTimeLT applies the LT predicate on the "update_time" field.
func UpdateTimeLT(v *ent2.CustomTime) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldLT(FieldUpdateTime, v))
}
// UpdateTimeLTE applies the LTE predicate on the "update_time" field.
func UpdateTimeLTE(v *ent2.CustomTime) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldLTE(FieldUpdateTime, v))
}
// UpdateTimeContains applies the Contains predicate on the "update_time" field.
func UpdateTimeContains(v *ent2.CustomTime) predicate.SysAnnouncement {
vc := v.String()
return predicate.SysAnnouncement(sql.FieldContains(FieldUpdateTime, vc))
}
// UpdateTimeHasPrefix applies the HasPrefix predicate on the "update_time" field.
func UpdateTimeHasPrefix(v *ent2.CustomTime) predicate.SysAnnouncement {
vc := v.String()
return predicate.SysAnnouncement(sql.FieldHasPrefix(FieldUpdateTime, vc))
}
// UpdateTimeHasSuffix applies the HasSuffix predicate on the "update_time" field.
func UpdateTimeHasSuffix(v *ent2.CustomTime) predicate.SysAnnouncement {
vc := v.String()
return predicate.SysAnnouncement(sql.FieldHasSuffix(FieldUpdateTime, vc))
}
// UpdateTimeEqualFold applies the EqualFold predicate on the "update_time" field.
func UpdateTimeEqualFold(v *ent2.CustomTime) predicate.SysAnnouncement {
vc := v.String()
return predicate.SysAnnouncement(sql.FieldEqualFold(FieldUpdateTime, vc))
}
// UpdateTimeContainsFold applies the ContainsFold predicate on the "update_time" field.
func UpdateTimeContainsFold(v *ent2.CustomTime) predicate.SysAnnouncement {
vc := v.String()
return predicate.SysAnnouncement(sql.FieldContainsFold(FieldUpdateTime, vc))
}
// TitleEQ applies the EQ predicate on the "title" field.
func TitleEQ(v string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldEQ(FieldTitle, v))
}
// TitleNEQ applies the NEQ predicate on the "title" field.
func TitleNEQ(v string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldNEQ(FieldTitle, v))
}
// TitleIn applies the In predicate on the "title" field.
func TitleIn(vs ...string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldIn(FieldTitle, vs...))
}
// TitleNotIn applies the NotIn predicate on the "title" field.
func TitleNotIn(vs ...string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldNotIn(FieldTitle, vs...))
}
// TitleGT applies the GT predicate on the "title" field.
func TitleGT(v string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldGT(FieldTitle, v))
}
// TitleGTE applies the GTE predicate on the "title" field.
func TitleGTE(v string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldGTE(FieldTitle, v))
}
// TitleLT applies the LT predicate on the "title" field.
func TitleLT(v string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldLT(FieldTitle, v))
}
// TitleLTE applies the LTE predicate on the "title" field.
func TitleLTE(v string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldLTE(FieldTitle, v))
}
// TitleContains applies the Contains predicate on the "title" field.
func TitleContains(v string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldContains(FieldTitle, v))
}
// TitleHasPrefix applies the HasPrefix predicate on the "title" field.
func TitleHasPrefix(v string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldHasPrefix(FieldTitle, v))
}
// TitleHasSuffix applies the HasSuffix predicate on the "title" field.
func TitleHasSuffix(v string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldHasSuffix(FieldTitle, v))
}
// TitleEqualFold applies the EqualFold predicate on the "title" field.
func TitleEqualFold(v string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldEqualFold(FieldTitle, v))
}
// TitleContainsFold applies the ContainsFold predicate on the "title" field.
func TitleContainsFold(v string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldContainsFold(FieldTitle, v))
}
// ContentEQ applies the EQ predicate on the "content" field.
func ContentEQ(v string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldEQ(FieldContent, v))
}
// ContentNEQ applies the NEQ predicate on the "content" field.
func ContentNEQ(v string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldNEQ(FieldContent, v))
}
// ContentIn applies the In predicate on the "content" field.
func ContentIn(vs ...string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldIn(FieldContent, vs...))
}
// ContentNotIn applies the NotIn predicate on the "content" field.
func ContentNotIn(vs ...string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldNotIn(FieldContent, vs...))
}
// ContentGT applies the GT predicate on the "content" field.
func ContentGT(v string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldGT(FieldContent, v))
}
// ContentGTE applies the GTE predicate on the "content" field.
func ContentGTE(v string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldGTE(FieldContent, v))
}
// ContentLT applies the LT predicate on the "content" field.
func ContentLT(v string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldLT(FieldContent, v))
}
// ContentLTE applies the LTE predicate on the "content" field.
func ContentLTE(v string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldLTE(FieldContent, v))
}
// ContentContains applies the Contains predicate on the "content" field.
func ContentContains(v string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldContains(FieldContent, v))
}
// ContentHasPrefix applies the HasPrefix predicate on the "content" field.
func ContentHasPrefix(v string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldHasPrefix(FieldContent, v))
}
// ContentHasSuffix applies the HasSuffix predicate on the "content" field.
func ContentHasSuffix(v string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldHasSuffix(FieldContent, v))
}
// ContentEqualFold applies the EqualFold predicate on the "content" field.
func ContentEqualFold(v string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldEqualFold(FieldContent, v))
}
// ContentContainsFold applies the ContainsFold predicate on the "content" field.
func ContentContainsFold(v string) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldContainsFold(FieldContent, v))
}
// StatusEQ applies the EQ predicate on the "status" field.
func StatusEQ(v Status) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldEQ(FieldStatus, v))
}
// StatusNEQ applies the NEQ predicate on the "status" field.
func StatusNEQ(v Status) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldNEQ(FieldStatus, v))
}
// StatusIn applies the In predicate on the "status" field.
func StatusIn(vs ...Status) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldIn(FieldStatus, vs...))
}
// StatusNotIn applies the NotIn predicate on the "status" field.
func StatusNotIn(vs ...Status) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldNotIn(FieldStatus, vs...))
}
// PinnedEQ applies the EQ predicate on the "pinned" field.
func PinnedEQ(v bool) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldEQ(FieldPinned, v))
}
// PinnedNEQ applies the NEQ predicate on the "pinned" field.
func PinnedNEQ(v bool) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldNEQ(FieldPinned, v))
}
// OrderEQ applies the EQ predicate on the "order" field.
func OrderEQ(v uint) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldEQ(FieldOrder, v))
}
// OrderNEQ applies the NEQ predicate on the "order" field.
func OrderNEQ(v uint) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldNEQ(FieldOrder, v))
}
// OrderIn applies the In predicate on the "order" field.
func OrderIn(vs ...uint) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldIn(FieldOrder, vs...))
}
// OrderNotIn applies the NotIn predicate on the "order" field.
func OrderNotIn(vs ...uint) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldNotIn(FieldOrder, vs...))
}
// OrderGT applies the GT predicate on the "order" field.
func OrderGT(v uint) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldGT(FieldOrder, v))
}
// OrderGTE applies the GTE predicate on the "order" field.
func OrderGTE(v uint) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldGTE(FieldOrder, v))
}
// OrderLT applies the LT predicate on the "order" field.
func OrderLT(v uint) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldLT(FieldOrder, v))
}
// OrderLTE applies the LTE predicate on the "order" field.
func OrderLTE(v uint) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldLTE(FieldOrder, v))
}
// CreatorIDEQ applies the EQ predicate on the "creator_id" field.
func CreatorIDEQ(v int64) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldEQ(FieldCreatorID, v))
}
// CreatorIDNEQ applies the NEQ predicate on the "creator_id" field.
func CreatorIDNEQ(v int64) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldNEQ(FieldCreatorID, v))
}
// CreatorIDIn applies the In predicate on the "creator_id" field.
func CreatorIDIn(vs ...int64) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldIn(FieldCreatorID, vs...))
}
// CreatorIDNotIn applies the NotIn predicate on the "creator_id" field.
func CreatorIDNotIn(vs ...int64) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.FieldNotIn(FieldCreatorID, vs...))
}
// HasCreator applies the HasEdge predicate on the "creator" edge.
func HasCreator() predicate.SysAnnouncement {
return predicate.SysAnnouncement(func(s *sql.Selector) {
step := sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.Edge(sqlgraph.M2O, true, CreatorTable, CreatorColumn),
)
schemaConfig := internal.SchemaConfigFromContext(s.Context())
step.To.Schema = schemaConfig.SysUser
step.Edge.Schema = schemaConfig.SysAnnouncement
sqlgraph.HasNeighbors(s, step)
})
}
// HasCreatorWith applies the HasEdge predicate on the "creator" edge with a given conditions (other predicates).
func HasCreatorWith(preds ...predicate.SysUser) predicate.SysAnnouncement {
return predicate.SysAnnouncement(func(s *sql.Selector) {
step := newCreatorStep()
schemaConfig := internal.SchemaConfigFromContext(s.Context())
step.To.Schema = schemaConfig.SysUser
step.Edge.Schema = schemaConfig.SysAnnouncement
sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
for _, p := range preds {
p(s)
}
})
})
}
// And groups predicates with the AND operator between them.
func And(predicates ...predicate.SysAnnouncement) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.AndPredicates(predicates...))
}
// Or groups predicates with the OR operator between them.
func Or(predicates ...predicate.SysAnnouncement) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.OrPredicates(predicates...))
}
// Not applies the not operator on the given predicate.
func Not(p predicate.SysAnnouncement) predicate.SysAnnouncement {
return predicate.SysAnnouncement(sql.NotPredicates(p))
}
+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
}
+19 -2
View File
@@ -1,18 +1,35 @@
package logger
import (
"go.uber.org/zap"
"log"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var Logger *zap.Logger
func init() {
logger, err := zap.NewProduction()
// Create a production config and customize the time encoder
config := zap.NewProductionConfig()
if IsDebug() {
config.Level = zap.NewAtomicLevelAt(zap.DebugLevel) // Enable debug logs
}
config.EncoderConfig.EncodeTime = BeijingTimeEncoder
// Build the logger with the custom config
logger, err := config.Build()
if err != nil {
log.Fatalf("cannot init zap logger: %+v", err)
}
Logger = logger
}
// BeijingTimeEncoder formats time in Beijing timezone
func BeijingTimeEncoder(t time.Time, enc zapcore.PrimitiveArrayEncoder) {
// Set timezone to Beijing (UTC+8)
beijingLoc, _ := time.LoadLocation("Asia/Shanghai")
enc.AppendString(t.In(beijingLoc).Format("2006-01-02 15:04:05"))
}
+18 -13
View File
@@ -40,19 +40,15 @@ func (c *CpdailyTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task)
e := gob.NewDecoder(bytes.NewReader(task.Payload())).Decode(&taskPayload)
utils.OkOrPanic(e, oops.WithContext(ctx).With("payload", task.Payload(), "type", task.Type()).In("CpdailyTask.DecodePayload").Wrap)
// 查询指定的任务和关联今日校园用户
targetTask, e := db.Global.CpTask.Query().Where(cptask.ID(taskPayload.CpTaskID)).
WithCpUser().
WithSysUser().
First(ctx)
if ent.IsNotFound(e) {
utils.OkOrPanic(oops.WithContext(ctx).With("task_id", taskPayload.CpTaskID).In("CpdailyTask.ProcessTask").Wrap(e))
}
utils.OkOrPanic(e, oops.WithContext(ctx).With("task_id", taskPayload.CpTaskID).In("CpdailyTask.ProcessTask").Wrap)
db.Global.CpTask.UpdateOneID(taskPayload.CpTaskID).SetTaskStatus(cptask.TaskStatusDoing).ExecX(ctx)
// 先注册错误处理,确保任何阶段的错误都能被捕获并重置状态
var targetTask *ent.CpTask
defer utils.HandleError(func(err error) {
// 如果 targetTask 还没查询到,只能重置状态为 not_start,让任务重新入队
if targetTask == nil {
db.Global.CpTask.UpdateOneID(taskPayload.CpTaskID).SetTaskStatus(cptask.TaskStatusNotStart).Exec(ctx)
panic(err)
}
// 执行失败应该更新下次执行时间
executeTime := utils.GetCpdailyNextExecuteTime(targetTask)
e := db.Global.CpTask.UpdateOneID(taskPayload.CpTaskID).SetNextExecuteTime(executeTime).SetTaskStatus(cptask.TaskStatusFail).Exec(ctx)
@@ -90,11 +86,20 @@ func (c *CpdailyTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task)
panic(err)
})
// 查询指定的任务和关联今日校园用户
targetTask, e = db.Global.CpTask.Query().Where(cptask.ID(taskPayload.CpTaskID)).
WithCpUser().
WithSysUser().
First(ctx)
utils.OkOrPanic(e, oops.WithContext(ctx).With("task_id", taskPayload.CpTaskID).In("CpdailyTask.ProcessTask").Wrap)
db.Global.CpTask.UpdateOneID(taskPayload.CpTaskID).SetTaskStatus(cptask.TaskStatusDoing).ExecX(ctx)
// 查询学校详情
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) {
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()
+14 -3
View File
@@ -7,6 +7,7 @@ import (
"likedream-goframe/internal/framework/logger"
"likedream-goframe/internal/framework/model/email"
"likedream-goframe/internal/utils"
"time"
"github.com/hibiken/asynq"
"github.com/samber/oops"
@@ -19,11 +20,18 @@ 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))
defer func() {
if r := recover(); r != nil {
// 打印日志
logger.Logger.Error("邮件发送失败", zap.String("邮箱", payload.To), zap.Error(r.(error)))
// 重新抛出相同的 panic
panic(r)
}
}()
message := mail.NewMsg()
@@ -37,7 +45,10 @@ func (h *EmailTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task) (e
message.SetBodyString(mail.TypeTextHTML, payload.Body)
client, e := mail.NewClient(payload.SMTPHost, mail.WithPort(payload.SMTPPort), mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover),
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)
+68 -1
View File
@@ -2,21 +2,85 @@ package schedule
import (
"context"
cst2 "likedream-goframe/internal/common/cst"
"likedream-goframe/internal/common/db"
"likedream-goframe/internal/ent/cptask"
"likedream-goframe/internal/framework/queue"
"likedream-goframe/internal/utils"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
"github.com/hibiken/asynq"
)
func Init(ctx context.Context) {
queue.InitClient(ctx)
initServer(ctx)
initInspector(ctx)
cleanupStuckTasks(ctx) // 清理卡住的任务
initScanner()
initPeriodicTasks(ctx)
}
// cleanupStuckTasks 清理服务重启时卡在 queue 或 doing 状态的任务
// 检查 asynq 队列中是否有 archived 的任务,如果有则删除并重置数据库状态
func cleanupStuckTasks(ctx context.Context) {
// 获取所有状态为 queue 或 doing 的任务
stuckTasks, err := db.Global.CpTask.Query().
Where(
cptask.Or(
cptask.TaskStatusEQ(cptask.TaskStatusQueue),
cptask.TaskStatusEQ(cptask.TaskStatusDoing),
),
).
All(ctx)
if err != nil {
g.Log().Warningf(ctx, "查询卡住的任务失败: %v", err)
return
}
if len(stuckTasks) == 0 {
return
}
// 检查 archived 队列中的任务数量
archivedTasks, err := globalInspector.ListArchivedTasks(cst2.TaskQueueCpdaily, asynq.PageSize(10000))
if err != nil {
g.Log().Warningf(ctx, "获取 archived 任务列表失败: %v", err)
}
// 删除所有 archived 的任务,让它们可以重新入队
for _, task := range archivedTasks {
if err := globalInspector.DeleteTask(cst2.TaskQueueCpdaily, task.ID); err != nil {
g.Log().Debugf(ctx, "删除 archived 任务失败: %v", err)
}
}
if len(archivedTasks) > 0 {
g.Log().Infof(ctx, "已清理 %d 个 archived 状态的 asynq 任务", len(archivedTasks))
}
// 重置数据库中卡住的任务状态
affected, err := db.Global.CpTask.Update().
Where(
cptask.Or(
cptask.TaskStatusEQ(cptask.TaskStatusQueue),
),
).
SetTaskStatus(cptask.TaskStatusNotStart).
Save(ctx)
if err != nil {
g.Log().Warningf(ctx, "重置卡住的任务失败: %v", err)
return
}
if affected > 0 {
g.Log().Infof(ctx, "已重置 %d 个卡住的任务状态为 not_start", affected)
}
}
func Stop() {
globalScanner.StopScan()
g.Log().Info(gctx.GetInitCtx(), "task scanner stopped")
@@ -30,7 +94,10 @@ func Stop() {
g.Log().Info(gctx.GetInitCtx(), "asynq client closed")
globalServer.Shutdown()
g.Log().Info(gctx.GetInitCtx(), "asynq server stopped")
g.Log().Info(gctx.GetInitCtx(), "asynq main server stopped")
auxiliaryServer.Shutdown()
g.Log().Info(gctx.GetInitCtx(), "asynq auxiliary server stopped")
e = globalInspector.Close()
utils.OkOrPanic(e)
+97 -3
View File
@@ -8,6 +8,7 @@ import (
cst2 "likedream-goframe/internal/common/cst"
"likedream-goframe/internal/common/db"
"likedream-goframe/internal/ent"
"likedream-goframe/internal/ent/courseentity"
"likedream-goframe/internal/ent/cptask"
"likedream-goframe/internal/framework/queue"
"likedream-goframe/internal/utils"
@@ -26,6 +27,20 @@ import (
var globalScanner *TaskScanner
func initScanner() {
ctx := gctx.GetInitCtx()
// 应用启动时,将所有 StatusDoing(执行中)状态的学习通任务重置为 StatusWaiting
// 这样可以确保应用重启后,之前正在执行的任务能够继续执行
count, err := db.Global.CourseEntity.Update().
Where(courseentity.StatusEQ(courseentity.StatusDoing)).
SetStatus(courseentity.StatusWaiting).
Save(ctx)
if err != nil {
g.Log().Warningf(ctx, "重置执行中的学习通任务失败: %v", err)
} else if count > 0 {
g.Log().Infof(ctx, "应用启动时重置了 %d 个执行中的学习通任务为等待状态", count)
}
globalScanner = NewTaskScanner()
go globalScanner.StartScan()
}
@@ -65,6 +80,7 @@ func (ts *TaskScanner) StartScan() {
}
ts.scanCpdailyTask()
ts.scanXuexitongTask()
}
}
@@ -121,12 +137,16 @@ func (ts *TaskScanner) scanCpdailyTask() {
year, month, day := currentTime.Date()
execTime := time.Date(year, month, day, *task.ExecuteHour, *task.ExecuteMinute, 0, 0, cst2.TimeZoneShangHai)
// 添加随机延迟(0-30秒),分散同一时间点的任务执行,避免瞬间占满所有 worker
randomDelay := time.Duration(task.ID%30) * time.Second
execTime = execTime.Add(randomDelay)
e = utils.Catch(func() {
queue.AddTask(ts.ctx, asynq.NewTask(cst2.TaskCpdaily, payloadWriter.Bytes()),
asynq.Unique(5*time.Minute),
asynq.Unique(2*time.Minute),
asynq.MaxRetry(4),
asynq.ProcessAt(execTime),
asynq.Timeout(10*time.Minute),
asynq.Timeout(5*time.Minute),
asynq.Queue(cst2.TaskQueueCpdaily),
)
// 更新未开始的任务状态为队列中
@@ -134,7 +154,8 @@ func (ts *TaskScanner) scanCpdailyTask() {
})
if e != nil {
if errors.Is(e, asynq.ErrDuplicateTask) {
// 任务已在队列中,不更新下次执行时间,等任务执行完成后再处理
// 任务已在队列中,确保数据库状态也是 queue,然后跳过
db.Global.CpTask.Update().Where(cptask.ID(task.ID)).SetTaskStatus(cptask.TaskStatusQueue).Exec(ts.ctx)
g.Log().Debugf(ts.ctx, "任务ID %d 已在队列中,跳过本次扫描", task.ID)
failedIDS = append(failedIDS, task.ID)
continue
@@ -186,3 +207,76 @@ func (ts *TaskScanner) scanCpdailyTask() {
utils.LogError(ts.ctx, "重置过期任务下次执行时间失败", oops.With("task_id", task.ID, "next_execute_at", nextExecuteAt).Wrap(e))
}
}
// scanXuexitongTask 扫描学习通刷课任务
func (ts *TaskScanner) scanXuexitongTask() {
// 查询所有状态为not_start或waiting的课程
allCourses := db.Global.CourseEntity.Query().
Where(
courseentity.Or(
courseentity.StatusEQ(courseentity.StatusNotStart),
courseentity.StatusEQ(courseentity.StatusWaiting),
),
).
AllX(ts.ctx)
if len(allCourses) == 0 {
return
}
// 按暴力模式区分任务
for _, course := range allCourses {
// 检查是否暂停
if course.Status == courseentity.StatusPause {
continue
}
payload := &XuexitongTaskPayload{
CourseID: course.ID,
}
payloadWriter := bytes.NewBuffer(nil)
e := gob.NewEncoder(payloadWriter).Encode(payload)
utils.OkOrPanic(e, oops.WithContext(ts.ctx).With("payload", payload).In("XuexitongTaskScanner.scanXuexitongTask").Wrap)
// 根据violence字段选择队列和任务类型
var queueName string
var taskType string
if course.Violence == courseentity.ViolenceYes {
queueName = cst2.TaskQueueXuexitongVio
taskType = cst2.TaskXuexitongVio
} else {
queueName = cst2.TaskQueueXuexitong
taskType = cst2.TaskXuexitong
}
e = utils.Catch(func() {
queue.AddTask(ts.ctx, asynq.NewTask(taskType, payloadWriter.Bytes()),
//asynq.Unique(30*time.Minute), // 30分钟内去重
asynq.MaxRetry(3),
asynq.Timeout(2*time.Hour), // 刷课可能需要较长时间
asynq.Queue(queueName),
)
// 更新状态为等待中
db.Global.CourseEntity.Update().
Where(courseentity.ID(course.ID), courseentity.StatusEQ(courseentity.StatusNotStart)).
SetStatus(courseentity.StatusWaiting).
Exec(ts.ctx)
})
if e != nil {
if errors.Is(e, asynq.ErrDuplicateTask) {
// 任务已在队列中,跳过
g.Log().Debugf(ts.ctx, "学习通课程ID %d 已在队列中,跳过本次扫描", course.ID)
continue
} else if errors.Is(e, context.Canceled) {
return
} else {
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)
}
}
}
+39 -12
View File
@@ -12,13 +12,19 @@ import (
"github.com/hibiken/asynq"
)
var globalServer *asynq.Server
var (
// 主任务服务器:处理 cpdaily、xuexitong 等业务任务
globalServer *asynq.Server
// 辅助任务服务器:处理 email、reset 等轻量级任务
auxiliaryServer *asynq.Server
)
func initServer(ctx context.Context) {
cfg := g.Cfg()
taskConcurrency := cfg.MustGetWithEnv(ctx, cst2.ConfigTaskConcurrency).Int()
// 主服务器:专门处理业务任务,使用大部分 worker
globalServer = asynq.NewServerFromRedisClient(redis.GetClient(), asynq.Config{
Concurrency: taskConcurrency,
BaseContext: gctx.GetInitCtx,
@@ -28,24 +34,45 @@ func initServer(ctx context.Context) {
},
DelayedTaskCheckInterval: 2 * time.Second,
ErrorHandler: taskErrorHandler{},
// 只处理业务队列,使用严格优先级模式
StrictPriority: true,
Queues: map[string]int{
cst2.TaskQueueEmail: 2,
cst2.TaskQueueCpdaily: 10,
cst2.TaskQueueXuexitong: 5,
cst2.TaskQueueCpdaily: 10, // 最高优先级
cst2.TaskQueueXuexitongVio: 8,
cst2.TaskQueueDefault: 1,
cst2.TaskQueueXuexitong: 5,
},
})
mux := asynq.NewServeMux()
// 辅助服务器:专门处理邮件等轻量级任务,独立的 worker 池
auxiliaryServer = asynq.NewServerFromRedisClient(redis.GetClient(), asynq.Config{
Concurrency: 5, // 邮件任务不需要太多并发
BaseContext: gctx.GetInitCtx,
TaskCheckInterval: 10 * time.Second,
RetryDelayFunc: func(n int, _ error, _ *asynq.Task) time.Duration {
return time.Duration(n) * 2 * time.Second
},
DelayedTaskCheckInterval: 2 * time.Second,
Queues: map[string]int{
cst2.TaskQueueEmail: 2,
cst2.TaskQueueDefault: 1,
},
})
mux.Handle(cst2.TaskCpdaily, &CpdailyTaskHandler{})
mux.Handle(cst2.TaskEmail, &EmailTaskHandler{})
mux.Handle(cst2.TaskResetDaily, &ResetTaskHandler{})
mux.Handle(cst2.TaskXuexitong, &XuexitongTaskHandler{})
mux.Handle(cst2.TaskXuexitongVio, &XuexitongTaskHandler{})
// 主服务器的任务处理器
mainMux := asynq.NewServeMux()
mainMux.Handle(cst2.TaskCpdaily, &CpdailyTaskHandler{})
mainMux.Handle(cst2.TaskXuexitong, &XuexitongTaskHandler{})
mainMux.Handle(cst2.TaskXuexitongVio, &XuexitongTaskHandler{})
e := globalServer.Start(mux)
// 辅助服务器的任务处理器
auxMux := asynq.NewServeMux()
auxMux.Handle(cst2.TaskEmail, &EmailTaskHandler{})
auxMux.Handle(cst2.TaskResetDaily, &ResetTaskHandler{})
e := globalServer.Start(mainMux)
utils.OkOrPanic(e)
e = auxiliaryServer.Start(auxMux)
utils.OkOrPanic(e)
}
+134 -19
View File
@@ -4,20 +4,44 @@ import (
"bytes"
"context"
"encoding/gob"
"encoding/json"
"likedream-goframe/internal/application/quesbank"
"likedream-goframe/internal/application/xuexitong"
"likedream-goframe/internal/application/xuexitong/entity"
"likedream-goframe/internal/common/db"
errors2 "likedream-goframe/internal/common/errors"
"likedream-goframe/internal/ent"
"likedream-goframe/internal/ent/courseentity"
"likedream-goframe/internal/ent/sysconfig"
"likedream-goframe/internal/framework/errors"
"likedream-goframe/internal/framework/logger"
"likedream-goframe/internal/utils"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/hibiken/asynq"
"github.com/samber/oops"
"go.uber.org/zap"
)
// getAIConfig 获取AI配置
func getAIConfig(ctx context.Context) *quesbank.AIConfig {
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 := json.Unmarshal([]byte(config.Value), &aiConfig); err != nil {
logger.Logger.Warn("AI配置解析失败", zap.Error(err))
return nil
}
return &aiConfig
}
type XuexitongTaskHandler struct{}
func (h *XuexitongTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task) (err error) {
@@ -38,7 +62,7 @@ func (h *XuexitongTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task
// 检查是否暂停
if targetCourse.Status == courseentity.StatusPause {
g.Log().Debugf(ctx, "课程ID %d 已暂停,跳过执行", targetCourse.ID)
logger.Logger.Debug("课程已暂停,跳过执行", zap.Int64("courseID", targetCourse.ID))
return nil
}
@@ -54,8 +78,6 @@ func (h *XuexitongTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task
currentMinutes := currentHour*60 + currentMinute
if currentMinutes < startTime || currentMinutes >= endTime {
g.Log().Debugf(ctx, "课程ID %d 非暴力模式,当前时间 %02d:%02d 不在执行时间范围内(8:30-17:30),跳过执行",
targetCourse.ID, currentHour, currentMinute)
return nil
}
}
@@ -77,7 +99,9 @@ func (h *XuexitongTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task
errorMessage = "未知的错误导致任务执行失败: " + err.Error()
}
g.Log().Errorf(ctx, "学习通刷课失败 - 课程: %s, 错误: %s", targetCourse.CourseName, errorMessage)
logger.Logger.Error("学习通刷课失败",
zap.String("courseName", targetCourse.CourseName),
zap.String("error", errorMessage))
panic(err)
})
@@ -96,33 +120,124 @@ func (h *XuexitongTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task
// 创建课程服务
courseService := xuexitong.NewXuexitongCourse(client)
// 执行刷课
err = courseService.AutoStudyByCourseID(ctx, targetCourse.CourseID)
// 非暴力模式下,设置时间检查回调,每个任务点完成后都会检查时间
if targetCourse.Violence == courseentity.ViolenceNo {
courseService.SetTimeCheckFunc(func() bool {
currentTime := time.Now()
currentHour := currentTime.Hour()
currentMinute := currentTime.Minute()
startTime := 8*60 + 30 // 8:30
endTime := 17*60 + 30 // 17:30
currentMinutes := currentHour*60 + currentMinute
needStudy := currentMinutes >= startTime && currentMinutes < endTime
if !needStudy {
// 更新状态为等待中
db.Global.CourseEntity.UpdateOneID(taskPayload.CourseID).SetStatus(courseentity.StatusWaiting).ExecX(ctx)
}
return needStudy
})
}
// 先获取课程列表找到目标课程
courseList := courseService.PullCourseList(ctx)
var course *entity.XueXiTCourse
for _, c := range courseList {
if c.CourseID == targetCourse.CourseID {
course = &c
break
}
}
if course == nil {
utils.OkOrPanic(errors.NewBusinessError(404, "未找到课程"))
}
// 开始刷课前,先获取并更新任务点统计
chapters := courseService.PullCourseChapterList(ctx, *course)
// FetchChapterPointStatus 会更新 chapters 中每个章节的 PointTotal 和 PointFinish
_, statusErr := courseService.FetchChapterPointStatus(ctx, *course, chapters)
if statusErr != nil {
logger.Logger.Warn("获取章节任务点状态失败", zap.Error(statusErr))
} else {
// 通过累加每个章节的 PointTotal 和 PointFinish 计算总数
var totalPointCount, finishedPointCount int
for _, chapter := range chapters {
totalPointCount += chapter.PointTotal
finishedPointCount += chapter.PointFinish
}
logger.Logger.Info("课程任务点统计",
zap.String("courseName", targetCourse.CourseName),
zap.Int("totalPointCount", totalPointCount),
zap.Int("finishedPointCount", finishedPointCount))
// 更新数据库
db.Global.CourseEntity.UpdateOneID(taskPayload.CourseID).
SetTaskCount(totalPointCount).
SetSuccessCount(finishedPointCount).
ExecX(ctx)
}
// 设置任务点完成回调,每完成一个任务点更新 success_count
courseService.SetTaskCompleteCallback(func() {
// 增加 success_count
db.Global.CourseEntity.UpdateOneID(taskPayload.CourseID).
AddSuccessCount(1).
ExecX(ctx)
})
// 获取AI配置(用于作业答题)
aiConfig := getAIConfig(ctx)
// 执行刷课(带AI配置)
err = courseService.AutoStudyByCourseIDWithOptions(ctx, targetCourse.CourseID, &xuexitong.AutoStudyOptions{
AIConfig: aiConfig,
AutoSubmit: true, // 默认不自动提交,仅保存
Thread: 5,
})
if err != nil {
// 如果是时间限制导致的停止,不算失败
if err == xuexitong.ErrTimeRestriction {
logger.Logger.Info("课程因时间限制暂停刷课,等待下次执行", zap.Int64("courseID", targetCourse.ID))
// 更新状态为等待中,等待下次定时任务
db.Global.CourseEntity.UpdateOneID(taskPayload.CourseID).SetStatus(courseentity.StatusWaiting).ExecX(ctx)
return nil
}
utils.OkOrPanic(errors.NewBusinessErrorWithDetail(503, "刷课失败", err))
}
// 刷课完成后,统计任务情况
g.Log().Infof(ctx, "刷课完成,正在统计任务情况...")
// 刷课完成后,重新统计任务情况
logger.Logger.Info("刷课完成,正在重新统计任务情况...")
// 获取章节列表
chapters := courseService.PullCourseChapterList(ctx, targetCourse.CourseID)
// 重新获取章节列表和任务点状态
chaptersAfter := courseService.PullCourseChapterList(ctx, *course)
// FetchChapterPointStatus 会更新 chapters 中每个章节的 PointTotal 和 PointFinish
_, statusErrAfter := courseService.FetchChapterPointStatus(ctx, *course, chaptersAfter)
// 统计总任务数(累加所有章节的JobCount)
totalTasks := 0
for _, chapter := range chapters {
totalTasks += chapter.JobCount
var totalPointCountAfter, finishedPointCountAfter int
if statusErrAfter != nil {
logger.Logger.Warn("获取章节任务点状态失败", zap.Error(statusErrAfter))
} else {
// 通过累加每个章节的 PointTotal 和 PointFinish 计算总数
for _, chapter := range chaptersAfter {
totalPointCountAfter += chapter.PointTotal
finishedPointCountAfter += chapter.PointFinish
}
}
g.Log().Infof(ctx, "课程 %s 统计完成: 总任务点 %d", targetCourse.CourseName, totalTasks)
logger.Logger.Info("课程统计完成",
zap.String("courseName", targetCourse.CourseName),
zap.Int("totalPointCount", totalPointCountAfter),
zap.Int("finishedPointCount", finishedPointCountAfter))
// 更新数据库(暂时不统计完成数,只统计总数)
// 更新数据库
db.Global.CourseEntity.UpdateOneID(taskPayload.CourseID).
SetStatus(courseentity.StatusDone).
SetTaskCount(totalTasks).
SetTaskCount(totalPointCountAfter).
SetSuccessCount(finishedPointCountAfter).
ExecX(ctx)
g.Log().Infof(ctx, "学习通刷课完成 - 课程: %s", targetCourse.CourseName)
logger.Logger.Info("学习通刷课完成", zap.String("courseName", targetCourse.CourseName))
return nil
}
@@ -18,6 +18,9 @@ type BalanceServiceV1 struct {
}
func (v *BalanceServiceV1) ChangeUserBalance(ctx context.Context, userId int64, balance decimal.Decimal, reason string) {
if decimal.Zero.Equal(balance) {
return
}
err := db.WithTx(ctx, func(c context.Context, tx *ent.Tx) {
// 使用 SELECT ... FOR UPDATE 锁定用户记录
user, err := tx.SysUser.Query().
@@ -0,0 +1,187 @@
package config
import (
"context"
"encoding/json"
"fmt"
v1 "likedream-goframe/internal/api/v1"
"likedream-goframe/internal/application/quesbank"
"likedream-goframe/internal/common/db"
errors2 "likedream-goframe/internal/common/errors"
"likedream-goframe/internal/ent"
"likedream-goframe/internal/ent/sysconfig"
"likedream-goframe/internal/utils"
"strings"
)
// AI配置键常量
const (
AIConfigKey = "ai.config"
)
// AIConfigServiceV1 AI配置服务
type AIConfigServiceV1 struct{}
// NewAIConfigServiceV1 创建AI配置服务
func NewAIConfigServiceV1() *AIConfigServiceV1 {
return &AIConfigServiceV1{}
}
// Get 获取AI配置
func (s *AIConfigServiceV1) Get(ctx context.Context, req *v1.AIConfigGetReq) *utils.Response[*v1.AIConfigGetResp] {
config, err := db.Global.SysConfig.Query().
Where(sysconfig.KeyEQ(AIConfigKey)).
Only(ctx)
if err != nil {
if ent.IsNotFound(err) {
return utils.SuccessWithData(&v1.AIConfigGetResp{
Type: "chatglm",
Model: "glm-4",
APIKey: "",
})
}
utils.OkOrPanic(err)
}
var aiConfig quesbank.AIConfig
if err := json.Unmarshal([]byte(config.Value), &aiConfig); err != nil {
utils.OkOrPanic(errors2.NewBusinessError(500, "AI配置解析失败"))
}
maskedKey := maskAPIKey(aiConfig.APIKey)
return utils.SuccessWithData(&v1.AIConfigGetResp{
Type: string(aiConfig.Type),
URL: aiConfig.URL,
Model: aiConfig.Model,
APIKey: maskedKey,
})
}
// Update 更新AI配置
func (s *AIConfigServiceV1) Update(ctx context.Context, req *v1.AIConfigUpdateReq) *utils.Response[any] {
if req.Type == "other" && req.URL == "" {
utils.OkOrPanic(errors2.NewBusinessError(400, "自定义AI类型必须填写API地址"))
}
aiConfig := quesbank.AIConfig{
Type: quesbank.AIType(req.Type),
URL: req.URL,
Model: req.Model,
APIKey: req.APIKey,
}
configValue, err := json.Marshal(aiConfig)
if err != nil {
utils.OkOrPanic(errors2.NewBusinessError(500, "配置序列化失败"))
}
config, err := db.Global.SysConfig.Query().
Where(sysconfig.KeyEQ(AIConfigKey)).
Only(ctx)
if err != nil {
if ent.IsNotFound(err) {
db.Global.SysConfig.Create().
SetKey(AIConfigKey).
SetValue(string(configValue)).
SetDescription("AI答题配置").
SetType("json").
SaveX(ctx)
return utils.SuccessWithMsg("AI配置创建成功")
}
utils.OkOrPanic(err)
}
db.Global.SysConfig.UpdateOne(config).
SetValue(string(configValue)).
ExecX(ctx)
return utils.SuccessWithMsg("AI配置更新成功")
}
// Test 测试AI配置
func (s *AIConfigServiceV1) Test(ctx context.Context, req *v1.AIConfigTestReq) *utils.Response[*v1.AIConfigTestResp] {
if req.Type == "other" && req.URL == "" {
return utils.SuccessWithData(&v1.AIConfigTestResp{
Success: false,
Message: "自定义AI类型必须填写API地址",
})
}
aiConfig := quesbank.AIConfig{
Type: quesbank.AIType(req.Type),
URL: req.URL,
Model: req.Model,
APIKey: req.APIKey,
}
aiService := quesbank.NewAIAnswerService(aiConfig)
testMessages := quesbank.AIChatMessages{
Messages: []quesbank.AIChatMessage{
{Role: "user", Content: "你好,请回复\"测试成功\""},
},
}
response, err := aiService.GetAnswer(ctx, testMessages)
if err != nil {
return utils.SuccessWithData(&v1.AIConfigTestResp{
Success: false,
Message: fmt.Sprintf("AI连接测试失败: %v", err),
})
}
return utils.SuccessWithData(&v1.AIConfigTestResp{
Success: true,
Message: fmt.Sprintf("AI连接测试成功,响应: %s", truncateString(response, 100)),
})
}
// GetTypeList 获取支持的AI类型列表
func (s *AIConfigServiceV1) GetTypeList(ctx context.Context, req *v1.AITypeListReq) *utils.Response[*v1.AITypeListResp] {
types := []v1.AITypeItem{
{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},
}
return utils.SuccessWithData(&v1.AITypeListResp{Types: types})
}
// GetAIConfig 获取AI配置(内部使用)
func (s *AIConfigServiceV1) GetAIConfig(ctx context.Context) (*quesbank.AIConfig, error) {
config, err := db.Global.SysConfig.Query().
Where(sysconfig.KeyEQ(AIConfigKey)).
Only(ctx)
if err != nil {
if ent.IsNotFound(err) {
return nil, fmt.Errorf("AI配置未设置")
}
return nil, err
}
var aiConfig quesbank.AIConfig
if err := json.Unmarshal([]byte(config.Value), &aiConfig); err != nil {
return nil, fmt.Errorf("AI配置解析失败: %w", err)
}
return &aiConfig, nil
}
func maskAPIKey(key string) string {
if len(key) <= 8 {
return strings.Repeat("*", len(key))
}
return key[:4] + strings.Repeat("*", len(key)-8) + key[len(key)-4:]
}
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
+78 -1
View File
@@ -36,7 +36,7 @@ func (s *ConfigServiceV1) Get(ctx context.Context, req *v1.ConfigGetReq) *utils.
// PublicGet 获取公开配置(无需认证)
func (s *ConfigServiceV1) PublicGet(ctx context.Context, req *v1.ConfigPublicGetReq) *utils.Response[*ent.SysConfig] {
// 只允许获取特定的公开配置
allowedKeys := []string{"system.announcement"}
allowedKeys := []string{"system.announcement", "system.allowRegister"}
allowed := false
for _, key := range allowedKeys {
@@ -112,3 +112,80 @@ func (s *ConfigServiceV1) Update(ctx context.Context, req *v1.ConfigUpdateReq) *
return utils.SuccessWithMsg("配置更新成功")
}
// 费率配置键常量
const (
PriceKeyCpdailyTask = "price.cpdaily_task"
PriceKeyCpdailyLeave = "price.cpdaily_leave"
PriceKeyCpdailyPhone = "price.cpdaily_phone"
PriceKeyXxt = "price.xxt"
PriceKeySmarteduSc = "price.smartedu_sc"
)
// GetPriceConfig 获取费率配置
func (s *ConfigServiceV1) GetPriceConfig(ctx context.Context, req *v1.PriceConfigGetReq) *utils.Response[*v1.PriceConfigResp] {
resp := &v1.PriceConfigResp{
CpdailyTask: s.getConfigValue(ctx, PriceKeyCpdailyTask, "0.5"),
CpdailyLeave: s.getConfigValue(ctx, PriceKeyCpdailyLeave, "4"),
CpdailyPhone: s.getConfigValue(ctx, PriceKeyCpdailyPhone, "0.25"),
Xxt: s.getConfigValue(ctx, PriceKeyXxt, "0"),
SmarteduSc: s.getConfigValue(ctx, PriceKeySmarteduSc, "0"),
}
return utils.SuccessWithData[*v1.PriceConfigResp](resp)
}
// getConfigValue 获取配置值,不存在则返回默认值
func (s *ConfigServiceV1) getConfigValue(ctx context.Context, key, defaultValue string) string {
config, err := db.Global.SysConfig.Query().
Where(sysconfig.KeyEQ(key)).
Only(ctx)
if err != nil {
return defaultValue
}
return config.Value
}
// UpdatePriceConfig 更新费率配置
func (s *ConfigServiceV1) UpdatePriceConfig(ctx context.Context, req *v1.PriceConfigUpdateReq) *utils.Response[any] {
// 更新各项费率配置
if req.CpdailyTask != "" {
s.upsertConfig(ctx, PriceKeyCpdailyTask, req.CpdailyTask, "今日校园签到单价")
}
if req.CpdailyLeave != "" {
s.upsertConfig(ctx, PriceKeyCpdailyLeave, req.CpdailyLeave, "今日校园请假单价")
}
if req.CpdailyPhone != "" {
s.upsertConfig(ctx, PriceKeyCpdailyPhone, req.CpdailyPhone, "今日校园手机号单价")
}
if req.Xxt != "" {
s.upsertConfig(ctx, PriceKeyXxt, req.Xxt, "学习通单价")
}
if req.SmarteduSc != "" {
s.upsertConfig(ctx, PriceKeySmarteduSc, req.SmarteduSc, "四川智慧教育单价")
}
return utils.SuccessWithMsg("费率配置更新成功")
}
// upsertConfig 更新或创建配置
func (s *ConfigServiceV1) upsertConfig(ctx context.Context, key, value, description string) {
config, err := db.Global.SysConfig.Query().
Where(sysconfig.KeyEQ(key)).
Only(ctx)
if err != nil {
if ent.IsNotFound(err) {
db.Global.SysConfig.Create().
SetKey(key).
SetValue(value).
SetDescription(description).
SetType("number").
SaveX(ctx)
return
}
utils.OkOrPanic(err)
}
db.Global.SysConfig.UpdateOne(config).
SetValue(value).
ExecX(ctx)
}
+19 -69
View File
@@ -6,15 +6,18 @@ import (
v1 "likedream-goframe/internal/api/v1"
"likedream-goframe/internal/application/xuexitong"
"likedream-goframe/internal/application/xuexitong/entity"
"likedream-goframe/internal/common/cst"
db2 "likedream-goframe/internal/common/db"
errors2 "likedream-goframe/internal/common/errors"
"likedream-goframe/internal/ent"
"likedream-goframe/internal/ent/courseentity"
"likedream-goframe/internal/ent/sysconfig"
"likedream-goframe/internal/ent/sysuser"
"likedream-goframe/internal/framework/logger"
"likedream-goframe/internal/service/balance"
"likedream-goframe/internal/utils"
"github.com/bytedance/sonic"
"github.com/shopspring/decimal"
"go.uber.org/zap"
"resty.dev/v3"
)
@@ -26,7 +29,7 @@ type CourseServiceV1 struct {
func (s CourseServiceV1) List(ctx context.Context, req *v1.CourseListReq) *utils.Response[[]*ent.CourseEntity] {
user := utils.GetUserInfoFromCtx(ctx)
query := db2.Global.CourseEntity.Query().
Where(courseentity.HasSysUserWith(sysuser.ID(user.ID))).
Where(courseentity.HasSysUserWith(sysuser.ID(user.ID)), courseentity.TypeEQ(courseentity.TypeXxt)).
Order(ent.Desc(courseentity.FieldCreateTime))
if req.Type != "" {
@@ -78,78 +81,27 @@ func (s CourseServiceV1) AddCourse(ctx context.Context, req *v1.AddCourseReq) *u
client := loginClient.GetClient()
courseService := xuexitong.NewXuexitongCourse(client)
// 获取课程章节列表
chapters := courseService.PullCourseChapterList(ctx, req.CourseID)
// 定义节点信息结构
type NodeInfo struct {
ChapterID string `json:"chapter_id"`
ChapterName string `json:"chapter_name"`
CardID string `json:"card_id"`
CardTitle string `json:"card_title"`
CardType string `json:"card_type"`
}
var allNodes []NodeInfo
var completedNodes []string
// 获取课程信息用于API调用
courses := courseService.PullCourseList(ctx)
var targetCourse *entity.XueXiTCourse
for _, c := range courses {
if c.CourseID == req.CourseID {
targetCourse = &c
// 获取课程章节列表
courseList := courseService.PullCourseList(ctx)
for _, course := range courseList {
if course.CourseID == req.CourseID {
targetCourse = &course
break
}
}
if targetCourse == nil {
utils.OkOrPanic(errors2.NewBusinessError(400, "未找到该课程"))
}
// 递归获取所有章节的任务点
var collectNodes func(chapters []entity.XueXiTCourseChapter)
collectNodes = func(chapters []entity.XueXiTCourseChapter) {
for _, chapter := range chapters {
// 如果有任务点,获取卡片信息
if chapter.JobCount > 0 {
cards, err := courseService.FetchChapterCards(ctx, *targetCourse, chapter.ID, chapter.ID)
if err != nil {
logger.Logger.Warn("获取章节任务点失败", zap.String("chapter", chapter.Name), zap.Error(err))
} else {
for _, card := range cards {
nodeInfo := NodeInfo{
ChapterID: chapter.ID,
ChapterName: chapter.Name,
CardID: card.ID,
CardTitle: card.Title,
CardType: card.Type,
}
allNodes = append(allNodes, nodeInfo)
// 检查是否已完成
if card.IsPassed {
nodeKey := chapter.ID + ":" + card.ID
completedNodes = append(completedNodes, nodeKey)
}
}
}
}
// 递归处理子章节
if len(chapter.Knowledge) > 0 {
collectNodes(chapter.Knowledge)
}
}
basePoint, err := decimal.NewFromString(db2.Global.SysConfig.Query().Where(sysconfig.KeyEQ(cst.ConfigRedisXxt)).OnlyX(ctx).Value)
utils.OkOrPanic(err)
if user.Edges.Agent != nil {
basePoint = basePoint.Mul(user.Edges.Agent.Rate)
}
// 先扣款
balance.BalanceService.ChangeUserBalance(ctx, user.ID, basePoint.Neg(), "学习通刷课")
collectNodes(chapters)
// 将节点信息序列化为JSON
nodesJSON, _ := sonic.MarshalString(allNodes)
completedNodesJSON, _ := sonic.MarshalString(completedNodes)
// 添加课程时不获取任务点统计,由定时任务处理
db2.Global.CourseEntity.Create().
SetUserID(user.ID).
SetUsername(req.Username).
@@ -161,10 +113,8 @@ func (s CourseServiceV1) AddCourse(ctx context.Context, req *v1.AddCourseReq) *u
SetViolence(req.Violence).
SetNillableDesc(&req.Desc).
SetStatus(courseentity.StatusNotStart).
SetTaskCount(len(allNodes)).
SetSuccessCount(len(completedNodes)).
SetNodes(nodesJSON).
SetCompletedNodes(completedNodesJSON).
SetTaskCount(0).
SetSuccessCount(0).
SaveX(ctx)
return utils.SuccessWithMsg("添加成功")
+226
View File
@@ -0,0 +1,226 @@
package course
import (
"context"
v1 "likedream-goframe/internal/api/v1"
"likedream-goframe/internal/application/smartedu/model"
"likedream-goframe/internal/application/smartedu/sc"
"likedream-goframe/internal/common/cst"
db2 "likedream-goframe/internal/common/db"
errors2 "likedream-goframe/internal/common/errors"
"likedream-goframe/internal/ent"
"likedream-goframe/internal/ent/courseentity"
"likedream-goframe/internal/ent/sysconfig"
"likedream-goframe/internal/ent/sysuser"
"likedream-goframe/internal/framework/logger"
"likedream-goframe/internal/service/balance"
"likedream-goframe/internal/utils"
"github.com/shopspring/decimal"
"go.uber.org/zap"
)
type SmartEduSCServiceV1 struct {
}
// List 获取四川智慧教育刷课列表
func (s SmartEduSCServiceV1) List(ctx context.Context, req *v1.SmartEduSCListReq) *utils.Response[[]*ent.CourseEntity] {
user := utils.GetUserInfoFromCtx(ctx)
query := db2.Global.CourseEntity.Query().
Where(
courseentity.HasSysUserWith(sysuser.ID(user.ID)),
courseentity.TypeEQ(courseentity.TypeSmarteduSc),
).
Order(ent.Desc(courseentity.FieldCreateTime))
if req.Username != "" {
query.Where(courseentity.UsernameContains(req.Username))
}
if req.Status != "" {
query.Where(courseentity.StatusEQ(courseentity.Status(req.Status)))
}
total := query.CountX(ctx)
var data []*ent.CourseEntity
if total > 0 {
query.Limit(req.Size).Offset((req.Page - 1) * req.Size)
data = query.AllX(ctx)
for _, item := range data {
item.Password = ""
}
}
return utils.SuccessWithTotal[[]*ent.CourseEntity](data, total)
}
// QueryCourses 查询四川智慧教育课程列表
func (s SmartEduSCServiceV1) QueryCourses(ctx context.Context, req *v1.SmartEduSCQueryCourseReq) *utils.Response[[]model.Course] {
login := sc.NewLogin(req.Username, req.Password)
ticket := login.Login(ctx)
token := login.OOSLogin(ctx, ticket)
client := login.GetClient()
courseService := sc.NewCourse(token, client)
courses := courseService.GetCourseList(ctx)
return utils.SuccessWithData[[]model.Course](courses)
}
// AddCourse 添加四川智慧教育刷课任务
func (s SmartEduSCServiceV1) AddCourse(ctx context.Context, req *v1.SmartEduSCAddCourseReq) *utils.Response[any] {
user := utils.GetUserInfoFromCtx(ctx)
// 检查是否已存在相同任务
exists := db2.Global.CourseEntity.Query().
Where(
courseentity.HasSysUserWith(sysuser.ID(user.ID)),
courseentity.Username(req.Username),
courseentity.CourseID(req.CourseID),
courseentity.TypeEQ(courseentity.TypeSmarteduSc),
).
ExistX(ctx)
if exists {
utils.OkOrPanic(errors2.NewBusinessError(400, "该课程已存在刷课任务"))
}
// 登录验证并获取课程信息
login := sc.NewLogin(req.Username, req.Password)
ticket := login.Login(ctx)
token := login.OOSLogin(ctx, ticket)
client := login.GetClient()
courseService := sc.NewCourse(token, client)
courseList := courseService.GetCourseList(ctx)
// 验证课程是否存在
var targetCourse *model.Course
for _, c := range courseList {
if c.Id == req.CourseID {
targetCourse = &c
break
}
}
if targetCourse == nil {
utils.OkOrPanic(errors2.NewBusinessError(400, "未找到该课程"))
}
basePoint, err := decimal.NewFromString(db2.Global.SysConfig.Query().Where(sysconfig.KeyEQ(cst.ConfigRedisSmarteduSc)).OnlyX(ctx).Value)
utils.OkOrPanic(err)
if user.Edges.Agent != nil {
basePoint = basePoint.Mul(user.Edges.Agent.Rate)
}
// 先扣款
balance.BalanceService.ChangeUserBalance(ctx, user.ID, basePoint.Neg(), "四川中小学教师刷课")
// 创建数据库记录
entity := db2.Global.CourseEntity.Create().
SetUserID(user.ID).
SetUsername(req.Username).
SetPassword(req.Password).
SetType(courseentity.TypeSmarteduSc).
SetCourseName(req.CourseName).
SetCourseID(req.CourseID).
SetNeed(courseentity.NeedTask).
SetViolence(courseentity.ViolenceYes).
SetNillableDesc(&req.Desc).
SetStatus(courseentity.StatusDoing).
SetTaskCount(0).
SetSuccessCount(0).
SaveX(ctx)
// 开启协程执行秒刷
go s.executeStudy(context.Background(), entity.ID, req.Username, req.Password, req.CourseID)
return utils.SuccessWithMsg("任务已提交,正在刷课中")
}
// executeStudy 执行刷课(协程中运行)
func (s SmartEduSCServiceV1) executeStudy(ctx context.Context, entityID int64, username, password, courseID string) {
defer func() {
if r := recover(); r != nil {
logger.Logger.Error("刷课执行异常", zap.Any("error", r), zap.Int64("entityID", entityID))
db2.Global.CourseEntity.UpdateOneID(entityID).
SetStatus(courseentity.StatusFail).
SaveX(ctx)
}
}()
login := sc.NewLogin(username, password)
ticket := login.Login(ctx)
token := login.OOSLogin(ctx, ticket)
client := login.GetClient()
courseService := sc.NewCourse(token, client)
courseList := courseService.GetCourseList(ctx)
var targetCourse *model.Course
for _, c := range courseList {
if c.Id == courseID {
targetCourse = &c
break
}
}
if targetCourse == nil {
logger.Logger.Error("未找到课程", zap.String("courseID", courseID))
db2.Global.CourseEntity.UpdateOneID(entityID).
SetStatus(courseentity.StatusFail).
SaveX(ctx)
return
}
chapterList := courseService.GetChapterList(ctx, courseID)
taskCount := 0
successCount := 0
for _, ch := range chapterList {
taskCount += len(ch.SubSection)
}
db2.Global.CourseEntity.UpdateOneID(entityID).
SetTaskCount(taskCount).
SaveX(ctx)
for _, ch := range chapterList {
for _, sec := range ch.SubSection {
courseService.Study(ctx, *targetCourse, ch, sec)
successCount++
db2.Global.CourseEntity.UpdateOneID(entityID).
SetSuccessCount(successCount).
SaveX(ctx)
}
}
db2.Global.CourseEntity.UpdateOneID(entityID).
SetStatus(courseentity.StatusDone).
SaveX(ctx)
logger.Logger.Info("刷课完成",
zap.String("username", username),
zap.String("courseID", courseID),
zap.Int("taskCount", taskCount),
zap.Int("successCount", successCount))
}
// DeleteCourse 删除四川智慧教育刷课任务
func (s SmartEduSCServiceV1) DeleteCourse(ctx context.Context, req *v1.SmartEduSCDeleteReq) *utils.Response[any] {
user := utils.GetUserInfoFromCtx(ctx)
course := db2.Global.CourseEntity.Query().
Where(
courseentity.ID(req.ID),
courseentity.HasSysUserWith(sysuser.ID(user.ID)),
courseentity.TypeEQ(courseentity.TypeSmarteduSc),
).
OnlyX(ctx)
if course == nil {
utils.OkOrPanic(errors2.NewBusinessError(400, "任务不存在或无权限操作"))
}
db2.Global.CourseEntity.DeleteOne(course).ExecX(ctx)
return utils.SuccessWithMsg("删除成功")
}
func NewSmartEduSCServiceV1() *SmartEduSCServiceV1 {
return &SmartEduSCServiceV1{}
}
+32 -19
View File
@@ -13,11 +13,11 @@ import (
"likedream-goframe/internal/ent"
"likedream-goframe/internal/ent/cpleave"
"likedream-goframe/internal/ent/cpuser"
"likedream-goframe/internal/ent/sysconfig"
"likedream-goframe/internal/ent/sysuser"
"likedream-goframe/internal/service/balance"
"likedream-goframe/internal/utils"
"github.com/gogf/gf/v2/frame/g"
"github.com/shopspring/decimal"
)
@@ -45,7 +45,7 @@ func (v CpLeaveServiceV1) GetLeaveListFromSystem(ctx context.Context, req *v1.Ge
client := login.GetClient(ctx, cookie)
// 检查cookie是否有效
if !login.CookieEnabled(ctx, schoolDetail.WecloudUrl, client) {
if !login.CookieEnabled(ctx, schoolDetail.WecloudUrl, client, cpUser.LoginType) {
loginExecutor := login.GetCpdailyLoginBySchoolInfo(ctx, &schoolDetail)
loginExecutor.Login(ctx, &schoolDetail, cpUser.Username, cpUser.Password)
cookie = loginExecutor.GetCookieString()
@@ -110,24 +110,16 @@ func (v CpLeaveServiceV1) Report(ctx context.Context, req *v1.CpLeaveReportReq)
utils.OkOrPanic(errors2.NewBusinessError(400, "非法的今日校园账号"))
}
// 检查是否已经存在该销假记录
existLeave, _ := db2.Global.CpLeave.Query().
Where(cpleave.LeaveIDEQ(req.LeaveId), cpleave.CpUserIDEQ(req.CpUserId)).
First(ctx)
if existLeave != nil {
utils.OkOrPanic(errors2.NewBusinessError(400, fmt.Sprintf("该请假记录已处理,状态:%s", existLeave.Status)))
}
// 计算扣款金额
basePoint, err := decimal.NewFromString(g.Cfg().MustGet(ctx, cst2.ConfigBasePointCpdaily).String())
//basePoint, err := decimal.NewFromString(g.Cfg().MustGet(ctx, cst2.ConfigBasePointCpdailyLeave).String())
basePoint, err := decimal.NewFromString(db2.Global.SysConfig.Query().Where(sysconfig.KeyEQ(cst2.ConfigRedisCpdailyLeave)).OnlyX(ctx).Value)
utils.OkOrPanic(err)
if user.Edges.Agent != nil {
basePoint = basePoint.Mul(user.Edges.Agent.Rate)
}
// 先扣款
balance.BalanceService.ChangeUserBalance(ctx, user.ID, basePoint.Neg(), "销假")
balance.BalanceService.ChangeUserBalance(ctx, user.ID, basePoint.Neg(), "今日校园销假")
// 创建销假记录
leaveRecord := db2.Global.CpLeave.Create().
@@ -161,7 +153,7 @@ func (v CpLeaveServiceV1) Report(ctx context.Context, req *v1.CpLeaveReportReq)
client := login.GetClient(ctx, cookie)
// 检查cookie是否有效
if !login.CookieEnabled(ctx, schoolDetail.WecloudUrl, client) {
if !login.CookieEnabled(ctx, schoolDetail.WecloudUrl, client, cpUser.LoginType) {
loginExecutor := login.GetCpdailyLoginBySchoolInfo(ctx, &schoolDetail)
loginExecutor.Login(ctx, &schoolDetail, cpUser.Username, cpUser.Password)
cookie = loginExecutor.GetCookieString()
@@ -176,13 +168,10 @@ func (v CpLeaveServiceV1) Report(ctx context.Context, req *v1.CpLeaveReportReq)
detail := reportApp.LeaveDetail(req.LeaveId)
// 填充表单
form := reportApp.FillReportForm(detail)
// 获取位置信息
lon, lat := reportApp.GetLocation(detail)
form := reportApp.FillReportForm(detail, req)
// 提交销假
reportApp.SubmitReport(ctx, form, lon, lat, cpUser.Username, cpUser.MobileCode)
reportApp.SubmitReport(ctx, form, req.Longitude, req.Latitude, cpUser.Username, cpUser.MobileCode)
// 如果执行成功,更新记录状态
db2.Global.CpLeave.UpdateOne(leaveRecord).
@@ -193,6 +182,30 @@ func (v CpLeaveServiceV1) Report(ctx context.Context, req *v1.CpLeaveReportReq)
return utils.SuccessWithMsg("销假成功")
}
// Upload 上传图片并返回预览地址
func (v CpLeaveServiceV1) Upload(ctx context.Context, req *v1.CpLeaveUploadReq) *utils.Response[string] {
user := utils.GetUserInfoFromCtx(ctx)
// 检测是否属于自己的cpuser
cpUser := db2.Global.CpUser.Query().Where(cpuser.ID(req.CpUserId), cpuser.HasSysuserWith(sysuser.ID(user.ID))).WithSysuser().WithSchool().OnlyX(ctx)
if cpUser.Edges.Sysuser == nil {
utils.OkOrPanic(errors2.NewBusinessError(400, "非法的今日校园账号"))
}
schoolDetail := school.GetSchoolDetail(ctx, cpUser.SchoolID)
client := login.GetClient(ctx, cpUser.Cookie)
if !login.CookieEnabled(ctx, schoolDetail.WecloudUrl, client, cpUser.LoginType) {
loginExecutor := login.GetCpdailyLoginBySchoolInfo(ctx, &schoolDetail)
loginExecutor.Login(ctx, &schoolDetail, cpUser.Username, cpUser.Password)
cookie := loginExecutor.GetCookieString()
db2.Global.CpUser.UpdateOne(cpUser).SetCookie(cookie).ExecX(ctx)
client = login.GetClient(ctx, cookie)
}
// 创建销假服务
reportApp := leave.NewCpdailyReportApp(client, schoolDetail.WecloudUrl)
imageUrl := reportApp.UploadImage(ctx, req.File)
return utils.SuccessWithData(imageUrl)
}
func NewCpLeaveServiceV1() *CpLeaveServiceV1 {
return &CpLeaveServiceV1{}
}
+5 -4
View File
@@ -16,6 +16,7 @@ import (
"likedream-goframe/internal/ent"
"likedream-goframe/internal/ent/cptask"
"likedream-goframe/internal/ent/cpuser"
"likedream-goframe/internal/ent/sysconfig"
"likedream-goframe/internal/ent/sysuser"
"likedream-goframe/internal/framework/queue"
"likedream-goframe/internal/framework/schedule"
@@ -43,7 +44,7 @@ func (v CpTaskServiceV1) GetRecentTasks(ctx context.Context, req *v1.GetRecentTa
}
schoolDetail := school.GetSchoolDetail(ctx, cpUser.SchoolID)
client := login.GetClient(ctx, cpUser.Cookie)
if !login.CookieEnabled(ctx, schoolDetail.WecloudUrl, client) {
if !login.CookieEnabled(ctx, schoolDetail.WecloudUrl, client, cpUser.LoginType) {
loginExecutor := login.GetCpdailyLoginBySchoolInfo(ctx, &schoolDetail)
loginExecutor.Login(ctx, &schoolDetail, cpUser.Username, cpUser.Password)
cookie := loginExecutor.GetCookieString()
@@ -118,7 +119,7 @@ func (v CpTaskServiceV1) TaskFormData(ctx context.Context, req *v1.GetCpTaskForm
schoolDetail := school.GetSchoolDetail(ctx, cpTask.Edges.CpUser.SchoolID)
cookie := cpUser.Cookie
client := login.GetClient(ctx, cookie)
if !login.CookieEnabled(ctx, schoolDetail.WecloudUrl, client) {
if !login.CookieEnabled(ctx, schoolDetail.WecloudUrl, client, cpUser.LoginType) {
loginExecutor := login.GetCpdailyLoginBySchoolInfo(ctx, &schoolDetail)
loginExecutor.Login(ctx, &schoolDetail, cpUser.Username, cpUser.Password)
cookie = loginExecutor.GetCookieString()
@@ -180,7 +181,7 @@ func (v CpTaskServiceV1) PostExecute(ctx context.Context, req *v1.PostCpTaskExec
}
//扣积分
basePoint, err := decimal.NewFromString(g.Cfg().MustGet(ctx, cst2.ConfigBasePointCpdaily).String())
basePoint, err := decimal.NewFromString(db2.Global.SysConfig.Query().Where(sysconfig.KeyEQ(cst2.ConfigRedisCpdailyTask)).OnlyX(ctx).Value)
utils.OkOrPanic(err)
if user.Edges.Agent != nil {
basePoint = basePoint.Mul(user.Edges.Agent.Rate)
@@ -265,7 +266,7 @@ func (v CpTaskServiceV1) ExecuteNow(ctx context.Context, req *v1.ExecuteNowReq)
queue.AddTask(ctx, asynq.NewTask(cst2.TaskCpdaily, payloadWriter.Bytes()),
asynq.Unique(2*time.Minute),
asynq.MaxRetry(0),
asynq.Timeout(10*time.Minute),
asynq.Timeout(5*time.Minute),
asynq.Deadline(time.Now().Add(10*time.Minute)),
asynq.Queue(cst2.TaskQueueCpdaily),
)
+52 -2
View File
@@ -3,6 +3,7 @@ package cpdaily
import (
"context"
v1 "likedream-goframe/internal/api/v1"
"likedream-goframe/internal/application/cookiejar"
"likedream-goframe/internal/application/cpdaily/login"
"likedream-goframe/internal/application/cpdaily/school"
"likedream-goframe/internal/common/cst"
@@ -10,14 +11,17 @@ import (
"likedream-goframe/internal/ent"
"likedream-goframe/internal/ent/cpschool"
"likedream-goframe/internal/ent/cpuser"
"likedream-goframe/internal/ent/sysconfig"
"likedream-goframe/internal/ent/sysuser"
"likedream-goframe/internal/framework/redis"
"likedream-goframe/internal/service/balance"
"likedream-goframe/internal/utils"
"strconv"
"time"
"github.com/bytedance/sonic"
"github.com/samber/oops"
"github.com/shopspring/decimal"
)
type CpUserServiceV1 struct {
@@ -66,6 +70,7 @@ func (v CpUserServiceV1) ConfirmAdd(ctx context.Context, req *v1.CpUserConfirmAd
SetMobileType(cpuser.MobileTypeRandom).
SetSchoolID(sc.Id).
SetSysuserID(user.ID).
SetLoginType(cpuser.LoginTypeAccount).
SaveX(ctx)
return utils.SuccessWithMsg("添加成功")
}
@@ -88,6 +93,7 @@ func (v CpUserServiceV1) List(ctx context.Context, req *v1.CpUserListReq) *utils
// 忽略密码
for _, v := range data {
v.Password = ""
v.Cookie = ""
}
return utils.SuccessWithTotal[[]*ent.CpUser](data, total)
}
@@ -174,11 +180,55 @@ func (v CpUserServiceV1) AuthorizeRandom(ctx context.Context, req *v1.CpUserAuth
}
func (v CpUserServiceV1) LoginGetCode(ctx context.Context, req *v1.LoginGetCode) *utils.Response[any] {
return utils.SuccessWithMsg("")
user := utils.GetUserInfoFromCtx(ctx)
// 扣费
//basePoint, err := decimal.NewFromString(g.Cfg().MustGet(ctx, cst.ConfigBasePointCpdailyPhone).String())
basePoint, err := decimal.NewFromString(db.Global.SysConfig.Query().Where(sysconfig.KeyEQ(cst.ConfigRedisCpdailyPhone)).OnlyX(ctx).Value)
utils.OkOrPanic(err)
if user.Edges.Agent != nil {
basePoint = basePoint.Mul(user.Edges.Agent.Rate)
}
if user.Balance.Sub(basePoint).IsNegative() {
return utils.Fail(400, "余额不足")
}
loginExecutor := login.NewSmsWithoutDeviceID()
loginExecutor.SendSms(ctx, req.Mobile)
balance.BalanceService.ChangeUserBalance(ctx, user.ID, basePoint.Neg(), "手机号登录 - 验证码")
return utils.SuccessWithMsg("发送成功")
}
func (v CpUserServiceV1) AddByCode(ctx context.Context, req *v1.AddByCode) *utils.Response[any] {
return utils.SuccessWithMsg("")
user := utils.GetUserInfoFromCtx(ctx)
jar := cookiejar.NewPersistCookieJar()
loginExecutor := login.NewSmsWithoutDeviceID()
data := loginExecutor.LoginByCode(context.Background(), req.Mobile, req.Code, jar)
loginExecutor.SetLoginCookie(context.Background(), jar, data)
// 扣费
//basePoint, err := decimal.NewFromString(g.Cfg().MustGet(ctx, cst.ConfigBasePointCpdailyPhone).String())
basePoint, err := decimal.NewFromString(db.Global.SysConfig.Query().Where(sysconfig.KeyEQ(cst.ConfigRedisCpdailyPhone)).OnlyX(ctx).Value)
utils.OkOrPanic(err)
if user.Edges.Agent != nil {
basePoint = basePoint.Mul(user.Edges.Agent.Rate)
}
balance.BalanceService.ChangeUserBalance(ctx, user.ID, basePoint.Neg(), "手机号登录 - 登录")
if req.Id == 0 {
db.Global.CpUser.Create().
SetUsername(req.Mobile).
SetEmail(req.Email).
SetSchoolID(req.SchoolId).
SetCookie(loginExecutor.GetCookieString()).
SetMobileCode(utils.UUID()).
SetMobileType(cpuser.MobileTypeRandom).
SetSysuserID(user.ID).
SetLoginType(cpuser.LoginTypePhone).
SaveX(ctx)
return utils.SuccessWithMsg("添加成功")
} else {
db.Global.CpUser.UpdateOneID(req.Id).
SetCookie(loginExecutor.GetCookieString()).
ExecX(ctx)
return utils.SuccessWithMsg("更新成功")
}
}
func (v CpUserServiceV1) LoginUrl(ctx context.Context, req *v1.CpUserLoginUrlReq) *utils.Response[any] {
+2 -2
View File
@@ -143,8 +143,8 @@ func (s *EmailServiceV1) sendEmail(ctx context.Context, to, subject, body string
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
task := queue.AddTask(ctx, asynq.NewTask(cst2.TaskEmail, payload.Bytes()), asynq.Queue(cst2.TaskQueueEmail),
asynq.MaxRetry(3),
asynq.Timeout(10*time.Minute),
asynq.MaxRetry(1),
asynq.Timeout(5*time.Minute),
)
logger.Logger.Info("添加任务: ", zap.String("task_id", task.ID), zap.String("邮箱", to))
return nil

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