Files
memora/memora-api/internal/service/word.go

355 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strings"
"time"
"memora-api/internal/config"
"memora-api/internal/model"
"gorm.io/gorm"
)
type WordService struct {
db *gorm.DB
}
func NewWordService(db *gorm.DB) *WordService {
return &WordService{db: db}
}
// 查询单词调用有道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(word string, youdaoResp *model.YoudaoResponse) (*model.Word, error) {
var existingWord model.Word
// 检查单词是否已存在
if err := s.db.Where("word = ?", word).First(&existingWord).Error; err == nil {
// 单词已存在,更新记忆记录
s.updateMemoryRecord(existingWord.ID, true)
return &existingWord, nil
}
// 解析有道API响应
var phoneticUK, phoneticUS, partOfSpeech, definition 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 - 有道返回的 speakUrl 示例: https://dict.youdao.com/dictvoice?audio=hello
audioPath := config.AppConfig.Audio.Path
var audioUK, audioUS string
if youdaoResp.SpeakUrl != "" {
// 默认是美音,加 _uk 是英音
audioUK = strings.Replace(youdaoResp.SpeakUrl, "audio=", "audio=word_uk_", 1)
audioUS = youdaoResp.SpeakUrl
// 异步下载音频文件
go func() {
if audioPath != "" {
ukPath := fmt.Sprintf("%s/%s_uk.mp3", audioPath, word)
usPath := fmt.Sprintf("%s/%s_us.mp3", audioPath, word)
s.DownloadAudio(audioUK, ukPath)
s.DownloadAudio(audioUS, usPath)
}
}()
audioUK = fmt.Sprintf("%s/%s_uk.mp3", audioPath, word)
audioUS = fmt.Sprintf("%s/%s_us.mp3", audioPath, word)
}
// 创建新单词
newWord := model.Word{
Word: word,
PhoneticUK: phoneticUK,
PhoneticUS: phoneticUS,
AudioUK: audioUK,
AudioUS: audioUS,
PartOfSpeech: partOfSpeech,
Definition: definition,
}
if err := s.db.Create(&newWord).Error; err != nil {
return nil, err
}
// 创建记忆记录 + 记一次"背过"
_ = s.createMemoryRecord(newWord.ID)
_ = s.updateMemoryRecord(newWord.ID, true)
return &newWord, nil
}
// 创建记忆记录
func (s *WordService) createMemoryRecord(wordID int64) error {
record := model.MemoryRecord{
WordID: wordID,
UserID: 1,
CorrectCount: 0,
TotalCount: 0,
MasteryLevel: 0,
}
return s.db.Create(&record).Error
}
// 更新记忆记录
func (s *WordService) updateMemoryRecord(wordID int64, correct bool) error {
var record model.MemoryRecord
if err := s.db.Where("word_id = ?", wordID).First(&record).Error; err != nil {
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.db.Save(&record).Error
}
// 获取待复习单词
func (s *WordService) GetReviewWords(mode string, limit int) ([]model.MemoryRecord, error) {
var records []model.MemoryRecord
query := s.db.Preload("Word").Where("next_review_at <= ? OR next_review_at IS NULL", time.Now())
if err := query.Limit(limit).Find(&records).Error; err != nil {
return nil, err
}
// 如果没有需要复习的,随机获取一些
if len(records) == 0 {
if err := s.db.Preload("Word").Limit(limit).Find(&records).Error; 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(req model.ReviewAnswerRequest) (*model.ReviewResult, error) {
var record model.MemoryRecord
if err := s.db.Preload("Word").Where("id = ?", req.RecordID).First(&record).Error; 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(record.WordID, correct)
return &model.ReviewResult{
Word: word,
Correct: correct,
Answer: req.Answer,
CorrectAns: correctAns,
}, nil
}
// 获取所有单词
func (s *WordService) GetAllWords(limit, offset int) ([]model.Word, int64, error) {
var words []model.Word
var total int64
s.db.Model(&model.Word{}).Count(&total)
if err := s.db.Limit(limit).Offset(offset).Order("created_at DESC").Find(&words).Error; err != nil {
return nil, 0, err
}
return words, total, nil
}
// 获取记忆统计
func (s *WordService) GetStatistics() (map[string]interface{}, error) {
var totalWords int64
var masteredWords int64
var needReview int64
var todayReviewed int64
s.db.Model(&model.Word{}).Count(&totalWords)
s.db.Model(&model.MemoryRecord{}).Where("mastery_level >= 4").Count(&masteredWords)
s.db.Model(&model.MemoryRecord{}).Where("next_review_at <= ?", time.Now()).Count(&needReview)
s.db.Model(&model.MemoryRecord{}).Where("last_reviewed_at >= ?", time.Now().Format("2006-01-02")).Count(&todayReviewed)
return map[string]interface{}{
"total_words": totalWords,
"mastered_words": masteredWords,
"need_review": needReview,
"today_reviewed": todayReviewed,
}, nil
}