🐛 学习通答题
This commit is contained in:
@@ -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 | 服务器内部错误 |
|
||||||
@@ -31,6 +31,7 @@ require (
|
|||||||
go.uber.org/automaxprocs v1.6.0
|
go.uber.org/automaxprocs v1.6.0
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.0
|
||||||
golang.org/x/crypto v0.43.0
|
golang.org/x/crypto v0.43.0
|
||||||
|
golang.org/x/net v0.45.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
resty.dev/v3 v3.0.0-beta.3
|
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/arch v0.0.0-20210923205945-b76863e36670 // indirect
|
||||||
golang.org/x/image v0.29.0 // indirect
|
golang.org/x/image v0.29.0 // indirect
|
||||||
golang.org/x/mod v0.28.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/sync v0.18.0 // indirect
|
||||||
golang.org/x/sys v0.37.0 // indirect
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
golang.org/x/text v0.30.0 // indirect
|
golang.org/x/text v0.30.0 // indirect
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ func StartServer(ctx context.Context) {
|
|||||||
systemGroup.Bind(menu.NewV1())
|
systemGroup.Bind(menu.NewV1())
|
||||||
systemGroup.Bind(user.NewUserCtrlV1())
|
systemGroup.Bind(user.NewUserCtrlV1())
|
||||||
systemGroup.Bind(config.NewConfigCtrlV1())
|
systemGroup.Bind(config.NewConfigCtrlV1())
|
||||||
|
systemGroup.Bind(config.NewAIConfigCtrlV1())
|
||||||
//systemGroup.Bind(agent.NewV1())
|
//systemGroup.Bind(agent.NewV1())
|
||||||
// 用户信息路由
|
// 用户信息路由
|
||||||
userGroup := group.Group("/user").Middleware(middleware.AuthHandler, middleware.PermissionHandler)
|
userGroup := group.Group("/user").Middleware(middleware.AuthHandler, middleware.PermissionHandler)
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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,145 @@
|
|||||||
|
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 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
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 {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
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.AutoStudyChapter(ctx, course, chapter)
|
||||||
|
if err != nil {
|
||||||
|
if err == ErrTimeRestriction {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logger.Logger.Error(fmt.Sprintf("刷章节失败: %s", chapter.Name), zap.Error(err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logger.Info(fmt.Sprintf("课程完成: %s", course.CourseName))
|
||||||
|
return 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 {
|
||||||
|
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 // 是否自动提交作业
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
logger.Logger.Info("开始处理章节", zap.String("chapter", chapter.Name))
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,758 @@
|
|||||||
|
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µTopicId=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
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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":
|
||||||
|
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, """, "\"")
|
||||||
|
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,237 @@
|
|||||||
|
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 {
|
||||||
|
property, ok := att["property"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
jobid, _ := property["jobid"].(string)
|
||||||
|
if jobid != p.JobID && p.JobID != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if name, ok := property["name"].(string); ok {
|
||||||
|
p.Title = name
|
||||||
|
}
|
||||||
|
if bookname, ok := property["bookname"].(string); ok {
|
||||||
|
p.Title = bookname
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttachmentsDetectionDocument 从附件设置中检测并填充文档信息
|
||||||
|
func (c *XuexitongCourse) AttachmentsDetectionDocument(attachment *entity.AttachmentSetting, p *entity.PointDocumentDto) error {
|
||||||
|
if attachment == nil {
|
||||||
|
return errors2.NewBusinessError(503, "附件设置为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
jobid, _ := property["jobid"].(string)
|
||||||
|
if jobid != p.JobID && p.JobID != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if name, ok := property["name"].(string); ok {
|
||||||
|
p.Title = name
|
||||||
|
}
|
||||||
|
if bookname, ok := property["bookname"].(string); ok {
|
||||||
|
p.Title = bookname
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
if typeStr == "document" || typeStr == "insertdoc" {
|
||||||
|
p.Type = "document"
|
||||||
|
} else if typeStr == "insertbook" {
|
||||||
|
p.Type = "insertbook"
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.Type == "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
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoReadDocument 自动阅读文档(刷课)
|
||||||
|
func (c *XuexitongCourse) AutoReadDocument(ctx context.Context, course entity.XueXiTCourse, p *entity.PointDocumentDto) 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.DocumentDtoReadingReport(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
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoReadDocumentByPoint 自动阅读文档(使用统一的 ChapterPoint)
|
||||||
|
func (c *XuexitongCourse) AutoReadDocumentByPoint(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.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) {
|
||||||
|
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.DocumentType == "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
|
||||||
|
}
|
||||||
@@ -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µTopicId=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µTopicId=undefined&_dc=%d",
|
||||||
|
p.JobID, p.KnowledgeID, p.CourseID, p.ClassID, p.Jtoken, time.Now().UnixMilli())
|
||||||
|
|
||||||
|
var body string
|
||||||
|
err := retry.Do(func() error {
|
||||||
|
resp, err := c.client.R().SetHeaders(map[string]string{
|
||||||
|
"User-Agent": GetUA("web"),
|
||||||
|
}).Get(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode() != 200 {
|
||||||
|
return errors2.NewBusinessError(resp.StatusCode(), "提交外链完成失败")
|
||||||
|
}
|
||||||
|
body = resp.String()
|
||||||
|
return nil
|
||||||
|
}, retry.Attempts(3), retry.Delay(2*time.Second))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
package xuexitong
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"likedream-goframe/internal/application/xuexitong/entity"
|
||||||
|
errors2 "likedream-goframe/internal/common/errors"
|
||||||
|
"likedream-goframe/internal/framework/logger"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/avast/retry-go/v4"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// attachmentDetectLive 从附件设置中检测并填充直播信息
|
||||||
|
func (c *XuexitongCourse) attachmentDetectLive(attachment *entity.AttachmentSetting, p *entity.ChapterPoint) {
|
||||||
|
for _, att := range attachment.Attachments {
|
||||||
|
jobid, _ := att["jobid"].(string)
|
||||||
|
if jobid != p.JobID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if authEnc, ok := att["authEnc"].(string); ok {
|
||||||
|
p.AuthEnc = authEnc
|
||||||
|
}
|
||||||
|
if liveDragEnc, ok := att["liveDragEnc"].(string); ok {
|
||||||
|
p.LiveDragEnc = liveDragEnc
|
||||||
|
}
|
||||||
|
if liveSetEnc, ok := att["liveSetEnc"].(string); ok {
|
||||||
|
p.LiveSetEnc = liveSetEnc
|
||||||
|
}
|
||||||
|
if otherInfo, ok := att["otherInfo"].(string); ok {
|
||||||
|
p.OtherInfo = otherInfo
|
||||||
|
}
|
||||||
|
if enc, ok := att["enc"].(string); ok {
|
||||||
|
p.Enc = enc
|
||||||
|
}
|
||||||
|
if liveSwDsEnc, ok := att["liveSwDsEnc"].(string); ok {
|
||||||
|
p.LiveSwDsEnc = liveSwDsEnc
|
||||||
|
}
|
||||||
|
if isJob, ok := att["job"].(bool); ok {
|
||||||
|
p.IsJob = isJob
|
||||||
|
}
|
||||||
|
if aid, ok := att["aid"].(float64); ok {
|
||||||
|
p.Aid = strconv.FormatInt(int64(aid), 10)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttachmentsDetectionLive 从附件设置中检测并填充直播信息
|
||||||
|
func (c *XuexitongCourse) AttachmentsDetectionLive(attachment *entity.AttachmentSetting, p *entity.PointLiveDto) error {
|
||||||
|
if attachment == nil {
|
||||||
|
return errors2.NewBusinessError(503, "附件设置为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, att := range attachment.Attachments {
|
||||||
|
jobid, _ := att["jobid"].(string)
|
||||||
|
if jobid != p.JobID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if authEnc, ok := att["authEnc"].(string); ok {
|
||||||
|
p.AuthEnc = authEnc
|
||||||
|
}
|
||||||
|
if liveDragEnc, ok := att["liveDragEnc"].(string); ok {
|
||||||
|
p.LiveDragEnc = liveDragEnc
|
||||||
|
}
|
||||||
|
if liveSetEnc, ok := att["liveSetEnc"].(string); ok {
|
||||||
|
p.LiveSetEnc = liveSetEnc
|
||||||
|
}
|
||||||
|
if otherInfo, ok := att["otherInfo"].(string); ok {
|
||||||
|
p.OtherInfo = otherInfo
|
||||||
|
}
|
||||||
|
if enc, ok := att["enc"].(string); ok {
|
||||||
|
p.Enc = enc
|
||||||
|
}
|
||||||
|
if liveSwDsEnc, ok := att["liveSwDsEnc"].(string); ok {
|
||||||
|
p.LiveSwDsEnc = liveSwDsEnc
|
||||||
|
}
|
||||||
|
if isJob, ok := att["job"].(bool); ok {
|
||||||
|
p.IsJob = isJob
|
||||||
|
}
|
||||||
|
if aid, ok := att["aid"].(float64); ok {
|
||||||
|
p.Aid = strconv.FormatInt(int64(aid), 10)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoWatchLive 自动观看直播任务点
|
||||||
|
func (c *XuexitongCourse) AutoWatchLive(ctx context.Context, course entity.XueXiTCourse, p *entity.PointLiveDto) error {
|
||||||
|
if !p.IsJob {
|
||||||
|
logger.Logger.Info("直播不是任务点,跳过", zap.String("jobID", p.JobID), zap.String("title", p.Title))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.PullLiveInfo(ctx, p); err != nil {
|
||||||
|
logger.Logger.Warn("获取直播信息失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.LiveStatusCode == 0 {
|
||||||
|
logger.Logger.Info("直播还未开始,跳过", zap.String("title", p.Title))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.VideoCompletePercent >= 90 {
|
||||||
|
logger.Logger.Info("直播已完成", zap.String("title", p.Title), zap.Float64("percent", p.VideoCompletePercent))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logger.Info("开始刷直播",
|
||||||
|
zap.String("jobID", p.JobID),
|
||||||
|
zap.String("title", p.Title),
|
||||||
|
zap.Float64("currentPercent", p.VideoCompletePercent))
|
||||||
|
|
||||||
|
if _, err := c.LiveRelationReport(ctx, p); err != nil {
|
||||||
|
logger.Logger.Warn("建立直播连接失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
report, err := c.LiveSaveTimeReport(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("提交直播观看进度失败", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.PullLiveInfo(ctx, p); err != nil {
|
||||||
|
logger.Logger.Warn("更新直播信息失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logger.Debug("直播观看中",
|
||||||
|
zap.String("title", p.Title),
|
||||||
|
zap.Float64("percent", p.VideoCompletePercent),
|
||||||
|
zap.String("report", report))
|
||||||
|
|
||||||
|
if p.VideoCompletePercent >= 90 {
|
||||||
|
logger.Logger.Info("直播刷课完成", zap.String("title", p.Title))
|
||||||
|
c.onTaskComplete()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(30 * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoWatchLiveByPoint 自动观看直播任务点(使用统一的 ChapterPoint)
|
||||||
|
func (c *XuexitongCourse) AutoWatchLiveByPoint(ctx context.Context, course entity.XueXiTCourse, p *entity.ChapterPoint) error {
|
||||||
|
if !p.IsJob {
|
||||||
|
logger.Logger.Info("直播不是任务点,跳过", zap.String("jobID", p.JobID), zap.String("title", p.Title))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.PullLiveInfoByPoint(ctx, p); err != nil {
|
||||||
|
logger.Logger.Warn("获取直播信息失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.LiveStatusCode == 0 {
|
||||||
|
logger.Logger.Info("直播还未开始,跳过", zap.String("title", p.Title))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.VideoCompletePercent >= 90 {
|
||||||
|
logger.Logger.Info("直播已完成", zap.String("title", p.Title), zap.Float64("percent", p.VideoCompletePercent))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logger.Info("开始刷直播",
|
||||||
|
zap.String("jobID", p.JobID),
|
||||||
|
zap.String("title", p.Title),
|
||||||
|
zap.Float64("currentPercent", p.VideoCompletePercent))
|
||||||
|
|
||||||
|
if _, err := c.LiveRelationReportByPoint(ctx, p); err != nil {
|
||||||
|
logger.Logger.Warn("建立直播连接失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
report, err := c.LiveSaveTimeReportByPoint(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("提交直播观看进度失败", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.PullLiveInfoByPoint(ctx, p); err != nil {
|
||||||
|
logger.Logger.Warn("更新直播信息失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logger.Debug("直播观看中",
|
||||||
|
zap.String("title", p.Title),
|
||||||
|
zap.Float64("percent", p.VideoCompletePercent),
|
||||||
|
zap.String("report", report))
|
||||||
|
|
||||||
|
if p.VideoCompletePercent >= 90 {
|
||||||
|
logger.Logger.Info("直播刷课完成", zap.String("title", p.Title))
|
||||||
|
c.onTaskComplete()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(30 * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullLiveInfo 获取直播信息
|
||||||
|
func (c *XuexitongCourse) PullLiveInfo(ctx context.Context, p *entity.PointLiveDto) error {
|
||||||
|
urlStr := fmt.Sprintf("https://mooc1.chaoxing.com/ananas/live/liveinfo?liveid=%s&userid=%s&clazzid=%s&knowledgeid=%d&courseid=%s&jobid=%s&ut=s",
|
||||||
|
p.LiveId, p.UserId, p.ClassID, p.KnowledgeID, p.CourseID, p.JobID)
|
||||||
|
|
||||||
|
var body string
|
||||||
|
err := retry.Do(func() error {
|
||||||
|
resp, err := c.client.R().SetHeaders(map[string]string{
|
||||||
|
"User-Agent": GetUA("web"),
|
||||||
|
}).Get(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode() != 200 {
|
||||||
|
return errors2.NewBusinessError(resp.StatusCode(), "获取直播信息失败")
|
||||||
|
}
|
||||||
|
body = resp.String()
|
||||||
|
return nil
|
||||||
|
}, retry.Attempts(3), retry.Delay(2*time.Second))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if status, ok := result["status"].(bool); ok && status {
|
||||||
|
if temp, ok := result["temp"].(map[string]interface{}); ok {
|
||||||
|
if data, ok := temp["data"].(map[string]interface{}); ok {
|
||||||
|
if percentValue, ok := data["percentValue"].(float64); ok {
|
||||||
|
p.VideoCompletePercent = percentValue
|
||||||
|
}
|
||||||
|
if duration, ok := data["duration"].(float64); ok {
|
||||||
|
p.VideoDuration = int(duration)
|
||||||
|
}
|
||||||
|
if liveStatus, ok := data["liveStatus"].(float64); ok {
|
||||||
|
p.LiveStatusCode = int(liveStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullLiveInfoByPoint 获取直播信息(使用统一的 ChapterPoint)
|
||||||
|
func (c *XuexitongCourse) PullLiveInfoByPoint(ctx context.Context, p *entity.ChapterPoint) error {
|
||||||
|
urlStr := fmt.Sprintf("https://mooc1.chaoxing.com/ananas/live/liveinfo?liveid=%s&userid=%s&clazzid=%s&knowledgeid=%d&courseid=%s&jobid=%s&ut=s",
|
||||||
|
p.LiveId, p.UserId, p.ClassID, p.KnowledgeID, p.CourseID, p.JobID)
|
||||||
|
|
||||||
|
var body string
|
||||||
|
err := retry.Do(func() error {
|
||||||
|
resp, err := c.client.R().SetHeaders(map[string]string{
|
||||||
|
"User-Agent": GetUA("web"),
|
||||||
|
}).Get(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode() != 200 {
|
||||||
|
return errors2.NewBusinessError(resp.StatusCode(), "获取直播信息失败")
|
||||||
|
}
|
||||||
|
body = resp.String()
|
||||||
|
return nil
|
||||||
|
}, retry.Attempts(3), retry.Delay(2*time.Second))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if status, ok := result["status"].(bool); ok && status {
|
||||||
|
if temp, ok := result["temp"].(map[string]interface{}); ok {
|
||||||
|
if data, ok := temp["data"].(map[string]interface{}); ok {
|
||||||
|
if percentValue, ok := data["percentValue"].(float64); ok {
|
||||||
|
p.VideoCompletePercent = percentValue
|
||||||
|
}
|
||||||
|
if duration, ok := data["duration"].(float64); ok {
|
||||||
|
p.VideoDuration = int(duration)
|
||||||
|
}
|
||||||
|
if liveStatus, ok := data["liveStatus"].(float64); ok {
|
||||||
|
p.LiveStatusCode = int(liveStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiveRelationReport 建立直播连接
|
||||||
|
func (c *XuexitongCourse) LiveRelationReport(ctx context.Context, p *entity.PointLiveDto) (string, error) {
|
||||||
|
urlStr := fmt.Sprintf("https://mooc1.chaoxing.com/mooc-ans/live/relation?courseid=%s&knowledgeid=%d&ut=s&jobid=%s&aid=%s",
|
||||||
|
p.CourseID, p.KnowledgeID, p.JobID, p.Aid)
|
||||||
|
|
||||||
|
var body string
|
||||||
|
err := retry.Do(func() error {
|
||||||
|
resp, err := c.client.R().SetHeaders(map[string]string{
|
||||||
|
"User-Agent": GetUA("web"),
|
||||||
|
}).Get(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode() != 200 {
|
||||||
|
return errors2.NewBusinessError(resp.StatusCode(), "建立直播连接失败")
|
||||||
|
}
|
||||||
|
body = resp.String()
|
||||||
|
return nil
|
||||||
|
}, retry.Attempts(3), retry.Delay(2*time.Second))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiveRelationReportByPoint 建立直播连接(使用统一的 ChapterPoint)
|
||||||
|
func (c *XuexitongCourse) LiveRelationReportByPoint(ctx context.Context, p *entity.ChapterPoint) (string, error) {
|
||||||
|
urlStr := fmt.Sprintf("https://mooc1.chaoxing.com/mooc-ans/live/relation?courseid=%s&knowledgeid=%d&ut=s&jobid=%s&aid=%s",
|
||||||
|
p.CourseID, p.KnowledgeID, p.JobID, p.Aid)
|
||||||
|
|
||||||
|
var body string
|
||||||
|
err := retry.Do(func() error {
|
||||||
|
resp, err := c.client.R().SetHeaders(map[string]string{
|
||||||
|
"User-Agent": GetUA("web"),
|
||||||
|
}).Get(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode() != 200 {
|
||||||
|
return errors2.NewBusinessError(resp.StatusCode(), "建立直播连接失败")
|
||||||
|
}
|
||||||
|
body = resp.String()
|
||||||
|
return nil
|
||||||
|
}, retry.Attempts(3), retry.Delay(2*time.Second))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiveSaveTimeReport 提交直播观看进度
|
||||||
|
func (c *XuexitongCourse) LiveSaveTimeReport(ctx context.Context, p *entity.PointLiveDto) (string, error) {
|
||||||
|
urlStr := fmt.Sprintf("https://zhibo.chaoxing.com/saveTimePc?streamName=%s&vdoid=%s&userId=%s&isStart=1&t=%d&courseId=%s",
|
||||||
|
p.StreamName, p.Vdoid, p.UserId, time.Now().UnixMilli(), p.CourseID)
|
||||||
|
|
||||||
|
var body string
|
||||||
|
err := retry.Do(func() error {
|
||||||
|
resp, err := c.client.R().SetHeaders(map[string]string{
|
||||||
|
"User-Agent": GetUA("web"),
|
||||||
|
}).Get(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode() != 200 {
|
||||||
|
return errors2.NewBusinessError(resp.StatusCode(), "提交直播观看进度失败")
|
||||||
|
}
|
||||||
|
body = resp.String()
|
||||||
|
return nil
|
||||||
|
}, retry.Attempts(3), retry.Delay(2*time.Second))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiveSaveTimeReportByPoint 提交直播观看进度(使用统一的 ChapterPoint)
|
||||||
|
func (c *XuexitongCourse) LiveSaveTimeReportByPoint(ctx context.Context, p *entity.ChapterPoint) (string, error) {
|
||||||
|
urlStr := fmt.Sprintf("https://zhibo.chaoxing.com/saveTimePc?streamName=%s&vdoid=%s&userId=%s&isStart=1&t=%d&courseId=%s",
|
||||||
|
p.StreamName, p.Vdoid, p.UserId, time.Now().UnixMilli(), p.CourseID)
|
||||||
|
|
||||||
|
var body string
|
||||||
|
err := retry.Do(func() error {
|
||||||
|
resp, err := c.client.R().SetHeaders(map[string]string{
|
||||||
|
"User-Agent": GetUA("web"),
|
||||||
|
}).Get(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode() != 200 {
|
||||||
|
return errors2.NewBusinessError(resp.StatusCode(), "提交直播观看进度失败")
|
||||||
|
}
|
||||||
|
body = resp.String()
|
||||||
|
return nil
|
||||||
|
}, retry.Attempts(3), retry.Delay(2*time.Second))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
@@ -2,11 +2,37 @@ package xuexitong
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"likedream-goframe/internal/application/quesbank"
|
||||||
|
"likedream-goframe/internal/common/db"
|
||||||
|
"likedream-goframe/internal/ent/sysconfig"
|
||||||
"likedream-goframe/internal/framework/errors"
|
"likedream-goframe/internal/framework/errors"
|
||||||
|
"likedream-goframe/internal/framework/logger"
|
||||||
"likedream-goframe/internal/utils"
|
"likedream-goframe/internal/utils"
|
||||||
"testing"
|
"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) {
|
func TestSchedule(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
xuexitongLogin := NewXuexitongLogin(ctx)
|
xuexitongLogin := NewXuexitongLogin(ctx)
|
||||||
@@ -17,7 +43,20 @@ func TestSchedule(t *testing.T) {
|
|||||||
courseService := NewXuexitongCourse(client)
|
courseService := NewXuexitongCourse(client)
|
||||||
|
|
||||||
// 人人必修的人工智能课
|
// 人人必修的人工智能课
|
||||||
err = courseService.AutoStudyByCourseID(ctx, "258109701")
|
config := getAIConfig(ctx)
|
||||||
|
if config != nil {
|
||||||
|
logger.Logger.Info("AI配置加载成功",
|
||||||
|
zap.String("aiType", string(config.Type)),
|
||||||
|
zap.String("apiURL", config.URL),
|
||||||
|
zap.String("model", config.Model))
|
||||||
|
} else {
|
||||||
|
logger.Logger.Warn("AI配置为空,作业将被跳过")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = courseService.AutoStudyByCourseIDWithOptions(ctx, "258109701", &AutoStudyOptions{
|
||||||
|
AIConfig: config,
|
||||||
|
AutoSubmit: true, // 默认不自动提交,仅保存
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.OkOrPanic(errors.NewBusinessErrorWithDetail(503, "刷课失败", err))
|
utils.OkOrPanic(errors.NewBusinessErrorWithDetail(503, "刷课失败", err))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
formData := url.Values{}
|
||||||
|
pyFlag := ""
|
||||||
|
if isSave {
|
||||||
|
pyFlag = "1"
|
||||||
|
}
|
||||||
|
formData.Set("pyFlag", pyFlag)
|
||||||
|
formData.Set("courseId", workDto.CourseId)
|
||||||
|
formData.Set("classId", workDto.ClassId)
|
||||||
|
formData.Set("api", workDto.Api)
|
||||||
|
formData.Set("workAnswerId", workDto.WorkAnswerId)
|
||||||
|
formData.Set("answerId", workDto.AnswerId)
|
||||||
|
formData.Set("totalQuestionNum", workDto.TotalQuestionNum)
|
||||||
|
formData.Set("fullScore", workDto.FullScore)
|
||||||
|
formData.Set("knowledgeid", workDto.KnowledgeId)
|
||||||
|
formData.Set("oldSchoolId", workDto.OldSchoolId)
|
||||||
|
formData.Set("oldWorkId", workDto.OldWorkId)
|
||||||
|
formData.Set("jobid", workDto.JobId)
|
||||||
|
formData.Set("workRelationId", workDto.WorkRelationId)
|
||||||
|
formData.Set("enc", "")
|
||||||
|
formData.Set("enc_work", workDto.EncWork)
|
||||||
|
formData.Set("userId", workDto.UserId)
|
||||||
|
formData.Set("cpi", workDto.Cpi)
|
||||||
|
formData.Set("workTimesEnc", "")
|
||||||
|
formData.Set("randomOptions", "false")
|
||||||
|
formData.Set("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, "")
|
||||||
|
formData.Set("answer"+q.Qid, answer)
|
||||||
|
formData.Set("answertype"+q.Qid, "0")
|
||||||
|
|
||||||
|
case entity.QuestionTypeMultipleChoice:
|
||||||
|
answer := strings.Join(q.Answers, "")
|
||||||
|
formData.Set("answer"+q.Qid, answer)
|
||||||
|
formData.Set("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 {
|
||||||
|
formData.Set("answer"+q.Qid+numbers[0], val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
formData.Set("tiankongsize"+q.Qid, strconv.Itoa(q.BlankCount))
|
||||||
|
formData.Set("answertype"+q.Qid, "2")
|
||||||
|
|
||||||
|
case entity.QuestionTypeTrueFalse:
|
||||||
|
answer := ""
|
||||||
|
for _, a := range q.Answers {
|
||||||
|
if a == "正确" {
|
||||||
|
answer = "true"
|
||||||
|
} else if a == "错误" {
|
||||||
|
answer = "false"
|
||||||
|
} else {
|
||||||
|
answer = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
formData.Set("answer"+q.Qid, answer)
|
||||||
|
formData.Set("answertype"+q.Qid, "3")
|
||||||
|
|
||||||
|
case entity.QuestionTypeShortAnswer:
|
||||||
|
answer := strings.Join(q.Answers, "")
|
||||||
|
formData.Set("answer"+q.Qid, answer)
|
||||||
|
formData.Set("answertype"+q.Qid, "4")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
formData.Set("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",
|
||||||
|
}).SetBody(formData.Encode()).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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -4,12 +4,15 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/gob"
|
"encoding/gob"
|
||||||
|
"encoding/json"
|
||||||
|
"likedream-goframe/internal/application/quesbank"
|
||||||
"likedream-goframe/internal/application/xuexitong"
|
"likedream-goframe/internal/application/xuexitong"
|
||||||
"likedream-goframe/internal/application/xuexitong/entity"
|
"likedream-goframe/internal/application/xuexitong/entity"
|
||||||
"likedream-goframe/internal/common/db"
|
"likedream-goframe/internal/common/db"
|
||||||
errors2 "likedream-goframe/internal/common/errors"
|
errors2 "likedream-goframe/internal/common/errors"
|
||||||
"likedream-goframe/internal/ent"
|
"likedream-goframe/internal/ent"
|
||||||
"likedream-goframe/internal/ent/courseentity"
|
"likedream-goframe/internal/ent/courseentity"
|
||||||
|
"likedream-goframe/internal/ent/sysconfig"
|
||||||
"likedream-goframe/internal/framework/errors"
|
"likedream-goframe/internal/framework/errors"
|
||||||
"likedream-goframe/internal/framework/logger"
|
"likedream-goframe/internal/framework/logger"
|
||||||
"likedream-goframe/internal/utils"
|
"likedream-goframe/internal/utils"
|
||||||
@@ -20,6 +23,25 @@ import (
|
|||||||
"go.uber.org/zap"
|
"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{}
|
type XuexitongTaskHandler struct{}
|
||||||
|
|
||||||
func (h *XuexitongTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task) (err error) {
|
func (h *XuexitongTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task) (err error) {
|
||||||
@@ -132,8 +154,6 @@ func (h *XuexitongTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 开始刷课前,先获取并更新任务点统计
|
// 开始刷课前,先获取并更新任务点统计
|
||||||
logger.Logger.Info("课程正在获取任务点统计...", zap.String("courseName", targetCourse.CourseName))
|
|
||||||
|
|
||||||
chapters := courseService.PullCourseChapterList(ctx, *course)
|
chapters := courseService.PullCourseChapterList(ctx, *course)
|
||||||
// FetchChapterPointStatus 会更新 chapters 中每个章节的 PointTotal 和 PointFinish
|
// FetchChapterPointStatus 会更新 chapters 中每个章节的 PointTotal 和 PointFinish
|
||||||
_, statusErr := courseService.FetchChapterPointStatus(ctx, *course, chapters)
|
_, statusErr := courseService.FetchChapterPointStatus(ctx, *course, chapters)
|
||||||
@@ -166,8 +186,14 @@ func (h *XuexitongTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task
|
|||||||
ExecX(ctx)
|
ExecX(ctx)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 执行刷课
|
// 获取AI配置(用于作业答题)
|
||||||
err = courseService.AutoStudyByCourseID(ctx, targetCourse.CourseID)
|
aiConfig := getAIConfig(ctx)
|
||||||
|
|
||||||
|
// 执行刷课(带AI配置)
|
||||||
|
err = courseService.AutoStudyByCourseIDWithOptions(ctx, targetCourse.CourseID, &xuexitong.AutoStudyOptions{
|
||||||
|
AIConfig: aiConfig,
|
||||||
|
AutoSubmit: false, // 默认不自动提交,仅保存
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 如果是时间限制导致的停止,不算失败
|
// 如果是时间限制导致的停止,不算失败
|
||||||
if err == xuexitong.ErrTimeRestriction {
|
if err == xuexitong.ErrTimeRestriction {
|
||||||
|
|||||||
@@ -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] + "..."
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ type globalService struct {
|
|||||||
Course *course.CourseServiceV1
|
Course *course.CourseServiceV1
|
||||||
SmartEduSC *course.SmartEduSCServiceV1
|
SmartEduSC *course.SmartEduSCServiceV1
|
||||||
Config *config.ConfigServiceV1
|
Config *config.ConfigServiceV1
|
||||||
|
AIConfig *config.AIConfigServiceV1
|
||||||
Balance *balance.BalanceRecordServiceV1
|
Balance *balance.BalanceRecordServiceV1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,5 +52,6 @@ var Global = globalService{
|
|||||||
Course: course.NewCourseServiceV1(),
|
Course: course.NewCourseServiceV1(),
|
||||||
SmartEduSC: course.NewSmartEduSCServiceV1(),
|
SmartEduSC: course.NewSmartEduSCServiceV1(),
|
||||||
Config: config.NewV1(),
|
Config: config.NewV1(),
|
||||||
|
AIConfig: config.NewAIConfigServiceV1(),
|
||||||
Balance: balance.NewBalanceRecordServiceV1(),
|
Balance: balance.NewBalanceRecordServiceV1(),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user