package service import ( "crypto/md5" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "regexp" "strings" "time" "memora-api/internal/config" "memora-api/internal/model" "memora-api/internal/repository" "memora-api/internal/request" "memora-api/internal/response" "gorm.io/gorm" ) type WordService struct { db *gorm.DB wordRepo *repository.WordRepository memoryRepo *repository.MemoryRepository } func NewWordService(db *gorm.DB) *WordService { return &WordService{ db: db, wordRepo: repository.NewWordRepository(db), memoryRepo: repository.NewMemoryRepository(db), } } type dictAPIEntry struct { PhoneticUK string PhoneticUS string Part string Example string } // fallback: dictionaryapi.dev(补充音标/词性/例句) func (s *WordService) fetchDictMeta(word string) *dictAPIEntry { endpoint := "https://api.dictionaryapi.dev/api/v2/entries/en/" + url.PathEscape(strings.ToLower(word)) resp, err := http.Get(endpoint) if err != nil || resp == nil { return nil } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil } body, err := io.ReadAll(resp.Body) if err != nil { return nil } var arr []map[string]any if err := json.Unmarshal(body, &arr); err != nil || len(arr) == 0 { return nil } entry := &dictAPIEntry{} first := arr[0] if p, ok := first["phonetics"].([]any); ok { for _, x := range p { if m, ok := x.(map[string]any); ok { if t, ok := m["text"].(string); ok && t != "" { if entry.PhoneticUK == "" { entry.PhoneticUK = t } if entry.PhoneticUS == "" { entry.PhoneticUS = t } } } } } if ms, ok := first["meanings"].([]any); ok && len(ms) > 0 { if m0, ok := ms[0].(map[string]any); ok { if p, ok := m0["partOfSpeech"].(string); ok { entry.Part = p } if defs, ok := m0["definitions"].([]any); ok && len(defs) > 0 { if d0, ok := defs[0].(map[string]any); ok { if ex, ok := d0["example"].(string); ok { entry.Example = ex } } } } } return entry } // 查询单词(调用有道API) func (s *WordService) QueryWord(word string) (*model.YoudaoResponse, error) { // 优先用 app_id,兼容 app_key appID := config.AppConfig.Youdao.AppID if appID == "" { appID = config.AppConfig.Youdao.AppKey } appSecret := config.AppConfig.Youdao.AppSecret // 未配置有道密钥时,先走 mock(保证流程可跑通) if strings.TrimSpace(appID) == "" || strings.TrimSpace(appSecret) == "" { return &model.YoudaoResponse{ErrorCode: "0"}, nil } // 有道 API 签名算法: sign = MD5(appKey + q + salt + appSecret) salt := fmt.Sprintf("%d", time.Now().UnixMilli()) q := strings.ToLower(word) rawStr := appID + q + salt + appSecret hash := md5.Sum([]byte(rawStr)) sign := hex.EncodeToString(hash[:]) // 调用有道 API - 智能词典查询 url := fmt.Sprintf("https://openapi.youdao.com/api?q=%s&from=en&to=zh_CHS&appKey=%s&salt=%s&sign=%s", word, appID, salt, sign) resp, err := http.Get(url) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } var result model.YoudaoResponse if err := json.Unmarshal(body, &result); err != nil { return nil, err } // Debug: 打印响应 fmt.Printf("[Youdao API] word=%s, errorCode=%s, translation=%v\n", word, result.ErrorCode, result.Translation) return &result, nil } // 下载音频文件 func (s *WordService) DownloadAudio(url, filePath string) error { if url == "" { return nil } resp, err := http.Get(url) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("download failed: %d", resp.StatusCode) } // 创建目录 dir := filePath[:strings.LastIndex(filePath, "/")] if err := os.MkdirAll(dir, 0755); err != nil { return err } file, err := os.Create(filePath) if err != nil { return err } defer file.Close() _, err = io.Copy(file, resp.Body) return err } // 保存单词到数据库 func (s *WordService) SaveWord(userID int64, word string, youdaoResp *model.YoudaoResponse) (*model.Word, error) { // 检查单词是否已存在 if w, err := s.wordRepo.FindByWord(word); err == nil { // 单词已存在,更新记忆记录 s.updateMemoryRecord(userID, w.ID, true) return w, nil } // 解析有道API响应 var phoneticUK, phoneticUS, partOfSpeech, definition, exampleSentence string // 解析音标 if youdaoResp.Basic.UkPhonetic != "" { phoneticUK = "[" + youdaoResp.Basic.UkPhonetic + "]" } else if youdaoResp.Basic.Phonetic != "" { phoneticUK = "[" + youdaoResp.Basic.Phonetic + "]" } if youdaoResp.Basic.UsPhonetic != "" { phoneticUS = "[" + youdaoResp.Basic.UsPhonetic + "]" } else if youdaoResp.Basic.Phonetic != "" { phoneticUS = "[" + youdaoResp.Basic.Phonetic + "]" } // 解析释义 - 优先用 translation(更准确) if len(youdaoResp.Translation) > 0 { definition = strings.Join(youdaoResp.Translation, "; ") } // 如果 basic 有 wfs,也加到释义里 if len(youdaoResp.Basic.Wfs) > 0 { var meanings []string for _, wf := range youdaoResp.Basic.Wfs { if len(wf.Means) > 0 { meanings = append(meanings, wf.Wf.Name+": "+wf.Means[0].Mean.Text) } } if len(meanings) > 0 { if definition != "" { definition += " | " } definition += strings.Join(meanings, "; ") } partOfSpeech = "basic" } if partOfSpeech == "" { partOfSpeech = "n./v./adj." } // 音频 URL:使用有道 dictvoice,type=1(英音) type=2(美音) audioPath := config.AppConfig.Audio.Path var audioUK, audioUS string ukURL := fmt.Sprintf("https://dict.youdao.com/dictvoice?audio=%s&type=1", url.QueryEscape(word)) usURL := fmt.Sprintf("https://dict.youdao.com/dictvoice?audio=%s&type=2", url.QueryEscape(word)) if audioPath != "" { ukPath := fmt.Sprintf("%s/%s_uk.mp3", audioPath, word) usPath := fmt.Sprintf("%s/%s_us.mp3", audioPath, word) go s.DownloadAudio(ukURL, ukPath) go s.DownloadAudio(usURL, usPath) audioUK = ukPath audioUS = usPath } // 补充元信息(音标/词性/例句) meta := s.fetchDictMeta(word) if meta != nil { if phoneticUK == "" && meta.PhoneticUK != "" { phoneticUK = meta.PhoneticUK } if phoneticUS == "" && meta.PhoneticUS != "" { phoneticUS = meta.PhoneticUS } if partOfSpeech == "" || partOfSpeech == "n./v./adj." { if meta.Part != "" { partOfSpeech = meta.Part } } if meta.Example != "" { exampleSentence = meta.Example } } if exampleSentence == "" { exampleSentence = fmt.Sprintf("Example: %s", word) } // 创建新单词 newWord := model.Word{ Word: word, PhoneticUK: phoneticUK, PhoneticUS: phoneticUS, AudioUK: audioUK, AudioUS: audioUS, PartOfSpeech: partOfSpeech, Definition: definition, ExampleSentence: exampleSentence, } if err := s.wordRepo.Create(&newWord); err != nil { return nil, err } // 创建记忆记录 + 记一次"背过" _ = s.createMemoryRecord(userID, newWord.ID) _ = s.updateMemoryRecord(userID, newWord.ID, true) return &newWord, nil } // 创建记忆记录 func (s *WordService) createMemoryRecord(userID, wordID int64) error { record := model.MemoryRecord{ WordID: wordID, UserID: userID, CorrectCount: 0, TotalCount: 0, MasteryLevel: 0, } return s.memoryRepo.Create(&record) } // 更新记忆记录 func (s *WordService) updateMemoryRecord(userID, wordID int64, correct bool) error { record, err := s.memoryRepo.FindByWord(userID, wordID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { if createErr := s.createMemoryRecord(userID, wordID); createErr != nil { return createErr } record, err = s.memoryRepo.FindByWord(userID, wordID) if err != nil { return err } } else { return err } } record.TotalCount++ if correct { record.CorrectCount++ // 计算掌握程度 record.MasteryLevel = (record.CorrectCount * 5) / (record.TotalCount + 1) if record.MasteryLevel > 5 { record.MasteryLevel = 5 } } now := time.Now() record.LastReviewedAt = &now // 根据掌握程度计算下次复习时间 reviewInterval := time.Hour * 24 * time.Duration(record.MasteryLevel+1) nextReview := now.Add(reviewInterval) record.NextReviewAt = &nextReview return s.memoryRepo.Save(record) } // 获取待复习单词 func (s *WordService) GetReviewWords(userID int64, mode string, limit int) ([]model.MemoryRecord, error) { records, err := s.memoryRepo.Due(userID, limit) if err != nil { return nil, err } // 如果没有需要复习的,随机获取一些 if len(records) == 0 { records, err = s.memoryRepo.ListByUser(userID, limit) if err != nil { return nil, err } } return records, nil } func normalizeText(s string) string { s = strings.ToLower(strings.TrimSpace(s)) re := regexp.MustCompile(`[^\p{Han}a-z0-9]+`) s = re.ReplaceAllString(s, "") return s } func containsAny(def string, ans string) bool { nDef := normalizeText(def) nAns := normalizeText(ans) if nDef == "" || nAns == "" { return false } if strings.Contains(nDef, nAns) { return true } for _, token := range strings.Split(def, "|") { if strings.Contains(normalizeText(token), nAns) { return true } } return false } // 提交复习答案 func (s *WordService) SubmitReviewAnswer(userID int64, req request.ReviewAnswerRequest) (*response.ReviewResult, error) { record, err := s.memoryRepo.FindByID(userID, req.RecordID) if err != nil { return nil, err } word := record.Word correct := false correctAns := "" switch req.Mode { case "spelling": // 读音拼单词 correct = normalizeText(req.Answer) == normalizeText(word.Word) correctAns = word.Word case "en2cn": // 英文写中文 correct = containsAny(word.Definition, req.Answer) correctAns = word.Definition case "cn2en": // 中文写英文 correct = normalizeText(req.Answer) == normalizeText(word.Word) correctAns = word.Word default: correct = false correctAns = "模式错误" } // 更新记忆记录 s.updateMemoryRecord(userID, record.WordID, correct) return &response.ReviewResult{ Word: word, Correct: correct, Answer: req.Answer, CorrectAns: correctAns, }, nil } // 获取所有单词 func (s *WordService) GetAllWords(limit, offset int) ([]model.Word, int64, error) { return s.wordRepo.List(limit, offset) } // 获取记忆统计 func (s *WordService) GetStatistics(userID int64) (map[string]interface{}, error) { totalWords, masteredWords, needReview, todayReviewed := s.memoryRepo.CountOverview(userID) return map[string]interface{}{ "total_words": totalWords, "mastered_words": masteredWords, "need_review": needReview, "today_reviewed": todayReviewed, }, nil } func (s *WordService) CreateStudySession(userID int64, limit int) ([]model.Word, error) { if limit <= 0 || limit > 50 { limit = 10 } words, err := s.wordRepo.Latest(limit) if err != nil { return nil, err } for i := range words { _ = s.createMemoryRecord(userID, words[i].ID) } return words, nil } func (s *WordService) SubmitStudyAnswer(userID int64, req request.SubmitStudyAnswerRequest) (*response.ReviewResult, error) { reviewReq := request.ReviewAnswerRequest{ RecordID: 0, Answer: req.Answer, Mode: req.Mode, } record, err := s.memoryRepo.FindByWord(userID, req.WordID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { if createErr := s.createMemoryRecord(userID, req.WordID); createErr != nil { return nil, createErr } record, err = s.memoryRepo.FindByWord(userID, req.WordID) if err != nil { return nil, err } } else { return nil, err } } reviewReq.RecordID = record.ID return s.SubmitReviewAnswer(userID, reviewReq) }