refactor(web): restructure Vue3 app layout

This commit is contained in:
2026-02-26 12:20:19 +08:00
parent a4c2b6a40b
commit 52f691f02e
30 changed files with 3290 additions and 0 deletions

View File

@@ -0,0 +1,354 @@
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
}