Compare commits

...

7 Commits

Author SHA1 Message Date
sugarBecomer d0e7652bf6 🐛 学习通答题 2025-12-12 11:37:33 +08:00
sugarBecomer 07e2438f93 🐛 取消部分日志 2025-12-11 16:50:31 +08:00
sugarBecomer d1b954ab8e 🐛 学习通答题 2025-12-11 16:49:02 +08:00
sugarBecomer 24da8bde3a 🐛 学习通日志 2025-12-10 19:56:33 +08:00
sugarBecomer 16692177d4 🐛 学习通列表 2025-12-10 15:10:39 +08:00
sugarBecomer 5f81a7ba5e 🐛 学习通刷课修复 2025-12-09 23:36:13 +08:00
sugarBecomer 58fb282fe8 🐛 学习通优化 2025-12-09 22:06:07 +08:00
28 changed files with 4357 additions and 1883 deletions
+289
View File
@@ -0,0 +1,289 @@
# AI 答题配置接口文档
## 概述
AI 答题功能用于在刷课过程中自动完成作业任务点。系统支持多种 AI 服务商,管理员可以通过配置接口设置 AI 参数。
核心代码位于 `internal/application/quesbank` 包,提供通用的题库答题能力,可被不同平台(学习通、智慧教育等)复用。
## 接口列表
### 1. 获取支持的 AI 类型列表
获取系统支持的所有 AI 类型及其说明。
**请求**
```
GET /v1/system/ai-config/types
```
**响应**
```json
{
"code": 0,
"message": "success",
"data": {
"types": [
{
"type": "chatglm",
"name": "智谱AI (ChatGLM)",
"description": "智谱AI大模型,支持glm-4等模型",
"needUrl": false
},
{
"type": "xinghuo",
"name": "讯飞星火",
"description": "讯飞星火认知大模型",
"needUrl": false
},
{
"type": "tongyi",
"name": "通义千问",
"description": "阿里云通义千问大模型",
"needUrl": false
},
{
"type": "doubao",
"name": "豆包",
"description": "字节跳动豆包大模型",
"needUrl": false
},
{
"type": "openai",
"name": "OpenAI",
"description": "OpenAI GPT系列模型",
"needUrl": false
},
{
"type": "other",
"name": "其他 (兼容OpenAI格式)",
"description": "支持任何兼容OpenAI API格式的服务",
"needUrl": true
}
]
}
}
```
---
### 2. 获取当前 AI 配置
获取当前系统配置的 AI 参数(API密钥会脱敏显示)。
**请求**
```
GET /v1/system/ai-config
```
**响应**
```json
{
"code": 0,
"message": "success",
"data": {
"type": "chatglm",
"url": "",
"model": "glm-4",
"apiKey": "sk-1****************************xyz"
}
}
```
**字段说明**
| 字段 | 类型 | 说明 |
|------|------|------|
| type | string | AI类型,可选值见类型列表 |
| url | string | API地址,仅 `other` 类型需要 |
| model | string | 模型名称 |
| apiKey | string | API密钥(脱敏显示) |
---
### 3. 更新 AI 配置
更新系统的 AI 配置参数。
**请求**
```
PUT /v1/system/ai-config
```
**权限**
需要 `sys:ai:edit` 权限
**请求体**
```json
{
"type": "chatglm",
"url": "",
"model": "glm-4",
"apiKey": "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
```
**参数说明**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| type | string | 是 | AI类型,可选值:`chatglm`, `xinghuo`, `tongyi`, `doubao`, `openai`, `other` |
| url | string | 否 | API地址,当 type 为 `other` 时必填 |
| model | string | 否 | 模型名称,不填则使用默认模型 |
| apiKey | string | 是 | API密钥 |
**各 AI 类型默认模型**
| AI类型 | 默认模型 |
|--------|----------|
| chatglm | glm-4 |
| xinghuo | generalv3.5 |
| tongyi | qwen-plus |
| doubao | 需指定 |
| openai | gpt-3.5-turbo |
| other | 需指定 |
**响应**
```json
{
"code": 0,
"message": "AI配置更新成功"
}
```
---
### 4. 测试 AI 配置
测试 AI 配置是否可用,发送测试消息验证连接。
**请求**
```
POST /v1/system/ai-config/test
```
**权限**
需要 `sys:ai:edit` 权限
**请求体**
```json
{
"type": "chatglm",
"url": "",
"model": "glm-4",
"apiKey": "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
```
**响应**
成功:
```json
{
"code": 0,
"message": "success",
"data": {
"success": true,
"message": "AI连接测试成功,响应: 测试成功..."
}
}
```
失败:
```json
{
"code": 0,
"message": "success",
"data": {
"success": false,
"message": "AI连接测试失败: failed to execute request: ..."
}
}
```
---
## AI 服务商配置指南
### 智谱AI (ChatGLM)
1. 访问 [智谱AI开放平台](https://open.bigmodel.cn/)
2. 注册并创建 API Key
3. 配置参数:
- type: `chatglm`
- model: `glm-4` 或 `glm-3-turbo`
- apiKey: 你的 API Key
### 讯飞星火
1. 访问 [讯飞开放平台](https://www.xfyun.cn/)
2. 创建应用并获取 API Key
3. 配置参数:
- type: `xinghuo`
- model: `generalv3.5` 或其他版本
- apiKey: 你的 API Key
### 通义千问
1. 访问 [阿里云百炼](https://dashscope.aliyun.com/)
2. 开通服务并获取 API Key
3. 配置参数:
- type: `tongyi`
- model: `qwen-plus` 或 `qwen-turbo`
- apiKey: 你的 API Key
### 豆包
1. 访问 [火山引擎](https://www.volcengine.com/)
2. 开通豆包大模型服务
3. 配置参数:
- type: `doubao`
- model: 你的模型端点 ID
- apiKey: 你的 API Key
### OpenAI
1. 访问 [OpenAI Platform](https://platform.openai.com/)
2. 创建 API Key
3. 配置参数:
- type: `openai`
- model: `gpt-3.5-turbo` 或 `gpt-4`
- apiKey: 你的 API Key
### 其他兼容服务
支持任何兼容 OpenAI API 格式的服务(如 DeepSeek、Moonshot 等)。
配置参数:
- type: `other`
- url: API 地址(如 `https://api.deepseek.com/v1/chat/completions`)
- model: 模型名称
- apiKey: 你的 API Key
---
## 使用说明
1. 配置 AI 后,系统在执行刷课任务时会自动使用 AI 完成作业任务点
2. 作业答案默认仅保存不提交,需要用户手动确认后提交
3. 如果未配置 AI,作业任务点将被跳过
4. 建议先使用测试接口验证配置是否正确
## 错误码
| 错误码 | 说明 |
|--------|------|
| 400 | 参数错误或配置不完整 |
| 403 | 无权限操作 |
| 500 | 服务器内部错误 |
+1 -1
View File
@@ -31,6 +31,7 @@ require (
go.uber.org/automaxprocs v1.6.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.43.0
golang.org/x/net v0.45.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
resty.dev/v3 v3.0.0-beta.3
@@ -106,7 +107,6 @@ require (
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
golang.org/x/image v0.29.0 // indirect
golang.org/x/mod v0.28.0 // indirect
golang.org/x/net v0.45.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
+1
View File
@@ -44,6 +44,7 @@ func StartServer(ctx context.Context) {
systemGroup.Bind(menu.NewV1())
systemGroup.Bind(user.NewUserCtrlV1())
systemGroup.Bind(config.NewConfigCtrlV1())
systemGroup.Bind(config.NewAIConfigCtrlV1())
//systemGroup.Bind(agent.NewV1())
// 用户信息路由
userGroup := group.Group("/user").Middleware(middleware.AuthHandler, middleware.PermissionHandler)
+60
View File
@@ -0,0 +1,60 @@
package v1
import (
"github.com/gogf/gf/v2/frame/g"
)
// AIConfigGetReq 获取AI配置请求
type AIConfigGetReq struct {
g.Meta `path:"/ai-config" method:"get" tags:"AIConfig" summary:"获取AI配置"`
}
// AIConfigGetResp AI配置响应
type AIConfigGetResp struct {
Type string `json:"type"` // AI类型: chatglm, xinghuo, tongyi, doubao, openai, other
URL string `json:"url"` // API地址(other类型必填)
Model string `json:"model"` // 模型名称
APIKey string `json:"apiKey"` // API密钥(返回时脱敏)
}
// AIConfigUpdateReq 更新AI配置请求
type AIConfigUpdateReq struct {
g.Meta `path:"/ai-config" method:"put" tags:"AIConfig" summary:"更新AI配置" permission:"sys:ai:edit"`
Type string `json:"type" v:"required|in:chatglm,xinghuo,tongyi,doubao,openai,other#AI类型不能为空|AI类型不合法"`
URL string `json:"url"` // API地址(other类型必填)
Model string `json:"model"` // 模型名称
APIKey string `json:"apiKey" v:"required#API密钥不能为空"` // API密钥
}
// AIConfigTestReq 测试AI配置请求
type AIConfigTestReq struct {
g.Meta `path:"/ai-config/test" method:"post" tags:"AIConfig" summary:"测试AI配置" permission:"sys:ai:edit"`
Type string `json:"type" v:"required|in:chatglm,xinghuo,tongyi,doubao,openai,other#AI类型不能为空|AI类型不合法"`
URL string `json:"url"`
Model string `json:"model"`
APIKey string `json:"apiKey" v:"required#API密钥不能为空"`
}
// AIConfigTestResp 测试AI配置响应
type AIConfigTestResp struct {
Success bool `json:"success"` // 是否成功
Message string `json:"message"` // 测试结果消息
}
// AITypeListReq 获取支持的AI类型列表请求
type AITypeListReq struct {
g.Meta `path:"/ai-config/types" method:"get" tags:"AIConfig" summary:"获取支持的AI类型列表"`
}
// AITypeItem AI类型项
type AITypeItem struct {
Type string `json:"type"` // 类型标识
Name string `json:"name"` // 显示名称
Description string `json:"description"` // 描述
NeedURL bool `json:"needUrl"` // 是否需要自定义URL
}
// AITypeListResp AI类型列表响应
type AITypeListResp struct {
Types []AITypeItem `json:"types"`
}
@@ -0,0 +1,32 @@
package quesbank
// AIType AI类型
type AIType string
const (
AITypeChatGLM AIType = "chatglm" // 智谱AI
AITypeXingHuo AIType = "xinghuo" // 讯飞星火
AITypeTongYi AIType = "tongyi" // 通义千问
AITypeDouBao AIType = "doubao" // 豆包
AITypeOpenAI AIType = "openai" // OpenAI
AITypeOther AIType = "other" // 其他兼容OpenAI格式的API
)
// AIConfig AI配置
type AIConfig struct {
Type AIType `json:"type"` // AI类型
URL string `json:"url"` // API地址(Other类型必填)
Model string `json:"model"` // 模型名称
APIKey string `json:"apiKey"` // API密钥
}
// AIChatMessage 聊天消息
type AIChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
// AIChatMessages 聊天消息列表
type AIChatMessages struct {
Messages []AIChatMessage `json:"messages"`
}
+173
View File
@@ -0,0 +1,173 @@
package quesbank
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sync"
"time"
)
// AIAnswerService AI答题服务
type AIAnswerService struct {
config AIConfig
mu sync.Mutex
}
// NewAIAnswerService 创建AI答题服务
func NewAIAnswerService(config AIConfig) *AIAnswerService {
return &AIAnswerService{
config: config,
}
}
// GetAnswer 获取AI答案
func (s *AIAnswerService) GetAnswer(ctx context.Context, messages AIChatMessages) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
switch s.config.Type {
case AITypeChatGLM:
return s.chatGLMRequest(ctx, messages)
case AITypeXingHuo:
return s.xingHuoRequest(ctx, messages)
case AITypeTongYi:
return s.tongYiRequest(ctx, messages)
case AITypeDouBao:
return s.douBaoRequest(ctx, messages)
case AITypeOpenAI:
return s.openAIRequest(ctx, messages)
case AITypeOther:
return s.otherRequest(ctx, messages)
default:
return "", fmt.Errorf("unsupported AI type: %s", s.config.Type)
}
}
// chatGLMRequest 智谱AI请求
func (s *AIAnswerService) chatGLMRequest(ctx context.Context, messages AIChatMessages) (string, error) {
url := "https://open.bigmodel.cn/api/paas/v4/chat/completions"
model := s.config.Model
if model == "" {
model = "glm-4"
}
return s.doOpenAIStyleRequest(ctx, url, model, messages)
}
// xingHuoRequest 讯飞星火请求
func (s *AIAnswerService) xingHuoRequest(ctx context.Context, messages AIChatMessages) (string, error) {
url := "https://spark-api-open.xf-yun.com/v1/chat/completions"
model := s.config.Model
if model == "" {
model = "generalv3.5"
}
return s.doOpenAIStyleRequest(ctx, url, model, messages)
}
// tongYiRequest 通义千问请求
func (s *AIAnswerService) tongYiRequest(ctx context.Context, messages AIChatMessages) (string, error) {
url := "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"
model := s.config.Model
if model == "" {
model = "qwen-plus"
}
return s.doOpenAIStyleRequest(ctx, url, model, messages)
}
// douBaoRequest 豆包请求
func (s *AIAnswerService) douBaoRequest(ctx context.Context, messages AIChatMessages) (string, error) {
url := "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
return s.doOpenAIStyleRequest(ctx, url, s.config.Model, messages)
}
// openAIRequest OpenAI请求
func (s *AIAnswerService) openAIRequest(ctx context.Context, messages AIChatMessages) (string, error) {
url := "https://api.openai.com/v1/chat/completions"
model := s.config.Model
if model == "" {
model = "gpt-3.5-turbo"
}
return s.doOpenAIStyleRequest(ctx, url, model, messages)
}
// otherRequest 其他兼容OpenAI格式的API请求
func (s *AIAnswerService) otherRequest(ctx context.Context, messages AIChatMessages) (string, error) {
if s.config.URL == "" {
return "", errors.New("API URL is required for 'other' AI type")
}
return s.doOpenAIStyleRequest(ctx, s.config.URL, s.config.Model, messages)
}
// doOpenAIStyleRequest 执行OpenAI风格的API请求
func (s *AIAnswerService) doOpenAIStyleRequest(ctx context.Context, url, model string, messages AIChatMessages) (string, error) {
requestBody := map[string]interface{}{
"model": model,
"messages": messages.Messages,
}
jsonData, err := json.Marshal(requestBody)
if err != nil {
return "", fmt.Errorf("failed to marshal request: %v", err)
}
var lastErr error
for retry := 0; retry < 3; retry++ {
result, err := s.doHTTPRequest(ctx, url, jsonData)
if err == nil {
return result, nil
}
lastErr = err
time.Sleep(100 * time.Millisecond)
}
return "", lastErr
}
// doHTTPRequest 执行HTTP请求
func (s *AIAnswerService) doHTTPRequest(ctx context.Context, url string, jsonData []byte) (string, error) {
client := &http.Client{Timeout: 60 * time.Second}
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("failed to create request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+s.config.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to execute request: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %v", err)
}
var responseMap map[string]interface{}
if err := json.Unmarshal(body, &responseMap); err != nil {
return "", fmt.Errorf("failed to parse response: %v, body: %s", err, string(body))
}
choices, ok := responseMap["choices"].([]interface{})
if !ok || len(choices) == 0 {
return "", fmt.Errorf("AI response not found, response: %s", string(body))
}
message, ok := choices[0].(map[string]interface{})["message"].(map[string]interface{})
if !ok {
return "", errors.New("failed to parse message from response")
}
content, ok := message["content"].(string)
if !ok {
return "", errors.New("content field missing in response")
}
return content, nil
}
@@ -0,0 +1,97 @@
package quesbank
import (
"encoding/json"
"strings"
)
// ParseAIAnswer 解析AI答案
func ParseAIAnswer(aiResponse string) ([]string, error) {
aiResponse = strings.TrimSpace(aiResponse)
// 尝试直接解析JSON数组
var answers []string
if err := json.Unmarshal([]byte(aiResponse), &answers); err == nil {
return answers, nil
}
// 尝试从响应中提取JSON数组
start := strings.Index(aiResponse, "[")
end := strings.LastIndex(aiResponse, "]")
if start != -1 && end != -1 && end > start {
jsonStr := aiResponse[start : end+1]
if err := json.Unmarshal([]byte(jsonStr), &answers); err == nil {
return answers, nil
}
}
// 如果都失败,返回原始响应作为单个答案
return []string{aiResponse}, nil
}
// MatchAnswerToOption 将AI答案匹配到选项
func MatchAnswerToOption(answer string, options map[string]string) string {
// 精确匹配
for key, val := range options {
if val == answer || strings.TrimSpace(val) == strings.TrimSpace(answer) {
return key
}
}
// 模糊匹配(包含关系)
for key, val := range options {
if strings.Contains(val, answer) || strings.Contains(answer, val) {
return key
}
}
// 相似度匹配
bestKey := ""
bestScore := 0.0
for key, val := range options {
score := similarity(answer, val)
if score > bestScore {
bestScore = score
bestKey = key
}
}
if bestScore >= 0.5 {
return bestKey
}
return ""
}
// similarity 计算字符串相似度(简单实现)
func similarity(s1, s2 string) float64 {
if s1 == s2 {
return 1.0
}
if len(s1) == 0 || len(s2) == 0 {
return 0.0
}
// 使用最长公共子序列计算相似度
lcs := longestCommonSubsequence(s1, s2)
return float64(lcs) / float64(max(len(s1), len(s2)))
}
// longestCommonSubsequence 计算最长公共子序列长度
func longestCommonSubsequence(s1, s2 string) int {
m, n := len(s1), len(s2)
dp := make([][]int, m+1)
for i := range dp {
dp[i] = make([]int, n+1)
}
for i := 1; i <= m; i++ {
for j := 1; j <= n; j++ {
if s1[i-1] == s2[j-1] {
dp[i][j] = dp[i-1][j-1] + 1
} else {
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
}
}
}
return dp[m][n]
}
+151
View File
@@ -0,0 +1,151 @@
package quesbank
import (
"fmt"
"strings"
)
// BuildQuestionPrompt 构建题目提示词
func BuildQuestionPrompt(question Question) AIChatMessages {
var messages []AIChatMessage
switch question.Type {
case QuestionTypeSingleChoice:
messages = buildSingleChoicePrompt(question)
case QuestionTypeMultipleChoice:
messages = buildMultipleChoicePrompt(question)
case QuestionTypeTrueFalse:
messages = buildTrueFalsePrompt(question)
case QuestionTypeFillBlank:
messages = buildFillBlankPrompt(question)
case QuestionTypeShortAnswer:
messages = buildShortAnswerPrompt(question)
default:
messages = buildDefaultPrompt(question)
}
return AIChatMessages{Messages: messages}
}
// buildSingleChoicePrompt 构建单选题提示词
func buildSingleChoicePrompt(q Question) []AIChatMessage {
context := buildQuestionContext(q)
return []AIChatMessage{
{Role: "user", Content: "接下来你只需要回答选项对应内容即可...格式:[\"选项1\"]"},
{Role: "user", Content: "就算你不知道选什么也随机选...无需回答任何解释!!!"},
{Role: "user", Content: singleChoiceExample()},
{Role: "user", Content: fmt.Sprintf("题目类型:单选题\n题目内容:%s", context)},
}
}
// buildMultipleChoicePrompt 构建多选题提示词
func buildMultipleChoicePrompt(q Question) []AIChatMessage {
context := buildQuestionContext(q)
return []AIChatMessage{
{Role: "user", Content: "接下来你只需要回答选项对应内容即可...格式:[\"选项1\",\"选项2\"]"},
{Role: "user", Content: "就算你不知道选什么也随机选...无需回答任何解释!!!"},
{Role: "user", Content: multipleChoiceExample()},
{Role: "user", Content: fmt.Sprintf("题目类型:多选题\n题目内容:%s", context)},
}
}
// buildTrueFalsePrompt 构建判断题提示词
func buildTrueFalsePrompt(q Question) []AIChatMessage {
context := buildQuestionContext(q)
return []AIChatMessage{
{Role: "user", Content: "接下来你只需要回答\"正确\"或者\"错误\"即可...格式:[\"正确\"]"},
{Role: "user", Content: "就算你不知道选什么也随机选...无需回答任何解释!!!"},
{Role: "user", Content: trueFalseExample()},
{Role: "user", Content: fmt.Sprintf("题目类型:判断题\n题目内容:%s", context)},
}
}
// buildFillBlankPrompt 构建填空题提示词
func buildFillBlankPrompt(q Question) []AIChatMessage {
return []AIChatMessage{
{Role: "user", Content: "其中,\"( )\"相关字样的地方是你需要填写答案的地方...格式:[\"答案1\",\"答案2\"]"},
{Role: "user", Content: "就算你不知道填什么也随机填...无需回答任何解释!!!"},
{Role: "user", Content: fillBlankExample()},
{Role: "user", Content: fmt.Sprintf("题目类型:填空题\n题目内容:%s", q.Text)},
}
}
// buildShortAnswerPrompt 构建简答题提示词
func buildShortAnswerPrompt(q Question) []AIChatMessage {
return []AIChatMessage{
{Role: "user", Content: "这是一个简答题...格式:[\"答案\"],注意不要拆分答案!!!"},
{Role: "user", Content: "就算你不知道答什么也随机答...无需回答任何解释!!!"},
{Role: "user", Content: shortAnswerExample()},
{Role: "user", Content: fmt.Sprintf("题目类型:简答题\n题目内容:%s", q.Text)},
}
}
// buildDefaultPrompt 构建默认提示词
func buildDefaultPrompt(q Question) []AIChatMessage {
context := buildQuestionContext(q)
return []AIChatMessage{
{Role: "user", Content: "请回答以下题目,格式:[\"答案\"]"},
{Role: "user", Content: context},
}
}
// buildQuestionContext 构建题目上下文
func buildQuestionContext(q Question) string {
var sb strings.Builder
sb.WriteString(q.Text)
sb.WriteString("\n")
for key, val := range q.Options {
sb.WriteString(fmt.Sprintf("%s. %s\n", key, val))
}
return sb.String()
}
func singleChoiceExample() string {
return `比如:
题目类型:单选题
题目内容:新中国是什么时候成立的
A. 1949年10月5日
B. 1949年10月1日
C. 1949年09月1日
D. 2002年10月1日
那么你应该回答选项B的内容:"["1949年10月1日"]"`
}
func multipleChoiceExample() string {
return `比如:
题目类型:多选题
题目内容:马克思关于资本积累的学说是剩余价值理论的重要组成部分...
A. 资本主义扩大再生产的源泉
B. 资本有机构成呈现不断降低趋势的根本原因
C. 社会财富占有两极分化的重要原因
D. 资本主义社会失业现象产生的根源
那么你应该回答选项A、C、D的内容:"["资本主义扩大再生产的源泉","社会财富占有两极分化的重要原因","资本主义社会失业现象产生的根源"]"`
}
func trueFalseExample() string {
return `比如:
题目类型:判断题
题目内容:新中国是1949年10月1日成立的吗?
A. 正确
B. 错误
那么你应该回答选项A的内容:"["正确"]"`
}
func fillBlankExample() string {
return `比如:
题目类型:填空题
题目内容:新中国成立于( )年。
那么你应该回答:"["1949"]"`
}
func shortAnswerExample() string {
return `比如:
题目类型:简答题
题目内容:请简述新中国成立的历史意义
那么你应该回答:"["新中国的成立标志着中国人民从此站起来了,结束了半殖民地半封建社会的历史..."]"`
}
@@ -0,0 +1 @@
package quesbank
+23
View File
@@ -0,0 +1,23 @@
package quesbank
// QuestionType 题目类型
type QuestionType int
const (
QuestionTypeSingleChoice QuestionType = 0 // 单选题
QuestionTypeMultipleChoice QuestionType = 1 // 多选题
QuestionTypeFillBlank QuestionType = 2 // 填空题
QuestionTypeTrueFalse QuestionType = 3 // 判断题
QuestionTypeShortAnswer QuestionType = 4 // 简答题
)
// Question 通用题目结构
type Question struct {
Qid string `json:"qid"` // 题目ID
Type QuestionType `json:"type"` // 题目类型
Text string `json:"text"` // 题目内容
Options map[string]string `json:"options"` // 选项 (A: xxx, B: xxx)
Answers []string `json:"answers"` // 答案
BlankCount int `json:"blankCount"` // 填空题空格数量
BlankAnswer map[string]string `json:"blankAnswer"` // 填空题答案
}
+145
View File
@@ -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
}
@@ -169,7 +169,7 @@ type AttachmentSetting struct {
Defaults map[string]interface{} `json:"defaults"`
}
// ChapterPoints 章节所有任务点集合
// ChapterPoints 章节所有任务点集合(旧版,保留兼容)
type ChapterPoints struct {
Videos []PointVideoDto
Documents []PointDocumentDto
@@ -178,6 +178,75 @@ type ChapterPoints struct {
Lives []PointLiveDto
}
// ChapterPointType 任务点类型枚举
type ChapterPointType int
const (
ChapterPointTypeVideo ChapterPointType = 1 // 视频
ChapterPointTypeDocument ChapterPointType = 2 // 文档
ChapterPointTypeWork ChapterPointType = 3 // 作业
ChapterPointTypeHyperlink ChapterPointType = 4 // 外链
ChapterPointTypeLive ChapterPointType = 5 // 直播
)
// ChapterPoint 统一的章节任务点结构(与Card一一对应)
type ChapterPoint struct {
CardIndex int // 对应的卡片索引
Type ChapterPointType // 任务点类型
KnowledgeID int // 知识点ID
CourseID string // 课程ID
ClassID string // 班级ID
Cpi string // cpi
JobID string // 任务ID
ObjectID string // 对象ID
Title string // 标题
IsJob bool // 是否是任务点
IsPassed bool // 是否已完成
// 视频相关字段
FID int
DToken string
PlayTime int
Duration int
OtherInfo string
RT float64
VideoFaceCaptureEnc string
RandomCaptureTime string
AttDurationEnc string
Enc string
Mid string
// 文档相关字段
Jtoken string
DocumentType string // document / insertbook
// 作业相关字段
WorkID string
SchoolID string
PUID string
KToken string
// 外链相关字段
LinkType int
// 直播相关字段
UserId string
Live bool
LiveId string
Vdoid string
StreamName string
LiveStatusStr string
LiveStatusCode int
VideoDuration int
Aid string
Module string
AuthEnc string
LiveDragEnc string
LiveSetEnc string
LiveSwDsEnc string
VideoCompletePercent float64
}
// QuestionType 题目类型
type QuestionType int
@@ -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&microTopicId=0",
ApiEnterChapter, course.CourseID, course.Key, chapterID, course.Cpi)
_, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("mobile"),
}).Get(urlStr)
return err
}
// FetchChapterCards 获取章节任务点卡片(JSON方式)- 支持所有任务点类型
func (c *XuexitongCourse) FetchChapterCards(ctx context.Context, course entity.XueXiTCourse, chapterID int) ([]entity.Card, []entity.ChapterPoint, error) {
var body string
courseID, _ := strconv.Atoi(course.CourseID)
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("mobile"),
"Accept-Language": "zh_CN",
}).SetQueryParams(map[string]string{
"id": strconv.Itoa(chapterID),
"courseid": strconv.Itoa(courseID),
"fields": "id,parentnodeid,indexorder,label,layer,name,begintime,createtime,lastmodifytime,status,jobUnfinishedCount,clickcount,openlock,card.fields(id,knowledgeid,title,knowledgeTitile,description,cardorder).contentcard(all)",
"view": "json",
"token": "4faa8662c59590c6f43ae9fe5b002b42",
"_time": strconv.FormatInt(time.Now().UnixMilli(), 10),
}).Get(ApiChapterCards)
if err != nil {
return err
}
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, "&quot;", "\"")
cleanedData = regexp.MustCompile(`\s+`).ReplaceAllString(cleanedData, "")
if err := json.Unmarshal([]byte(cleanedData), &attrs.Data); err != nil {
logger.Logger.Debug("Failed to decode JSON", zap.Error(err))
}
} else {
attrs.Other[attr.Key] = attr.Val
}
}
attrs.HasData = hasData
iframes = append(iframes, attrs)
}
for ch := n.FirstChild; ch != nil; ch = ch.NextSibling {
traverse(ch)
}
}
traverse(node)
return iframes, nil
}
// FetchChapterCards2 获取章节卡片(新接口,用于获取 otherInfo 等)
func (c *XuexitongCourse) FetchChapterCards2(ctx context.Context, course entity.XueXiTCourse, knowledgeID int) (map[string]interface{}, error) {
urlStr := fmt.Sprintf("%s?clazzid=%s&courseid=%s&knowledgeid=%d&num=0&ut=s&cpi=%d&v=2025-0424-1038-3&mooc2=1&isMicroCourse=false&editorPreview=0",
ApiChapterCards2, course.Key, course.CourseID, knowledgeID, course.Cpi)
logger.Logger.Debug("FetchChapterCards2 请求",
zap.String("url", urlStr),
zap.Int("knowledgeID", knowledgeID))
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("mobile"),
}).Get(urlStr)
if err != nil {
return err
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
logger.Logger.Error("FetchChapterCards2 请求失败", zap.Error(err))
return nil, err
}
logger.Logger.Debug("FetchChapterCards2 响应",
zap.Int("bodyLength", len(body)),
zap.Bool("containsMArg", strings.Contains(body, "mArg")))
re := regexp.MustCompile(`mArg = ([^;]{6,})`)
matches := re.FindStringSubmatch(body)
if len(matches) <= 1 {
logger.Logger.Warn("FetchChapterCards2 未找到 mArg")
return nil, nil
}
logger.Logger.Debug("FetchChapterCards2 提取到 mArg", zap.Int("jsonLength", len(matches[1])))
var result map[string]interface{}
if err := json.Unmarshal([]byte(matches[1]), &result); err != nil {
logger.Logger.Error("FetchChapterCards2 解析 JSON 失败", zap.Error(err))
return nil, err
}
if attachments, ok := result["attachments"].([]interface{}); ok {
logger.Logger.Debug("FetchChapterCards2 解析成功",
zap.Int("attachmentsCount", len(attachments)))
}
return result, nil
}
// PageMobileChapterCard 获取章节卡片页面(SSR方式,用于获取附件信息)
func (c *XuexitongCourse) PageMobileChapterCard(ctx context.Context, course entity.XueXiTCourse, knowledgeID, cardIndex int) (*entity.AttachmentSetting, string, error) {
classID, _ := strconv.Atoi(course.Key)
courseID, _ := strconv.Atoi(course.CourseID)
params := url.Values{}
params.Add("clazzid", strconv.Itoa(classID))
params.Add("courseid", strconv.Itoa(courseID))
params.Add("knowledgeid", strconv.Itoa(knowledgeID))
params.Add("num", strconv.Itoa(cardIndex))
params.Add("isPhone", "1")
params.Add("control", "true")
params.Add("cpi", strconv.Itoa(course.Cpi))
requestURL := PageChapterCard + "?" + params.Encode()
logger.Logger.Debug("PageMobileChapterCard 请求",
zap.String("url", requestURL),
zap.Int("knowledgeID", knowledgeID),
zap.Int("cardIndex", cardIndex))
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("mobile"),
}).Get(requestURL)
if err != nil {
return err
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
logger.Logger.Error("PageMobileChapterCard 请求失败", zap.Error(err))
return nil, "", err
}
logger.Logger.Debug("PageMobileChapterCard 响应",
zap.Int("bodyLength", len(body)),
zap.Bool("containsAttachmentSetting", strings.Contains(body, "window.AttachmentSetting")),
zap.Bool("containsBlankTips", strings.Contains(body, "blankTips")),
zap.String("bodyPreview", body[:min(len(body), 500)]))
doc, err := html.Parse(strings.NewReader(body))
if err != nil {
logger.Logger.Error("解析 HTML 失败", zap.Error(err))
return nil, "", err
}
var scriptContent string
var scriptCount int
var traverse func(*html.Node)
traverse = func(n *html.Node) {
if scriptContent != "" {
return
}
if n.Type == html.ElementNode && n.Data == "script" {
hasTypeAttr := false
for _, attr := range n.Attr {
if attr.Key == "type" && strings.TrimSpace(attr.Val) == "text/javascript" {
hasTypeAttr = true
break
}
}
if hasTypeAttr {
scriptCount++
if n.FirstChild != nil && n.FirstChild.Type == html.TextNode {
scriptContent = strings.TrimSpace(n.FirstChild.Data)
logger.Logger.Debug("找到 script 标签",
zap.Int("scriptIndex", scriptCount),
zap.Int("contentLength", len(scriptContent)),
zap.Bool("hasAttachmentSetting", strings.Contains(scriptContent, "window.AttachmentSetting")))
return
}
}
}
for ch := n.FirstChild; ch != nil; ch = ch.NextSibling {
traverse(ch)
}
}
traverse(doc)
logger.Logger.Debug("HTML 解析完成", zap.Int("scriptCount", scriptCount), zap.Bool("hasScriptContent", scriptContent != ""))
if scriptContent == "" || !strings.Contains(scriptContent, "window.AttachmentSetting") {
var blankTips string
traverse = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "p" {
for _, attr := range n.Attr {
if attr.Key == "class" && strings.Contains(attr.Val, "blankTips") {
if n.FirstChild != nil {
blankTips = strings.TrimSpace(n.FirstChild.Data)
}
return
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
traverse(c)
}
}
traverse(doc)
if blankTips != "" {
logger.Logger.Warn("章节提示信息", zap.String("blankTips", blankTips))
}
if strings.Contains(body, "window.AttachmentSetting") {
scriptContent = body
logger.Logger.Debug("从原始 body 中找到 window.AttachmentSetting")
} else {
logger.Logger.Warn("未找到 window.AttachmentSetting",
zap.Int("knowledgeID", knowledgeID),
zap.Int("cardIndex", cardIndex),
zap.String("blankTips", blankTips))
return nil, "", nil
}
}
re := regexp.MustCompile(`window\.AttachmentSetting\s*=\s*(\{.*?\});`)
matches := re.FindStringSubmatch(scriptContent)
var jsonStr string
if len(matches) > 1 {
jsonStr = matches[1]
logger.Logger.Debug("正则提取 JSON 成功", zap.Int("jsonLength", len(jsonStr)))
} else {
jsonStr = extractJSONObject(scriptContent, "window.AttachmentSetting")
if jsonStr == "" {
logger.Logger.Warn("无法提取 JSON",
zap.Int("scriptContentLength", len(scriptContent)))
return nil, "", nil
}
logger.Logger.Debug("括号匹配提取 JSON 成功", zap.Int("jsonLength", len(jsonStr)))
}
var attachment entity.AttachmentSetting
if err := json.Unmarshal([]byte(jsonStr), &attachment); err != nil {
logger.Logger.Error("解析 AttachmentSetting 失败",
zap.Error(err),
zap.String("jsonStr", jsonStr[:min(len(jsonStr), 500)]))
return nil, "", err
}
logger.Logger.Debug("解析 AttachmentSetting 成功",
zap.Int("attachmentsCount", len(attachment.Attachments)),
zap.Any("defaults", attachment.Defaults))
for idx, att := range attachment.Attachments {
property, _ := att["property"].(map[string]interface{})
objectid := ""
workid := ""
if property != nil {
if oid, ok := property["objectid"].(string); ok {
objectid = oid
}
if wid, ok := property["workid"].(string); ok {
workid = wid
}
}
otherInfo, _ := att["otherInfo"].(string)
attDurationEnc, _ := att["attDurationEnc"].(string)
videoFaceCaptureEnc, _ := att["videoFaceCaptureEnc"].(string)
enc, _ := att["enc"].(string)
logger.Logger.Debug("附件信息",
zap.Int("index", idx),
zap.String("objectid", objectid),
zap.String("workid", workid),
zap.String("enc", enc),
zap.Bool("hasOtherInfo", otherInfo != ""),
zap.Bool("hasAttDurationEnc", attDurationEnc != ""),
zap.Bool("hasVideoFaceCaptureEnc", videoFaceCaptureEnc != ""))
}
reEnc := regexp.MustCompile(`<input type="hidden" id="from" value="[^_]+_[^_]+_[^_]+_([^"]+)"/>`)
matchesEnc := reEnc.FindStringSubmatch(body)
enc := ""
if len(matchesEnc) > 1 {
enc = matchesEnc[1]
}
return &attachment, enc, nil
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,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&microTopicId=undefined&_dc=%d",
p.JobID, p.KnowledgeID, p.CourseID, p.ClassID, p.Jtoken, time.Now().UnixMilli())
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("web"),
}).Get(urlStr)
if err != nil {
return err
}
if resp.StatusCode() != 200 {
return errors2.NewBusinessError(resp.StatusCode(), "提交外链完成失败")
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return "", err
}
return body, nil
}
// HyperlinkDtoCompleteReportByPoint 提交外链完成(使用统一的 ChapterPoint)
func (c *XuexitongCourse) HyperlinkDtoCompleteReportByPoint(ctx context.Context, p *entity.ChapterPoint) (string, error) {
urlStr := fmt.Sprintf("https://mooc1.chaoxing.com/ananas/job/hyperlink?jobid=%s&knowledgeid=%d&courseid=%s&clazzid=%s&jtoken=%s&checkMicroTopic=true&microTopicId=undefined&_dc=%d",
p.JobID, p.KnowledgeID, p.CourseID, p.ClassID, p.Jtoken, time.Now().UnixMilli())
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("web"),
}).Get(urlStr)
if err != nil {
return err
}
if resp.StatusCode() != 200 {
return errors2.NewBusinessError(resp.StatusCode(), "提交外链完成失败")
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return "", err
}
return body, nil
}
@@ -0,0 +1,413 @@
package xuexitong
import (
"context"
"encoding/json"
"fmt"
"likedream-goframe/internal/application/xuexitong/entity"
errors2 "likedream-goframe/internal/common/errors"
"likedream-goframe/internal/framework/logger"
"strconv"
"time"
"github.com/avast/retry-go/v4"
"go.uber.org/zap"
)
// attachmentDetectLive 从附件设置中检测并填充直播信息
func (c *XuexitongCourse) attachmentDetectLive(attachment *entity.AttachmentSetting, p *entity.ChapterPoint) {
for _, att := range attachment.Attachments {
jobid, _ := att["jobid"].(string)
if jobid != p.JobID {
continue
}
if authEnc, ok := att["authEnc"].(string); ok {
p.AuthEnc = authEnc
}
if liveDragEnc, ok := att["liveDragEnc"].(string); ok {
p.LiveDragEnc = liveDragEnc
}
if liveSetEnc, ok := att["liveSetEnc"].(string); ok {
p.LiveSetEnc = liveSetEnc
}
if otherInfo, ok := att["otherInfo"].(string); ok {
p.OtherInfo = otherInfo
}
if enc, ok := att["enc"].(string); ok {
p.Enc = enc
}
if liveSwDsEnc, ok := att["liveSwDsEnc"].(string); ok {
p.LiveSwDsEnc = liveSwDsEnc
}
if isJob, ok := att["job"].(bool); ok {
p.IsJob = isJob
}
if aid, ok := att["aid"].(float64); ok {
p.Aid = strconv.FormatInt(int64(aid), 10)
}
break
}
}
// AttachmentsDetectionLive 从附件设置中检测并填充直播信息
func (c *XuexitongCourse) AttachmentsDetectionLive(attachment *entity.AttachmentSetting, p *entity.PointLiveDto) error {
if attachment == nil {
return errors2.NewBusinessError(503, "附件设置为空")
}
for _, att := range attachment.Attachments {
jobid, _ := att["jobid"].(string)
if jobid != p.JobID {
continue
}
if authEnc, ok := att["authEnc"].(string); ok {
p.AuthEnc = authEnc
}
if liveDragEnc, ok := att["liveDragEnc"].(string); ok {
p.LiveDragEnc = liveDragEnc
}
if liveSetEnc, ok := att["liveSetEnc"].(string); ok {
p.LiveSetEnc = liveSetEnc
}
if otherInfo, ok := att["otherInfo"].(string); ok {
p.OtherInfo = otherInfo
}
if enc, ok := att["enc"].(string); ok {
p.Enc = enc
}
if liveSwDsEnc, ok := att["liveSwDsEnc"].(string); ok {
p.LiveSwDsEnc = liveSwDsEnc
}
if isJob, ok := att["job"].(bool); ok {
p.IsJob = isJob
}
if aid, ok := att["aid"].(float64); ok {
p.Aid = strconv.FormatInt(int64(aid), 10)
}
break
}
return nil
}
// AutoWatchLive 自动观看直播任务点
func (c *XuexitongCourse) AutoWatchLive(ctx context.Context, course entity.XueXiTCourse, p *entity.PointLiveDto) error {
if !p.IsJob {
logger.Logger.Info("直播不是任务点,跳过", zap.String("jobID", p.JobID), zap.String("title", p.Title))
return nil
}
if err := c.PullLiveInfo(ctx, p); err != nil {
logger.Logger.Warn("获取直播信息失败", zap.Error(err))
}
if p.LiveStatusCode == 0 {
logger.Logger.Info("直播还未开始,跳过", zap.String("title", p.Title))
return nil
}
if p.VideoCompletePercent >= 90 {
logger.Logger.Info("直播已完成", zap.String("title", p.Title), zap.Float64("percent", p.VideoCompletePercent))
return nil
}
logger.Logger.Info("开始刷直播",
zap.String("jobID", p.JobID),
zap.String("title", p.Title),
zap.Float64("currentPercent", p.VideoCompletePercent))
if _, err := c.LiveRelationReport(ctx, p); err != nil {
logger.Logger.Warn("建立直播连接失败", zap.Error(err))
}
for {
report, err := c.LiveSaveTimeReport(ctx, p)
if err != nil {
logger.Logger.Error("提交直播观看进度失败", zap.Error(err))
return err
}
if err := c.PullLiveInfo(ctx, p); err != nil {
logger.Logger.Warn("更新直播信息失败", zap.Error(err))
}
logger.Logger.Debug("直播观看中",
zap.String("title", p.Title),
zap.Float64("percent", p.VideoCompletePercent),
zap.String("report", report))
if p.VideoCompletePercent >= 90 {
logger.Logger.Info("直播刷课完成", zap.String("title", p.Title))
c.onTaskComplete()
break
}
time.Sleep(30 * time.Second)
}
return nil
}
// AutoWatchLiveByPoint 自动观看直播任务点(使用统一的 ChapterPoint)
func (c *XuexitongCourse) AutoWatchLiveByPoint(ctx context.Context, course entity.XueXiTCourse, p *entity.ChapterPoint) error {
if !p.IsJob {
logger.Logger.Info("直播不是任务点,跳过", zap.String("jobID", p.JobID), zap.String("title", p.Title))
return nil
}
if err := c.PullLiveInfoByPoint(ctx, p); err != nil {
logger.Logger.Warn("获取直播信息失败", zap.Error(err))
}
if p.LiveStatusCode == 0 {
logger.Logger.Info("直播还未开始,跳过", zap.String("title", p.Title))
return nil
}
if p.VideoCompletePercent >= 90 {
logger.Logger.Info("直播已完成", zap.String("title", p.Title), zap.Float64("percent", p.VideoCompletePercent))
return nil
}
logger.Logger.Info("开始刷直播",
zap.String("jobID", p.JobID),
zap.String("title", p.Title),
zap.Float64("currentPercent", p.VideoCompletePercent))
if _, err := c.LiveRelationReportByPoint(ctx, p); err != nil {
logger.Logger.Warn("建立直播连接失败", zap.Error(err))
}
for {
report, err := c.LiveSaveTimeReportByPoint(ctx, p)
if err != nil {
logger.Logger.Error("提交直播观看进度失败", zap.Error(err))
return err
}
if err := c.PullLiveInfoByPoint(ctx, p); err != nil {
logger.Logger.Warn("更新直播信息失败", zap.Error(err))
}
logger.Logger.Debug("直播观看中",
zap.String("title", p.Title),
zap.Float64("percent", p.VideoCompletePercent),
zap.String("report", report))
if p.VideoCompletePercent >= 90 {
logger.Logger.Info("直播刷课完成", zap.String("title", p.Title))
c.onTaskComplete()
break
}
time.Sleep(30 * time.Second)
}
return nil
}
// PullLiveInfo 获取直播信息
func (c *XuexitongCourse) PullLiveInfo(ctx context.Context, p *entity.PointLiveDto) error {
urlStr := fmt.Sprintf("https://mooc1.chaoxing.com/ananas/live/liveinfo?liveid=%s&userid=%s&clazzid=%s&knowledgeid=%d&courseid=%s&jobid=%s&ut=s",
p.LiveId, p.UserId, p.ClassID, p.KnowledgeID, p.CourseID, p.JobID)
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("web"),
}).Get(urlStr)
if err != nil {
return err
}
if resp.StatusCode() != 200 {
return errors2.NewBusinessError(resp.StatusCode(), "获取直播信息失败")
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return err
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(body), &result); err != nil {
return err
}
if status, ok := result["status"].(bool); ok && status {
if temp, ok := result["temp"].(map[string]interface{}); ok {
if data, ok := temp["data"].(map[string]interface{}); ok {
if percentValue, ok := data["percentValue"].(float64); ok {
p.VideoCompletePercent = percentValue
}
if duration, ok := data["duration"].(float64); ok {
p.VideoDuration = int(duration)
}
if liveStatus, ok := data["liveStatus"].(float64); ok {
p.LiveStatusCode = int(liveStatus)
}
}
}
}
return nil
}
// PullLiveInfoByPoint 获取直播信息(使用统一的 ChapterPoint)
func (c *XuexitongCourse) PullLiveInfoByPoint(ctx context.Context, p *entity.ChapterPoint) error {
urlStr := fmt.Sprintf("https://mooc1.chaoxing.com/ananas/live/liveinfo?liveid=%s&userid=%s&clazzid=%s&knowledgeid=%d&courseid=%s&jobid=%s&ut=s",
p.LiveId, p.UserId, p.ClassID, p.KnowledgeID, p.CourseID, p.JobID)
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("web"),
}).Get(urlStr)
if err != nil {
return err
}
if resp.StatusCode() != 200 {
return errors2.NewBusinessError(resp.StatusCode(), "获取直播信息失败")
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return err
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(body), &result); err != nil {
return err
}
if status, ok := result["status"].(bool); ok && status {
if temp, ok := result["temp"].(map[string]interface{}); ok {
if data, ok := temp["data"].(map[string]interface{}); ok {
if percentValue, ok := data["percentValue"].(float64); ok {
p.VideoCompletePercent = percentValue
}
if duration, ok := data["duration"].(float64); ok {
p.VideoDuration = int(duration)
}
if liveStatus, ok := data["liveStatus"].(float64); ok {
p.LiveStatusCode = int(liveStatus)
}
}
}
}
return nil
}
// LiveRelationReport 建立直播连接
func (c *XuexitongCourse) LiveRelationReport(ctx context.Context, p *entity.PointLiveDto) (string, error) {
urlStr := fmt.Sprintf("https://mooc1.chaoxing.com/mooc-ans/live/relation?courseid=%s&knowledgeid=%d&ut=s&jobid=%s&aid=%s",
p.CourseID, p.KnowledgeID, p.JobID, p.Aid)
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("web"),
}).Get(urlStr)
if err != nil {
return err
}
if resp.StatusCode() != 200 {
return errors2.NewBusinessError(resp.StatusCode(), "建立直播连接失败")
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return "", err
}
return body, nil
}
// LiveRelationReportByPoint 建立直播连接(使用统一的 ChapterPoint)
func (c *XuexitongCourse) LiveRelationReportByPoint(ctx context.Context, p *entity.ChapterPoint) (string, error) {
urlStr := fmt.Sprintf("https://mooc1.chaoxing.com/mooc-ans/live/relation?courseid=%s&knowledgeid=%d&ut=s&jobid=%s&aid=%s",
p.CourseID, p.KnowledgeID, p.JobID, p.Aid)
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("web"),
}).Get(urlStr)
if err != nil {
return err
}
if resp.StatusCode() != 200 {
return errors2.NewBusinessError(resp.StatusCode(), "建立直播连接失败")
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return "", err
}
return body, nil
}
// LiveSaveTimeReport 提交直播观看进度
func (c *XuexitongCourse) LiveSaveTimeReport(ctx context.Context, p *entity.PointLiveDto) (string, error) {
urlStr := fmt.Sprintf("https://zhibo.chaoxing.com/saveTimePc?streamName=%s&vdoid=%s&userId=%s&isStart=1&t=%d&courseId=%s",
p.StreamName, p.Vdoid, p.UserId, time.Now().UnixMilli(), p.CourseID)
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("web"),
}).Get(urlStr)
if err != nil {
return err
}
if resp.StatusCode() != 200 {
return errors2.NewBusinessError(resp.StatusCode(), "提交直播观看进度失败")
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return "", err
}
return body, nil
}
// LiveSaveTimeReportByPoint 提交直播观看进度(使用统一的 ChapterPoint)
func (c *XuexitongCourse) LiveSaveTimeReportByPoint(ctx context.Context, p *entity.ChapterPoint) (string, error) {
urlStr := fmt.Sprintf("https://zhibo.chaoxing.com/saveTimePc?streamName=%s&vdoid=%s&userId=%s&isStart=1&t=%d&courseId=%s",
p.StreamName, p.Vdoid, p.UserId, time.Now().UnixMilli(), p.CourseID)
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("web"),
}).Get(urlStr)
if err != nil {
return err
}
if resp.StatusCode() != 200 {
return errors2.NewBusinessError(resp.StatusCode(), "提交直播观看进度失败")
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return "", err
}
return body, nil
}
@@ -2,11 +2,37 @@ package xuexitong
import (
"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/logger"
"likedream-goframe/internal/utils"
"testing"
"github.com/bytedance/sonic"
"go.uber.org/zap"
)
// getAIConfig 获取AI配置
func getAIConfig(ctx context.Context) *quesbank.AIConfig {
db.InitDB()
config, err := db.Global.SysConfig.Query().
Where(sysconfig.KeyEQ("ai.config")).
Only(ctx)
if err != nil {
logger.Logger.Debug("未配置AI,作业将跳过", zap.Error(err))
return nil
}
var aiConfig quesbank.AIConfig
if err := sonic.Unmarshal([]byte(config.Value), &aiConfig); err != nil {
logger.Logger.Warn("AI配置解析失败", zap.Error(err))
return nil
}
return &aiConfig
}
func TestSchedule(t *testing.T) {
ctx := context.Background()
xuexitongLogin := NewXuexitongLogin(ctx)
@@ -17,7 +43,20 @@ func TestSchedule(t *testing.T) {
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 {
utils.OkOrPanic(errors.NewBusinessErrorWithDetail(503, "刷课失败", err))
}
@@ -97,33 +136,6 @@ func TestFetchChapterCards(t *testing.T) {
if targetChapterID == 0 {
t.Fatal("没有找到有任务点的章节")
}
// 获取任务点卡片
cards, points, err := courseAction.FetchChapterCards(context.Background(), course, targetChapterID)
utils.OkOrPanic(err)
t.Logf("卡片数量: %d", len(cards))
if points != nil {
t.Logf("视频数量: %d, 文档数量: %d, 作业数量: %d, 外链数量: %d, 直播数量: %d",
len(points.Videos), len(points.Documents), len(points.Works),
len(points.Hyperlinks), len(points.Lives))
for _, video := range points.Videos {
t.Logf("视频任务点: JobID=%s, ObjectID=%s", video.JobID, video.ObjectID)
}
for _, doc := range points.Documents {
t.Logf("文档任务点: JobID=%s, Type=%s", doc.JobID, doc.Type)
}
for _, work := range points.Works {
t.Logf("作业任务点: JobID=%s, WorkID=%s", work.JobID, work.WorkID)
}
for _, link := range points.Hyperlinks {
t.Logf("外链任务点: JobID=%s", link.JobID)
}
for _, live := range points.Lives {
t.Logf("直播任务点: JobID=%s, LiveId=%s, Title=%s", live.JobID, live.LiveId, live.Title)
}
}
}
// TestAutoStudyByCourseID 测试通过课程ID刷课(定时任务使用)
@@ -0,0 +1,455 @@
package xuexitong
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"likedream-goframe/internal/application/xuexitong/entity"
errors2 "likedream-goframe/internal/common/errors"
"likedream-goframe/internal/framework/logger"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/avast/retry-go/v4"
"go.uber.org/zap"
)
// VideoDtoFetch 获取视频详细信息
func (c *XuexitongCourse) VideoDtoFetch(ctx context.Context, p *entity.PointVideoDto) error {
params := url.Values{}
params.Set("k", strconv.Itoa(p.FID))
params.Set("flag", "normal")
params.Set("_dc", strconv.FormatInt(time.Now().UnixMilli(), 10))
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("mobile"),
}).Get(fmt.Sprintf("%s/%s?%s", ApiVideoStatus, p.ObjectID, params.Encode()))
if err != nil {
return err
}
if resp.StatusCode() != 200 {
return errors2.NewBusinessError(resp.StatusCode(), "获取视频信息失败")
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return err
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(body), &result); err != nil {
return err
}
if dtoken, ok := result["dtoken"].(string); ok {
p.DToken = dtoken
}
if duration, ok := result["duration"].(float64); ok {
p.Duration = int(duration)
}
if filename, ok := result["filename"].(string); ok {
if decoded, err := url.QueryUnescape(filename); err == nil {
p.Title = decoded
} else {
p.Title = filename
}
}
if status, ok := result["status"].(string); ok && status == "success" {
return nil
}
return errors2.NewBusinessError(503, "视频状态异常")
}
// AttachmentsDetectionVideo 从附件设置中检测并填充视频信息(旧版,保留兼容)
func (c *XuexitongCourse) AttachmentsDetectionVideo(attachment *entity.AttachmentSetting, p *entity.PointVideoDto) error {
if attachment == nil {
return errors2.NewBusinessError(503, "附件设置为空")
}
for _, att := range attachment.Attachments {
property, ok := att["property"].(map[string]interface{})
if !ok {
continue
}
objectid, _ := property["objectid"].(string)
if objectid != p.ObjectID {
continue
}
if jobid, ok := property["jobid"].(string); ok {
p.JobID = jobid
} else if jobidFloat, ok := property["jobid"].(float64); ok {
p.JobID = strconv.FormatFloat(jobidFloat, 'f', -1, 64)
}
if otherInfo, ok := att["otherInfo"].(string); ok {
parts := strings.SplitN(otherInfo, "&", 2)
if len(parts) > 0 {
p.OtherInfo = parts[0]
}
}
if isPassed, ok := att["isPassed"].(bool); ok {
p.IsPassed = isPassed
}
if rtObj, ok := property["rt"]; ok {
switch v := rtObj.(type) {
case float64:
p.RT = v
case string:
if rt, err := strconv.ParseFloat(v, 64); err == nil {
p.RT = rt
} else {
p.RT = 0.9
}
}
} else {
p.RT = 0.9
}
if playTime, ok := att["playTime"].(float64); ok {
p.PlayTime = int(playTime) / 1000
}
if mid, ok := att["mid"].(string); ok {
p.Mid = mid
}
if rct, ok := att["randomCaptureTime"].(string); ok {
p.RandomCaptureTime = rct
}
if ade, ok := att["attDurationEnc"].(string); ok {
p.AttDurationEnc = ade
}
if vfce, ok := att["videoFaceCaptureEnc"].(string); ok {
p.VideoFaceCaptureEnc = vfce
}
break
}
if defaults := attachment.Defaults; defaults != nil {
if fid, ok := defaults["fid"].(string); ok {
p.FID, _ = strconv.Atoi(fid)
}
if userid, ok := defaults["userid"].(string); ok {
c.userID = userid
}
}
return nil
}
// attachmentDetectVideo 从附件设置中检测并填充视频信息
func (c *XuexitongCourse) attachmentDetectVideo(attachment *entity.AttachmentSetting, p *entity.ChapterPoint) {
logger.Logger.Debug("attachmentDetectVideo 开始匹配",
zap.String("targetObjectID", p.ObjectID),
zap.Int("attachmentsCount", len(attachment.Attachments)))
found := false
for idx, att := range attachment.Attachments {
property, ok := att["property"].(map[string]interface{})
if !ok {
continue
}
objectid := property["objectid"]
if objectid == nil {
continue
}
objectidStr, _ := objectid.(string)
logger.Logger.Debug("检查附件",
zap.Int("index", idx),
zap.String("objectid", objectidStr),
zap.String("targetObjectID", p.ObjectID))
if objectidStr != p.ObjectID {
continue
}
found = true
logger.Logger.Debug("找到匹配的附件", zap.Int("index", idx))
if jobid, ok := property["jobid"].(string); ok {
p.JobID = jobid
} else if jobidFloat, ok := property["jobid"].(float64); ok {
p.JobID = strconv.FormatFloat(jobidFloat, 'f', -1, 64)
}
if otherInfo, ok := att["otherInfo"].(string); ok {
parts := strings.SplitN(otherInfo, "&", 2)
if len(parts) > 0 {
p.OtherInfo = parts[0]
}
logger.Logger.Debug("获取到 otherInfo", zap.String("otherInfo", p.OtherInfo))
}
if isPassed, ok := att["isPassed"].(bool); ok {
p.IsPassed = isPassed
}
if rtObj, ok := property["rt"]; ok {
switch v := rtObj.(type) {
case float64:
p.RT = v
case string:
if rt, err := strconv.ParseFloat(v, 64); err == nil {
p.RT = rt
} else {
p.RT = 0.9
}
}
} else {
p.RT = 0.9
}
if playTime, ok := att["playTime"].(float64); ok {
p.PlayTime = int(playTime) / 1000
}
if mid, ok := att["mid"].(string); ok {
p.Mid = mid
}
if rct, ok := att["randomCaptureTime"].(string); ok {
p.RandomCaptureTime = rct
}
if ade, ok := att["attDurationEnc"].(string); ok {
p.AttDurationEnc = ade
logger.Logger.Debug("获取到 attDurationEnc", zap.String("attDurationEnc", ade))
}
if vfce, ok := att["videoFaceCaptureEnc"].(string); ok {
p.VideoFaceCaptureEnc = vfce
logger.Logger.Debug("获取到 videoFaceCaptureEnc", zap.String("videoFaceCaptureEnc", vfce))
}
break
}
if !found {
logger.Logger.Warn("未找到匹配的视频附件", zap.String("objectID", p.ObjectID))
}
if defaults := attachment.Defaults; defaults != nil {
if fid, ok := defaults["fid"].(string); ok {
p.FID, _ = strconv.Atoi(fid)
}
if userid, ok := defaults["userid"].(string); ok {
c.userID = userid
}
}
}
// AutoPlayVideoByPoint 自动播放视频(使用统一的 ChapterPoint)
func (c *XuexitongCourse) AutoPlayVideoByPoint(ctx context.Context, course entity.XueXiTCourse, p *entity.ChapterPoint) error {
if err := c.VideoDtoFetchByPoint(ctx, p); err != nil {
return fmt.Errorf("获取视频信息失败: %w", err)
}
if p.Duration <= 0 {
logger.Logger.Warn("视频时长为0,跳过", zap.String("jobID", p.JobID))
return nil
}
if p.IsPassed {
return nil
}
logger.Logger.Info("开始刷视频",
zap.String("jobID", p.JobID),
zap.Int("duration", p.Duration),
zap.Int("playTime", p.PlayTime),
zap.String("title", p.Title),
zap.String("userID", c.userID),
zap.String("cpi", p.Cpi),
zap.Int("FID", p.FID),
zap.String("otherInfo", p.OtherInfo))
playingTime := p.PlayTime
if !p.IsPassed && p.PlayTime == p.Duration {
playingTime = 0
}
selectSec := 58
isFirstSubmit := true
for {
var playReport string
var err error
isdrag := 0
if isFirstSubmit && playingTime == p.PlayTime {
isdrag = 3
}
playReport, err = c.VideoSubmitStudyTimeByPoint(ctx, p, playingTime, isdrag)
isFirstSubmit = false
if err != nil {
logger.Logger.Error("提交视频进度失败", zap.Error(err))
return err
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(playReport), &result); err == nil {
if isPassed, ok := result["isPassed"].(bool); ok && isPassed {
logger.Logger.Info("视频播放完成", zap.String("jobID", p.JobID))
c.onTaskComplete()
return nil
}
}
logger.Logger.Info("视频播放", zap.Int("playingTime", playingTime), zap.Int("duration", p.Duration))
if p.Duration-playingTime < selectSec && p.Duration != playingTime {
sleepTime := p.Duration - playingTime
playingTime = p.Duration
time.Sleep(time.Duration(sleepTime) * time.Second)
} else if playingTime >= p.Duration {
time.Sleep(5 * time.Second)
} else {
playingTime += selectSec
time.Sleep(time.Duration(selectSec) * time.Second)
}
}
}
// VideoDtoFetchByPoint 获取视频详细信息(使用统一的 ChapterPoint)
func (c *XuexitongCourse) VideoDtoFetchByPoint(ctx context.Context, p *entity.ChapterPoint) error {
params := url.Values{}
params.Set("k", strconv.Itoa(p.FID))
params.Set("flag", "normal")
params.Set("_dc", strconv.FormatInt(time.Now().UnixMilli(), 10))
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("mobile"),
}).Get(fmt.Sprintf("%s/%s?%s", ApiVideoStatus, p.ObjectID, params.Encode()))
if err != nil {
return err
}
if resp.StatusCode() != 200 {
return errors2.NewBusinessError(resp.StatusCode(), "获取视频信息失败")
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return err
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(body), &result); err != nil {
return err
}
if dtoken, ok := result["dtoken"].(string); ok {
p.DToken = dtoken
}
if duration, ok := result["duration"].(float64); ok {
p.Duration = int(duration)
}
if filename, ok := result["filename"].(string); ok {
if decoded, err := url.QueryUnescape(filename); err == nil {
p.Title = decoded
} else {
p.Title = filename
}
}
if status, ok := result["status"].(string); ok && status == "success" {
return nil
}
return errors2.NewBusinessError(503, "视频状态异常")
}
// VideoSubmitStudyTimeByPoint 提交视频观看进度(使用统一的 ChapterPoint)
func (c *XuexitongCourse) VideoSubmitStudyTimeByPoint(ctx context.Context, p *entity.ChapterPoint, playingTime, isdrag int) (string, error) {
clipTime := fmt.Sprintf("0_%d", p.Duration)
hash := md5.Sum([]byte(fmt.Sprintf("[%s][%s][%s][%s][%d][%s][%d][%s]",
p.ClassID, c.userID, p.JobID, p.ObjectID, playingTime*1000, VideoEncKey, p.Duration*1000, clipTime)))
enc := hex.EncodeToString(hash[:])
urlStr := "https://mooc1.chaoxing.com/mooc-ans/multimedia/log/a/" + p.Cpi + "/" + p.DToken +
"?clazzId=" + p.ClassID +
"&playingTime=" + strconv.Itoa(playingTime) +
"&duration=" + strconv.Itoa(p.Duration) +
"&clipTime=" + clipTime +
"&objectId=" + p.ObjectID +
"&otherInfo=" + p.OtherInfo +
"&courseId=" + p.CourseID +
"&jobid=" + p.JobID +
"&userid=" + c.userID +
"&isdrag=" + strconv.Itoa(isdrag) +
"&view=pc" +
"&enc=" + enc +
"&rt=" + fmt.Sprintf("%.2f", p.RT) +
"&videoFaceCaptureEnc=" + p.VideoFaceCaptureEnc +
"&dtype=Video" +
"&_t=" + strconv.FormatInt(time.Now().UnixMilli(), 10) +
"&attDuration=" + strconv.Itoa(p.Duration) +
"&attDurationEnc=" + p.AttDurationEnc
logger.Logger.Debug("VideoSubmitStudyTimeByPoint 请求参数",
zap.String("cpi", p.Cpi),
zap.String("dtoken", p.DToken),
zap.String("classID", p.ClassID),
zap.String("courseID", p.CourseID),
zap.String("objectID", p.ObjectID),
zap.String("jobID", p.JobID),
zap.String("userID", c.userID),
zap.Int("playingTime", playingTime),
zap.Int("duration", p.Duration),
zap.Int("isdrag", isdrag),
zap.String("otherInfo", p.OtherInfo),
zap.String("attDurationEnc", p.AttDurationEnc),
zap.String("videoFaceCaptureEnc", p.VideoFaceCaptureEnc),
zap.Float64("RT", p.RT),
zap.Int("FID", p.FID),
zap.String("enc", enc))
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0",
"Content-Type": "application/json",
"Accept": "*/*",
"Host": "mooc1.chaoxing.com",
"Connection": "keep-alive",
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Dest": "empty",
"Pragma": "no-cache",
"Sec-Ch-Ua-Platform": "Windows",
}).SetCookies([]*http.Cookie{
{Name: "fanyamoocs", Value: "11401F839C536D9E"},
{Name: "thirdRegist", Value: "0"},
{Name: "videojs_id", Value: "1778753"},
}).Get(urlStr)
if err != nil {
return err
}
if resp.StatusCode() != 200 {
return errors2.NewBusinessError(resp.StatusCode(), fmt.Sprintf("提交视频进度失败,状态码: %d", resp.StatusCode()))
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return "", err
}
return body, nil
}
@@ -0,0 +1,539 @@
package xuexitong
import (
"bytes"
"context"
"fmt"
"likedream-goframe/internal/application/xuexitong/entity"
"likedream-goframe/internal/framework/logger"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/avast/retry-go/v4"
"go.uber.org/zap"
"resty.dev/v3"
)
// attachmentDetectWork 从附件设置中检测并填充作业信息
func (c *XuexitongCourse) attachmentDetectWork(attachment *entity.AttachmentSetting, p *entity.ChapterPoint) {
for _, att := range attachment.Attachments {
property, ok := att["property"].(map[string]interface{})
if !ok {
continue
}
workId, _ := property["workid"].(string)
if workId != p.WorkID {
continue
}
if enc, ok := att["enc"].(string); ok {
p.Enc = enc
}
if title, ok := property["title"].(string); ok {
p.Title = title
}
break
}
if defaults := attachment.Defaults; defaults != nil {
if ktoken, ok := defaults["ktoken"].(string); ok {
p.KToken = ktoken
}
if userid, ok := defaults["userid"].(string); ok {
p.PUID = userid
}
}
}
// AttachmentsDetectionWork 从附件设置中检测并填充作业信息
func (c *XuexitongCourse) AttachmentsDetectionWork(attachment *entity.AttachmentSetting, p *entity.PointWorkDto) error {
if attachment == nil {
logger.Logger.Warn("attachment 为空")
return nil
}
logger.Logger.Debug("AttachmentsDetectionWork 开始",
zap.Int("attachmentsCount", len(attachment.Attachments)),
zap.String("targetWorkID", p.WorkID))
foundMatch := false
for i, att := range attachment.Attachments {
property, ok := att["property"].(map[string]interface{})
if !ok {
logger.Logger.Debug("附件 property 不存在", zap.Int("index", i))
continue
}
workId, _ := property["workid"].(string)
logger.Logger.Debug("检查附件",
zap.Int("index", i),
zap.String("workId", workId),
zap.String("targetWorkID", p.WorkID),
zap.Bool("match", workId == p.WorkID))
if workId != p.WorkID {
continue
}
foundMatch = true
if enc, ok := att["enc"].(string); ok {
p.Enc = enc
logger.Logger.Debug("从附件获取 enc", zap.String("enc", enc))
}
if title, ok := property["title"].(string); ok {
p.Title = title
}
break
}
if !foundMatch {
logger.Logger.Warn("未找到匹配的作业附件", zap.String("workID", p.WorkID))
}
if defaults := attachment.Defaults; defaults != nil {
logger.Logger.Debug("defaults 内容", zap.Any("defaults", defaults))
if ktoken, ok := defaults["ktoken"].(string); ok {
p.KToken = ktoken
logger.Logger.Debug("从 defaults 获取 ktoken", zap.String("ktoken", ktoken))
} else {
logger.Logger.Warn("defaults 中没有 ktoken")
}
if userid, ok := defaults["userid"].(string); ok {
p.PUID = userid
logger.Logger.Debug("从 defaults 获取 userid", zap.String("userid", userid))
} else {
logger.Logger.Warn("defaults 中没有 userid")
}
// 检查 defaults 中是否有 cpi,如果有则使用
if cpi, ok := defaults["cpi"]; ok {
switch v := cpi.(type) {
case string:
p.Cpi = v
logger.Logger.Debug("从 defaults 获取 cpi (string)", zap.String("cpi", v))
case float64:
p.Cpi = strconv.FormatInt(int64(v), 10)
logger.Logger.Debug("从 defaults 获取 cpi (float64)", zap.String("cpi", p.Cpi))
}
}
} else {
logger.Logger.Warn("attachment.Defaults 为空")
}
logger.Logger.Debug("AttachmentsDetectionWork 完成",
zap.String("PUID", p.PUID),
zap.String("KToken", p.KToken),
zap.String("Enc", p.Enc))
return nil
}
// AutoCompleteWorkByPoint 自动完成作业任务点(使用统一的 ChapterPoint)
func (c *XuexitongCourse) AutoCompleteWorkByPoint(ctx context.Context, course entity.XueXiTCourse, p *entity.ChapterPoint, aiConfig *AIConfig, autoSubmit bool) error {
if p.IsPassed {
logger.Logger.Info("作业已完成", zap.String("jobID", p.JobID), zap.String("title", p.Title))
return nil
}
// 如果没有AI配置,跳过
if aiConfig == nil {
logger.Logger.Warn("未配置AI,跳过作业", zap.String("title", p.Title))
return nil
}
// 转换为 PointWorkDto
workDto := &entity.PointWorkDto{
CardIndex: p.CardIndex,
CourseID: p.CourseID,
ClassID: p.ClassID,
KnowledgeID: p.KnowledgeID,
Cpi: p.Cpi,
WorkID: p.WorkID,
SchoolID: p.SchoolID,
JobID: p.JobID,
PUID: p.PUID,
KToken: p.KToken,
Enc: p.Enc,
Title: p.Title,
IsPassed: p.IsPassed,
}
// 关键修复:获取 AttachmentSetting 来填充 PUID、KToken、Enc 等参数
attachment, enc, err := c.PageMobileChapterCard(ctx, course, p.KnowledgeID, p.CardIndex)
if err != nil {
logger.Logger.Error("获取章节卡片失败", zap.String("title", p.Title), zap.Error(err))
return err
}
if attachment != nil {
// 填充作业参数
if err := c.AttachmentsDetectionWork(attachment, workDto); err != nil {
logger.Logger.Error("填充作业参数失败", zap.String("title", p.Title), zap.Error(err))
return err
}
// 只有当附件中没有获取到 enc 时,才使用从页面获取的 enc
if workDto.Enc == "" && enc != "" {
workDto.Enc = enc
logger.Logger.Debug("使用页面 enc", zap.String("enc", enc))
}
}
logger.Logger.Debug("作业参数",
zap.String("workID", workDto.WorkID),
zap.String("PUID", workDto.PUID),
zap.String("KToken", workDto.KToken),
zap.String("Enc", workDto.Enc),
zap.String("Cpi", workDto.Cpi))
// 使用AI自动完成作业
result, err := c.AutoCompleteWorkWithAI(ctx, workDto, *aiConfig, autoSubmit)
if err != nil {
logger.Logger.Error("AI答题失败", zap.String("title", p.Title), zap.Error(err))
return err
}
logger.Logger.Info("AI答题完成", zap.String("title", p.Title), zap.String("result", result))
return nil
}
// FetchWorkQuestions 获取作业题目
func (c *XuexitongCourse) FetchWorkQuestions(ctx context.Context, p *entity.PointWorkDto) (*entity.WorkDto, error) {
c.client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
defer func() {
c.client.SetRedirectPolicy(resty.NoRedirectPolicy())
}()
params := url.Values{}
params.Add("courseid", p.CourseID)
workID := p.WorkID
if p.SchoolID != "" && p.SchoolID != "0" {
workID = p.SchoolID + "-" + p.WorkID
}
params.Add("workid", workID)
params.Add("jobid", p.JobID)
params.Add("needRedirect", "true")
params.Add("knowledgeid", strconv.Itoa(p.KnowledgeID))
// 关键修复:使用 p.PUID 而不是 c.userID
params.Add("userid", p.PUID)
params.Add("ut", "s")
params.Add("clazzId", p.ClassID)
params.Add("cpi", p.Cpi)
params.Add("ktoken", p.KToken)
params.Add("enc", p.Enc)
logger.Logger.Debug("FetchWorkQuestions 请求参数",
zap.String("courseid", p.CourseID),
zap.String("workid", workID),
zap.String("jobid", p.JobID),
zap.Int("knowledgeid", p.KnowledgeID),
zap.String("userid", p.PUID),
zap.String("clazzId", p.ClassID),
zap.String("cpi", p.Cpi),
zap.String("ktoken", p.KToken),
zap.String("enc", p.Enc))
var body string
requestURL := ApiWorkFetch + "?" + params.Encode()
logger.Logger.Debug("FetchWorkQuestions 请求URL", zap.String("url", requestURL))
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("mobile"),
"Sec-Ch-Ua-Platform": "Windows",
"Accept": "*/*",
"Host": "mooc1-api.chaoxing.com",
"Connection": "keep-alive",
"Accept-Language": "zh_CN",
}).Get(requestURL)
if err != nil {
return err
}
body = resp.String()
logger.Logger.Debug("FetchWorkQuestions 响应", zap.String("body", body[:min(len(body), 500)]))
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return nil, err
}
workDto, err := c.parseWorkHTML(body, p)
if err != nil {
return nil, err
}
return workDto, nil
}
// parseWorkHTML 解析作业HTML页面,提取题目信息(使用 goquery,与 pkg/yatori-go-core 保持一致)
func (c *XuexitongCourse) parseWorkHTML(htmlContent string, p *entity.PointWorkDto) (*entity.WorkDto, error) {
workDto := &entity.WorkDto{
WorkId: p.WorkID,
ClassId: p.ClassID,
CourseId: p.CourseID,
KnowledgeId: strconv.Itoa(p.KnowledgeID),
Cpi: p.Cpi,
JobId: p.JobID,
UserId: p.PUID,
}
// 使用 goquery 解析 HTML
doc, err := goquery.NewDocumentFromReader(bytes.NewReader([]byte(htmlContent)))
if err != nil {
logger.Logger.Error("解析作业HTML失败", zap.Error(err))
return nil, err
}
// 解析作业信息(与 pkg/yatori-go-core/utils/XueXiTParseWorkUtils.go 中的 ParseWorkInform 一致)
findList := []string{"userId", "courseId", "classId", "api", "workAnswerId",
"answerId", "totalQuestionNum", "fullScore", "knowledgeid", "oldSchoolId",
"oldWorkId", "jobid", "workRelationId", "enc", "enc_work", "cpi", "workId"}
for _, find := range findList {
doc.Find("input#" + find).Each(func(i int, s *goquery.Selection) {
val, exists := s.Attr("value")
if exists {
switch find {
case "workAnswerId":
workDto.WorkAnswerId = val
case "answerId":
workDto.AnswerId = val
case "totalQuestionNum":
workDto.TotalQuestionNum = val
case "fullScore":
workDto.FullScore = val
case "oldWorkId":
workDto.OldWorkId = val
case "oldSchoolId":
workDto.OldSchoolId = val
case "api":
workDto.Api = val
case "workRelationId":
workDto.WorkRelationId = val
case "enc_work":
workDto.EncWork = val
}
}
})
}
// 截取试卷标题
doc.Find(".chapter-title").Each(func(i int, s *goquery.Selection) {
val, exists := s.Attr("workname")
if exists {
workDto.Title = val
}
})
// 解析题目(与 pkg/yatori-go-core/aggregation/xuexitong/XueXiTongCardAction.go 中的 ParseWorkQuestionAction 一致)
questionNodes := doc.Find("div.Py-mian1")
questionNodes.Each(func(i int, questionNode *goquery.Selection) {
// 获取题目ID
qid, _ := questionNode.Attr("data")
// 获取题目HTML
questionHTML, _ := questionNode.Html()
qdoc, err := goquery.NewDocumentFromReader(bytes.NewReader([]byte(questionHTML)))
if err != nil {
return
}
// 提取题目类型
quesTypeText := qdoc.Find(".Py-m1-title .quesType").Text()
quesType := extractQuestionType(quesTypeText)
// 提取题目文本
var quesTextBuilder strings.Builder
qdoc.Find(".Py-m1-title").Contents().Each(func(i int, n *goquery.Selection) {
quesTextBuilder.WriteString(n.Text())
})
quesText := cleanText(quesTextBuilder.String())
question := entity.WorkQuestion{
Qid: qid,
Text: quesText,
Options: make(map[string]string),
}
// 根据题型解析选项
switch quesType {
case "单选题":
question.Type = entity.QuestionTypeSingleChoice
qdoc.Find(".answerList.singleChoice li").Each(func(i int, s *goquery.Selection) {
optionLetter := s.Find("em.choose-opt").Text()
ccContent := s.Find("cc").Text()
if ccContent == "" {
img, exists := s.Find("cc img").Attr("src")
if exists {
ccContent = "Image: " + img
}
}
question.Options[optionLetter] = strings.TrimSpace(ccContent)
})
case "多选题":
question.Type = entity.QuestionTypeMultipleChoice
qdoc.Find(".answerList.multiChoice li").Each(func(i int, s *goquery.Selection) {
optionLetter := s.Find("em.choose-opt").Text()
ccContent := s.Find("cc").Text()
if ccContent == "" {
img, exists := s.Find("cc img").Attr("src")
if exists {
ccContent = "Image: " + img
}
}
question.Options[optionLetter] = strings.TrimSpace(ccContent)
})
case "判断题":
question.Type = entity.QuestionTypeTrueFalse
qdoc.Find(".answerList.panduan li").Each(func(i int, s *goquery.Selection) {
optionLetter := s.Find("em").Text()
pContent := s.Find("p").Text()
question.Options[optionLetter] = strings.TrimSpace(pContent)
})
case "填空题":
question.Type = entity.QuestionTypeFillBlank
// 统计填空数量
blankCount := 0
qdoc.Find("ul.blankList2").Each(func(i int, selection *goquery.Selection) {
blankCount++
})
question.BlankCount = blankCount
if question.BlankCount == 0 {
question.BlankCount = 1
}
case "简答题":
question.Type = entity.QuestionTypeShortAnswer
}
workDto.Questions = append(workDto.Questions, question)
})
logger.Logger.Debug("解析作业完成",
zap.String("title", workDto.Title),
zap.Int("questionCount", len(workDto.Questions)))
return workDto, nil
}
// extractQuestionType 从文本中提取题目类型
func extractQuestionType(text string) string {
start := strings.Index(text, "[")
end := strings.Index(text, "]")
if start != -1 && end != -1 && end > start {
return strings.TrimSpace(text[start+1 : end])
}
return ""
}
// cleanText 清理文本中的多余空白
func cleanText(text string) string {
text = strings.TrimSpace(text)
// 移除多余的空白字符
text = strings.Join(strings.Fields(text), " ")
return text
}
// SubmitWorkAnswers 提交作业答案
func (c *XuexitongCourse) SubmitWorkAnswers(ctx context.Context, workDto *entity.WorkDto, isSave bool) (string, error) {
pyFlag := ""
if isSave {
pyFlag = "1"
}
formMap := map[string]string{
"pyFlag": pyFlag,
"courseId": workDto.CourseId,
"classId": workDto.ClassId,
"api": workDto.Api,
"mooc": "0",
"workAnswerId": workDto.WorkAnswerId,
"answerId": "",
"totalQuestionNum": workDto.TotalQuestionNum,
"fullScore": workDto.FullScore,
"knowledgeid": workDto.KnowledgeId,
"oldSchoolId": workDto.OldSchoolId,
"oldWorkId": workDto.OldWorkId,
"jobid": workDto.JobId,
"workRelationId": workDto.WorkRelationId,
"enc_work": workDto.EncWork,
"isphone": "true",
"userId": workDto.UserId,
"workTimesEnc": "",
"isAccessibleCustomFid": "0",
}
answerwqbid := ""
for _, q := range workDto.Questions {
if q.Qid != "" {
answerwqbid += q.Qid + ","
}
switch q.Type {
case entity.QuestionTypeSingleChoice:
answer := strings.Join(q.Answers, "")
formMap["answer"+q.Qid] = answer
formMap["answertype"+q.Qid] = "0"
case entity.QuestionTypeMultipleChoice:
answer := strings.Join(q.Answers, "")
formMap["answer"+q.Qid] = answer
formMap["answertype"+q.Qid] = "1"
case entity.QuestionTypeFillBlank:
for key, val := range q.BlankAnswer {
re := regexp.MustCompile(`\d+`)
numbers := re.FindAllString(key, -1)
if len(numbers) > 0 {
formMap["answer"+q.Qid+numbers[0]] = val
}
}
formMap["tiankongsize"+q.Qid] = strconv.Itoa(q.BlankCount)
formMap["answertype"+q.Qid] = "2"
case entity.QuestionTypeTrueFalse:
answer := ""
for _, a := range q.Answers {
if a == "正确" {
answer = "true"
} else if a == "错误" {
answer = "false"
} else {
answer = a
}
}
formMap["answer"+q.Qid] = answer
formMap["answertype"+q.Qid] = "3"
case entity.QuestionTypeShortAnswer:
answer := strings.Join(q.Answers, "")
formMap["answer"+q.Qid] = answer
formMap["answertype"+q.Qid] = "4"
}
}
formMap["answerwqbid"] = answerwqbid
urlStr := fmt.Sprintf("%s?_classId=%s&courseid=%s&token=%s&totalQuestionNum=%s&ua=pc&formType=post&saveStatus=1&version=1&tempsave=1",
ApiWorkSubmit, workDto.ClassId, workDto.CourseId, workDto.EncWork, workDto.TotalQuestionNum)
var body string
err := retry.Do(func() error {
resp, err := c.client.R().SetHeaders(map[string]string{
"User-Agent": GetUA("mobile"),
"Content-Type": "application/x-www-form-urlencoded",
}).SetFormData(formMap).Post(urlStr)
if err != nil {
return err
}
body = resp.String()
return nil
}, retry.Attempts(3), retry.Delay(2*time.Second))
if err != nil {
return "", err
}
return body, nil
}
@@ -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)
}
+1 -1
View File
@@ -276,7 +276,7 @@ func (ts *TaskScanner) scanXuexitongTask() {
utils.LogError(ts.ctx, "扫描学习通任务时,入列失败", oops.With("course_id", course.ID).Wrap(e))
}
} else {
g.Log().Debugf(ts.ctx, "学习通课程 %s (ID: %d) 已加入队列: %s", course.CourseName, course.ID, queueName)
//g.Log().Debugf(ts.ctx, "学习通课程 %s (ID: %d) 已加入队列: %s", course.CourseName, course.ID, queueName)
}
}
}
@@ -4,21 +4,44 @@ import (
"bytes"
"context"
"encoding/gob"
"encoding/json"
"likedream-goframe/internal/application/quesbank"
"likedream-goframe/internal/application/xuexitong"
"likedream-goframe/internal/application/xuexitong/entity"
"likedream-goframe/internal/common/db"
errors2 "likedream-goframe/internal/common/errors"
"likedream-goframe/internal/ent"
"likedream-goframe/internal/ent/courseentity"
"likedream-goframe/internal/ent/sysconfig"
"likedream-goframe/internal/framework/errors"
"likedream-goframe/internal/framework/logger"
"likedream-goframe/internal/utils"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/hibiken/asynq"
"github.com/samber/oops"
"go.uber.org/zap"
)
// getAIConfig 获取AI配置
func getAIConfig(ctx context.Context) *quesbank.AIConfig {
config, err := db.Global.SysConfig.Query().
Where(sysconfig.KeyEQ("ai.config")).
Only(ctx)
if err != nil {
logger.Logger.Debug("未配置AI,作业将跳过", zap.Error(err))
return nil
}
var aiConfig quesbank.AIConfig
if err := json.Unmarshal([]byte(config.Value), &aiConfig); err != nil {
logger.Logger.Warn("AI配置解析失败", zap.Error(err))
return nil
}
return &aiConfig
}
type XuexitongTaskHandler struct{}
func (h *XuexitongTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task) (err error) {
@@ -39,7 +62,7 @@ func (h *XuexitongTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task
// 检查是否暂停
if targetCourse.Status == courseentity.StatusPause {
g.Log().Debugf(ctx, "课程ID %d 已暂停,跳过执行", targetCourse.ID)
logger.Logger.Debug("课程已暂停,跳过执行", zap.Int64("courseID", targetCourse.ID))
return nil
}
@@ -55,8 +78,10 @@ func (h *XuexitongTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task
currentMinutes := currentHour*60 + currentMinute
if currentMinutes < startTime || currentMinutes >= endTime {
g.Log().Debugf(ctx, "课程ID %d 非暴力模式,当前时间 %02d:%02d 不在执行时间范围内(8:30-17:30),跳过执行",
targetCourse.ID, currentHour, currentMinute)
// logger.Logger.Debug("非暴力模式,当前时间不在执行时间范围内(8:30-17:30),跳过执行",
// zap.Int64("courseID", targetCourse.ID),
// zap.Int("currentHour", currentHour),
// zap.Int("currentMinute", currentMinute))
return nil
}
}
@@ -78,7 +103,9 @@ func (h *XuexitongTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task
errorMessage = "未知的错误导致任务执行失败: " + err.Error()
}
g.Log().Errorf(ctx, "学习通刷课失败 - 课程: %s, 错误: %s", targetCourse.CourseName, errorMessage)
logger.Logger.Error("学习通刷课失败",
zap.String("courseName", targetCourse.CourseName),
zap.String("error", errorMessage))
panic(err)
})
@@ -127,13 +154,11 @@ func (h *XuexitongTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task
}
// 开始刷课前,先获取并更新任务点统计
g.Log().Infof(ctx, "课程 %s 正在获取任务点统计...", targetCourse.CourseName)
chapters := courseService.PullCourseChapterList(ctx, *course)
// FetchChapterPointStatus 会更新 chapters 中每个章节的 PointTotal 和 PointFinish
_, statusErr := courseService.FetchChapterPointStatus(ctx, *course, chapters)
if statusErr != nil {
g.Log().Warningf(ctx, "获取章节任务点状态失败: %v", statusErr)
logger.Logger.Warn("获取章节任务点状态失败", zap.Error(statusErr))
} else {
// 通过累加每个章节的 PointTotal 和 PointFinish 计算总数
var totalPointCount, finishedPointCount int
@@ -141,7 +166,10 @@ func (h *XuexitongTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task
totalPointCount += chapter.PointTotal
finishedPointCount += chapter.PointFinish
}
g.Log().Infof(ctx, "课程 %s 任务点统计: 总数 %d, 已完成 %d", targetCourse.CourseName, totalPointCount, finishedPointCount)
logger.Logger.Info("课程任务点统计",
zap.String("courseName", targetCourse.CourseName),
zap.Int("totalPointCount", totalPointCount),
zap.Int("finishedPointCount", finishedPointCount))
// 更新数据库
db.Global.CourseEntity.UpdateOneID(taskPayload.CourseID).
@@ -158,12 +186,18 @@ func (h *XuexitongTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task
ExecX(ctx)
})
// 执行刷课
err = courseService.AutoStudyByCourseID(ctx, targetCourse.CourseID)
// 获取AI配置(用于作业答题)
aiConfig := getAIConfig(ctx)
// 执行刷课(带AI配置)
err = courseService.AutoStudyByCourseIDWithOptions(ctx, targetCourse.CourseID, &xuexitong.AutoStudyOptions{
AIConfig: aiConfig,
AutoSubmit: true, // 默认不自动提交,仅保存
})
if err != nil {
// 如果是时间限制导致的停止,不算失败
if err == xuexitong.ErrTimeRestriction {
g.Log().Infof(ctx, "课程ID %d 因时间限制暂停刷课,等待下次执行", targetCourse.ID)
logger.Logger.Info("课程因时间限制暂停刷课,等待下次执行", zap.Int64("courseID", targetCourse.ID))
// 更新状态为等待中,等待下次定时任务
db.Global.CourseEntity.UpdateOneID(taskPayload.CourseID).SetStatus(courseentity.StatusWaiting).ExecX(ctx)
return nil
@@ -172,7 +206,7 @@ func (h *XuexitongTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task
}
// 刷课完成后,重新统计任务情况
g.Log().Infof(ctx, "刷课完成,正在重新统计任务情况...")
logger.Logger.Info("刷课完成,正在重新统计任务情况...")
// 重新获取章节列表和任务点状态
chaptersAfter := courseService.PullCourseChapterList(ctx, *course)
@@ -181,7 +215,7 @@ func (h *XuexitongTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task
var totalPointCountAfter, finishedPointCountAfter int
if statusErrAfter != nil {
g.Log().Warningf(ctx, "获取章节任务点状态失败: %v", statusErrAfter)
logger.Logger.Warn("获取章节任务点状态失败", zap.Error(statusErrAfter))
} else {
// 通过累加每个章节的 PointTotal 和 PointFinish 计算总数
for _, chapter := range chaptersAfter {
@@ -190,7 +224,10 @@ func (h *XuexitongTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task
}
}
g.Log().Infof(ctx, "课程 %s 统计完成: 总任务点 %d, 已完成 %d", targetCourse.CourseName, totalPointCountAfter, finishedPointCountAfter)
logger.Logger.Info("课程统计完成",
zap.String("courseName", targetCourse.CourseName),
zap.Int("totalPointCount", totalPointCountAfter),
zap.Int("finishedPointCount", finishedPointCountAfter))
// 更新数据库
db.Global.CourseEntity.UpdateOneID(taskPayload.CourseID).
@@ -199,7 +236,7 @@ func (h *XuexitongTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task
SetSuccessCount(finishedPointCountAfter).
ExecX(ctx)
g.Log().Infof(ctx, "学习通刷课完成 - 课程: %s", targetCourse.CourseName)
logger.Logger.Info("学习通刷课完成", zap.String("courseName", targetCourse.CourseName))
return nil
}
@@ -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] + "..."
}
+1 -1
View File
@@ -29,7 +29,7 @@ type CourseServiceV1 struct {
func (s CourseServiceV1) List(ctx context.Context, req *v1.CourseListReq) *utils.Response[[]*ent.CourseEntity] {
user := utils.GetUserInfoFromCtx(ctx)
query := db2.Global.CourseEntity.Query().
Where(courseentity.HasSysUserWith(sysuser.ID(user.ID))).
Where(courseentity.HasSysUserWith(sysuser.ID(user.ID)), courseentity.TypeEQ(courseentity.TypeXxt)).
Order(ent.Desc(courseentity.FieldCreateTime))
if req.Type != "" {
+2
View File
@@ -31,6 +31,7 @@ type globalService struct {
Course *course.CourseServiceV1
SmartEduSC *course.SmartEduSCServiceV1
Config *config.ConfigServiceV1
AIConfig *config.AIConfigServiceV1
Balance *balance.BalanceRecordServiceV1
}
@@ -51,5 +52,6 @@ var Global = globalService{
Course: course.NewCourseServiceV1(),
SmartEduSC: course.NewSmartEduSCServiceV1(),
Config: config.NewV1(),
AIConfig: config.NewAIConfigServiceV1(),
Balance: balance.NewBalanceRecordServiceV1(),
}