refactor(web): restructure Vue3 app layout
This commit is contained in:
15
memora-api/Dockerfile
Normal file
15
memora-api/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM golang:1.21-alpine AS build
|
||||
WORKDIR /src
|
||||
RUN apk add --no-cache git
|
||||
COPY go.mod ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN go build -o /out/memora-api ./main.go
|
||||
|
||||
FROM alpine:3.19
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/memora-api /app/memora-api
|
||||
COPY config.yaml /app/config.yaml
|
||||
RUN mkdir -p /app/audio
|
||||
EXPOSE 8080
|
||||
CMD ["/app/memora-api"]
|
||||
18
memora-api/config.yaml
Normal file
18
memora-api/config.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 8080
|
||||
|
||||
database:
|
||||
host: localhost
|
||||
port: 3306
|
||||
user: root
|
||||
password: root
|
||||
name: memora
|
||||
charset: utf8mb4
|
||||
|
||||
youdao:
|
||||
app_id: "29a8d1df97e9e709"
|
||||
app_secret: "o3bC1llh9XQoVPuWuE3fbbqYU58zpTTn"
|
||||
|
||||
audio:
|
||||
path: ./audio
|
||||
39
memora-api/go.mod
Normal file
39
memora-api/go.mod
Normal file
@@ -0,0 +1,39 @@
|
||||
module memora-api
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/mysql v1.5.2
|
||||
gorm.io/gorm v1.25.5
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/crypto v0.9.0 // indirect
|
||||
golang.org/x/net v0.10.0 // indirect
|
||||
golang.org/x/sys v0.8.0 // indirect
|
||||
golang.org/x/text v0.9.0 // indirect
|
||||
google.golang.org/protobuf v1.30.0 // indirect
|
||||
)
|
||||
61
memora-api/internal/config/config.go
Normal file
61
memora-api/internal/config/config.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Youdao YoudaoConfig `yaml:"youdao"`
|
||||
Audio AudioConfig `yaml:"audio"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
User string `yaml:"user"`
|
||||
Password string `yaml:"password"`
|
||||
Name string `yaml:"name"`
|
||||
Charset string `yaml:"charset"`
|
||||
}
|
||||
|
||||
type YoudaoConfig struct {
|
||||
AppID string `yaml:"app_id"`
|
||||
AppKey string `yaml:"app_key"` // 兼容旧字段
|
||||
AppSecret string `yaml:"app_secret"`
|
||||
}
|
||||
|
||||
type AudioConfig struct {
|
||||
Path string `yaml:"path"`
|
||||
}
|
||||
|
||||
func (d *DatabaseConfig) DSN() string {
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local",
|
||||
d.User, d.Password, d.Host, d.Port, d.Name, d.Charset)
|
||||
}
|
||||
|
||||
var AppConfig *Config
|
||||
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
AppConfig = &cfg
|
||||
return &cfg, nil
|
||||
}
|
||||
140
memora-api/internal/handler/word.go
Normal file
140
memora-api/internal/handler/word.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"memora-api/internal/model"
|
||||
"memora-api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type WordHandler struct {
|
||||
wordService *service.WordService
|
||||
}
|
||||
|
||||
func NewWordHandler(wordService *service.WordService) *WordHandler {
|
||||
return &WordHandler{wordService: wordService}
|
||||
}
|
||||
|
||||
// 添加单词(记忆模式)
|
||||
func (h *WordHandler) AddWord(c *gin.Context) {
|
||||
var req model.AddWordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 调用有道API查询
|
||||
youdaoResp, err := h.wordService.QueryWord(req.Word)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if youdaoResp.ErrorCode != "0" && youdaoResp.ErrorCode != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("有道API错误: %s", youdaoResp.ErrorCode)})
|
||||
return
|
||||
}
|
||||
|
||||
// 保存到数据库
|
||||
word, err := h.wordService.SaveWord(req.Word, youdaoResp)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "单词添加成功",
|
||||
"data": word,
|
||||
})
|
||||
}
|
||||
|
||||
// 获取复习单词列表
|
||||
func (h *WordHandler) GetReviewWords(c *gin.Context) {
|
||||
mode := c.DefaultQuery("mode", "spelling")
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
|
||||
words, err := h.wordService.GetReviewWords(mode, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": words})
|
||||
}
|
||||
|
||||
// 提交复习答案
|
||||
func (h *WordHandler) SubmitReview(c *gin.Context) {
|
||||
var req model.ReviewAnswerRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.wordService.SubmitReviewAnswer(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||
}
|
||||
|
||||
// 获取所有单词
|
||||
func (h *WordHandler) GetWords(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
|
||||
words, total, err := h.wordService.GetAllWords(limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": words,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
// 获取统计信息
|
||||
func (h *WordHandler) GetStatistics(c *gin.Context) {
|
||||
stats, err := h.wordService.GetStatistics()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": stats})
|
||||
}
|
||||
|
||||
// 获取音频文件
|
||||
func (h *WordHandler) GetAudio(c *gin.Context) {
|
||||
word := c.Query("word")
|
||||
audioType := c.DefaultQuery("type", "uk")
|
||||
|
||||
if word == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少word参数"})
|
||||
return
|
||||
}
|
||||
|
||||
// very small sanitization
|
||||
word = strings.TrimSpace(word)
|
||||
if word == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "word为空"})
|
||||
return
|
||||
}
|
||||
for _, r := range word {
|
||||
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z') {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "word包含非法字符"})
|
||||
return
|
||||
}
|
||||
}
|
||||
filename := word + "_" + audioType + ".mp3"
|
||||
path := "./audio/" + filename
|
||||
c.File(path)
|
||||
}
|
||||
86
memora-api/internal/model/model.go
Normal file
86
memora-api/internal/model/model.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Word struct {
|
||||
ID int64 `json:"id" gorm:"primaryKey"`
|
||||
Word string `json:"word" gorm:"size:100;uniqueIndex;not null"`
|
||||
PhoneticUK string `json:"phonetic_uk" gorm:"size:255"`
|
||||
PhoneticUS string `json:"phonetic_us" gorm:"size:255"`
|
||||
AudioUK string `json:"audio_uk" gorm:"size:500"`
|
||||
AudioUS string `json:"audio_us" gorm:"size:500"`
|
||||
PartOfSpeech string `json:"part_of_speech" gorm:"size:50"`
|
||||
Definition string `json:"definition" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Word) TableName() string {
|
||||
return "words"
|
||||
}
|
||||
|
||||
type MemoryRecord struct {
|
||||
ID int64 `json:"id" gorm:"primaryKey"`
|
||||
WordID int64 `json:"word_id" gorm:"index;not null"`
|
||||
UserID int64 `json:"user_id" gorm:"index;default:1"`
|
||||
CorrectCount int `json:"correct_count" gorm:"default:0"`
|
||||
TotalCount int `json:"total_count" gorm:"default:0"`
|
||||
MasteryLevel int `json:"mastery_level" gorm:"default:0"`
|
||||
LastReviewedAt *time.Time `json:"last_reviewed_at"`
|
||||
NextReviewAt *time.Time `json:"next_review_at" gorm:"index"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Word *Word `json:"word,omitempty" gorm:"foreignKey:WordID"`
|
||||
}
|
||||
|
||||
func (MemoryRecord) TableName() string {
|
||||
return "memory_records"
|
||||
}
|
||||
|
||||
// 请求响应结构
|
||||
type AddWordRequest struct {
|
||||
Word string `json:"word" binding:"required"`
|
||||
}
|
||||
|
||||
type ReviewAnswerRequest struct {
|
||||
RecordID int64 `json:"record_id" binding:"required"`
|
||||
Answer string `json:"answer" binding:"required"`
|
||||
Mode string `json:"mode" binding:"required"` // spelling, en2cn, cn2en
|
||||
}
|
||||
|
||||
type ReviewResult struct {
|
||||
Word *Word `json:"word"`
|
||||
Correct bool `json:"correct"`
|
||||
Answer string `json:"answer"`
|
||||
CorrectAns string `json:"correct_ans,omitempty"`
|
||||
}
|
||||
|
||||
// 有道API响应 (标准API)
|
||||
type YoudaoResponse struct {
|
||||
Query string `json:"query"`
|
||||
Translation []string `json:"translation"`
|
||||
Basic struct {
|
||||
Phonetic string `json:"phonetic"`
|
||||
UkPhonetic string `json:"uk-phonetic"`
|
||||
UsPhonetic string `json:"us-phonetic"`
|
||||
ExamType []string `json:"exam_type"`
|
||||
Wfs []struct {
|
||||
Wf struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"wf"`
|
||||
Means []struct {
|
||||
Mean struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"mean"`
|
||||
} `json:"means"`
|
||||
} `json:"wfs"`
|
||||
} `json:"basic"`
|
||||
SpeakUrl string `json:"speakUrl"`
|
||||
SpeakFile string `json:"speakFile"`
|
||||
Web []struct {
|
||||
Value []string `json:"value"`
|
||||
} `json:"web"`
|
||||
ErrorCode string `json:"errorCode"`
|
||||
}
|
||||
354
memora-api/internal/service/word.go
Normal file
354
memora-api/internal/service/word.go
Normal 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
|
||||
}
|
||||
78
memora-api/main.go
Normal file
78
memora-api/main.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"memora-api/internal/config"
|
||||
"memora-api/internal/handler"
|
||||
"memora-api/internal/model"
|
||||
"memora-api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 加载配置
|
||||
cfg, err := config.LoadConfig("config.yaml")
|
||||
if err != nil {
|
||||
log.Fatalf("加载配置失败: %v", err)
|
||||
}
|
||||
|
||||
// 连接数据库
|
||||
db, err := gorm.Open(mysql.Open(cfg.Database.DSN()), &gorm.Config{})
|
||||
if err != nil {
|
||||
log.Fatalf("连接数据库失败: %v", err)
|
||||
}
|
||||
|
||||
// 自动迁移表
|
||||
if err := db.AutoMigrate(&model.Word{}, &model.MemoryRecord{}); err != nil {
|
||||
log.Fatalf("数据库迁移失败: %v", err)
|
||||
}
|
||||
|
||||
// 初始化服务
|
||||
wordService := service.NewWordService(db)
|
||||
wordHandler := handler.NewWordHandler(wordService)
|
||||
|
||||
// 启动 Gin
|
||||
r := gin.Default()
|
||||
|
||||
// CORS 中间件
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
// 路由
|
||||
api := r.Group("/api")
|
||||
{
|
||||
// 记忆模式
|
||||
api.POST("/words", wordHandler.AddWord)
|
||||
api.GET("/words", wordHandler.GetWords)
|
||||
|
||||
// 复习模式
|
||||
api.GET("/review", wordHandler.GetReviewWords)
|
||||
api.POST("/review", wordHandler.SubmitReview)
|
||||
|
||||
// 统计
|
||||
api.GET("/stats", wordHandler.GetStatistics)
|
||||
|
||||
// 音频
|
||||
api.GET("/audio", wordHandler.GetAudio)
|
||||
}
|
||||
|
||||
// 启动服务器
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
|
||||
log.Printf("服务器启动在: %s", addr)
|
||||
if err := r.Run(addr); err != nil {
|
||||
log.Fatalf("启动服务器失败: %v", err)
|
||||
}
|
||||
}
|
||||
36
memora-api/sql/init.sql
Normal file
36
memora-api/sql/init.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- 初始化数据库
|
||||
CREATE DATABASE IF NOT EXISTS memora DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
USE memora;
|
||||
|
||||
-- 标准单词表
|
||||
CREATE TABLE IF NOT EXISTS words (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
word VARCHAR(100) NOT NULL UNIQUE COMMENT '单词',
|
||||
phonetic_uk VARCHAR(255) COMMENT '英式音标',
|
||||
phonetic_us VARCHAR(255) COMMENT '美式音标',
|
||||
audio_uk VARCHAR(500) COMMENT '英式音频文件路径',
|
||||
audio_us VARCHAR(500) COMMENT '美式音频文件路径',
|
||||
part_of_speech VARCHAR(50) COMMENT '词性',
|
||||
definition TEXT COMMENT '标准释义',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_word (word)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 记忆记录表
|
||||
CREATE TABLE IF NOT EXISTS memory_records (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
word_id BIGINT NOT NULL COMMENT '关联words表',
|
||||
user_id BIGINT DEFAULT 1 COMMENT '用户ID',
|
||||
correct_count INT DEFAULT 0 COMMENT '正确次数',
|
||||
total_count INT DEFAULT 0 COMMENT '总复习次数',
|
||||
mastery_level INT DEFAULT 0 COMMENT '掌握程度 0-5',
|
||||
last_reviewed_at TIMESTAMP NULL COMMENT '上次复习时间',
|
||||
next_review_at TIMESTAMP NULL COMMENT '下次复习时间',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_word_id (word_id),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_next_review (next_review_at),
|
||||
FOREIGN KEY (word_id) REFERENCES words(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
Reference in New Issue
Block a user