Files
memora/memora-api/internal/service/word.go
wsy182 613ce02e9a feat(layout): 更新应用布局和UI组件样式
- 重构App.vue中的侧边栏布局,更新Logo设计为带有标识和副标题的新样式
- 调整顶部导航栏,增加标题区域显示当前路由标题和日期
- 修改菜单项配置,更新导航标签为更直观的中文描述
- 在Home.vue中替换原有的仪表板为新的Hero卡片和项目进展展示
- 更新Memory.vue中的学习界面,添加学习计划设置和多阶段学习模式
- 集成新的API端点路径,将baseURL从/api调整为/api/v1
- 调整整体视觉风格,包括颜色主题、字体家族和响应式布局
- 更新数据库模型以支持词库功能,添加相关的数据迁移和种子数据
- 调整认证系统的用户ID类型从整型到字符串的变更
- 更改前端构建工具从npm到pnpm,并更新相应的Dockerfile配置
2026-02-27 16:16:57 +08:00

476 lines
12 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"
"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 string, 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使用有道 dictvoicetype=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{
BookID: model.DefaultWordBookID,
HeadWord: 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 string) 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 string, 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 string, 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 string, 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, query string) ([]model.Word, int64, error) {
return s.wordRepo.List(limit, offset, strings.TrimSpace(query))
}
func (s *WordService) GetWordByID(id string) (*model.Word, error) {
return s.wordRepo.FindByID(id)
}
// 获取记忆统计
func (s *WordService) GetStatistics(userID string) (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 string, 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 string, req request.SubmitStudyAnswerRequest) (*response.ReviewResult, error) {
reviewReq := request.ReviewAnswerRequest{
RecordID: "",
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)
}