482 lines
13 KiB
Go
482 lines
13 KiB
Go
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/request"
|
||
"memora-api/internal/response"
|
||
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
type WordService struct {
|
||
db *gorm.DB
|
||
}
|
||
|
||
func NewWordService(db *gorm.DB) *WordService {
|
||
return &WordService{db: 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) {
|
||
var existingWord model.Word
|
||
|
||
// 检查单词是否已存在
|
||
if err := s.db.Where("word = ?", word).First(&existingWord).Error; err == nil {
|
||
// 单词已存在,更新记忆记录
|
||
s.updateMemoryRecord(userID, existingWord.ID, true)
|
||
return &existingWord, 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.db.Create(&newWord).Error; 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.db.Create(&record).Error
|
||
}
|
||
|
||
// 更新记忆记录
|
||
func (s *WordService) updateMemoryRecord(userID, wordID int64, correct bool) error {
|
||
var record model.MemoryRecord
|
||
if err := s.db.Where("word_id = ? AND user_id = ?", wordID, userID).First(&record).Error; err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
if createErr := s.createMemoryRecord(userID, wordID); createErr != nil {
|
||
return createErr
|
||
}
|
||
if err = s.db.Where("word_id = ? AND user_id = ?", wordID, userID).First(&record).Error; 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.db.Save(&record).Error
|
||
}
|
||
|
||
// 获取待复习单词
|
||
func (s *WordService) GetReviewWords(userID int64, mode string, limit int) ([]model.MemoryRecord, error) {
|
||
var records []model.MemoryRecord
|
||
|
||
query := s.db.Preload("Word").Where("user_id = ? AND (next_review_at <= ? OR next_review_at IS NULL)", userID, 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").Where("user_id = ?", userID).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(userID int64, req request.ReviewAnswerRequest) (*response.ReviewResult, error) {
|
||
var record model.MemoryRecord
|
||
if err := s.db.Preload("Word").Where("id = ? AND user_id = ?", req.RecordID, userID).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(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) {
|
||
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(userID int64) (map[string]interface{}, error) {
|
||
var totalWords int64
|
||
var masteredWords int64
|
||
var needReview int64
|
||
var todayReviewed int64
|
||
|
||
s.db.Model(&model.MemoryRecord{}).Where("user_id = ?", userID).Count(&totalWords)
|
||
s.db.Model(&model.MemoryRecord{}).Where("user_id = ? AND mastery_level >= 4", userID).Count(&masteredWords)
|
||
s.db.Model(&model.MemoryRecord{}).Where("user_id = ? AND next_review_at <= ?", userID, time.Now()).Count(&needReview)
|
||
s.db.Model(&model.MemoryRecord{}).Where("user_id = ? AND last_reviewed_at >= ?", userID, 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
|
||
}
|
||
|
||
func (s *WordService) CreateStudySession(userID int64, limit int) ([]model.Word, error) {
|
||
if limit <= 0 || limit > 50 {
|
||
limit = 10
|
||
}
|
||
var words []model.Word
|
||
if err := s.db.Order("created_at DESC").Limit(limit).Find(&words).Error; 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,
|
||
}
|
||
|
||
var record model.MemoryRecord
|
||
if err := s.db.Where("user_id = ? AND word_id = ?", userID, req.WordID).First(&record).Error; err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
if createErr := s.createMemoryRecord(userID, req.WordID); createErr != nil {
|
||
return nil, createErr
|
||
}
|
||
if err = s.db.Where("user_id = ? AND word_id = ?", userID, req.WordID).First(&record).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
} else {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
reviewReq.RecordID = record.ID
|
||
return s.SubmitReviewAnswer(userID, reviewReq)
|
||
}
|