560 lines
16 KiB
Markdown
560 lines
16 KiB
Markdown
# 複習系統後端規格書 (更新版)
|
||
|
||
**版本**: 2.0
|
||
**基於**: 前端技術規格實作版 + 實際系統需求
|
||
**技術棧**: .NET 8 + Entity Framework + SQLite
|
||
**架構**: RESTful API + Clean Architecture
|
||
**最後更新**: 2025-10-06
|
||
**狀態**: 🚧 **準備實作階段** - 前端已完成,需要後端API支援
|
||
|
||
---
|
||
|
||
## 📊 **前端需求分析**
|
||
|
||
### **✅ 前端已實現功能**
|
||
- 完整的複習流程 (翻卡記憶 + 詞彙選擇)
|
||
- 延遲計數系統 (skipCount + wrongCount)
|
||
- 智能排序算法 (優先級排序)
|
||
- localStorage 進度保存
|
||
- 線性測驗項目系統
|
||
- 信心度評估 (0=不熟悉, 1=一般, 2=熟悉)
|
||
|
||
### **❗ 前端急需的API**
|
||
1. **獲取詞卡數據** - 替換靜態 api_seeds.json
|
||
2. **記錄複習結果** - 實現間隔重複算法
|
||
3. **進度同步** - 支援多設備學習
|
||
|
||
---
|
||
|
||
## 🏗️ **API端點設計 (實作優先級)**
|
||
|
||
### **🔥 階段1: 核心API (立即需要)**
|
||
|
||
#### **1.1 獲取待複習詞卡**
|
||
```http
|
||
GET /api/flashcards/due
|
||
Headers:
|
||
- Authorization: Bearer {token}
|
||
Query Parameters:
|
||
- limit: number (default: 10, max: 50)
|
||
- includeToday: boolean (default: true)
|
||
- includeOverdue: boolean (default: true)
|
||
- favoritesOnly: boolean (default: false)
|
||
|
||
Response:
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"flashcards": [
|
||
{
|
||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||
"word": "evidence",
|
||
"translation": "證據",
|
||
"definition": "facts or information indicating whether a belief is true",
|
||
"partOfSpeech": "noun",
|
||
"pronunciation": "/ˈevɪdəns/",
|
||
"example": "There was evidence of forced entry.",
|
||
"exampleTranslation": "有強行進入的證據。",
|
||
"cefr": "B2", // 字串格式 (前端需要)
|
||
"difficultyLevelNumeric": 4, // 數字格式 (後端計算)
|
||
"isFavorite": true,
|
||
"hasExampleImage": false,
|
||
"primaryImageUrl": null,
|
||
"synonyms": ["proof", "testimony"], // 前端需要
|
||
"createdAt": "2025-10-01T12:48:11Z",
|
||
"updatedAt": "2025-10-01T13:37:22Z",
|
||
|
||
// 複習相關信息
|
||
"reviewInfo": {
|
||
"successCount": 2,
|
||
"nextReviewDate": "2025-10-06T10:00:00Z",
|
||
"lastReviewDate": "2025-10-04T15:30:00Z",
|
||
"totalCorrectCount": 5,
|
||
"totalWrongCount": 2,
|
||
"totalSkipCount": 1,
|
||
"isOverdue": false,
|
||
"daysSinceLastReview": 2
|
||
}
|
||
}
|
||
],
|
||
"count": 8,
|
||
"metadata": {
|
||
"todayDue": 5,
|
||
"overdue": 3,
|
||
"totalReviews": 45,
|
||
"studyStreak": 7
|
||
}
|
||
},
|
||
"message": null,
|
||
"timestamp": "2025-10-06T09:00:00Z"
|
||
}
|
||
```
|
||
|
||
#### **1.2 記錄複習結果**
|
||
```http
|
||
POST /api/flashcards/{flashcardId}/review
|
||
Headers:
|
||
- Authorization: Bearer {token}
|
||
- Content-Type: application/json
|
||
|
||
Request Body:
|
||
{
|
||
"confidence": 1, // 0=不熟悉, 1=一般, 2=熟悉 (配合前端)
|
||
"reviewType": "flip-card", // "flip-card" | "vocab-choice"
|
||
"responseTimeMs": 4200, // 回應時間
|
||
"wasSkipped": false, // 是否跳過
|
||
"sessionSkipCount": 0, // 本次會話跳過次數 (前端統計)
|
||
"sessionWrongCount": 1, // 本次會話錯誤次數 (前端統計)
|
||
|
||
// 可選: 詳細信息
|
||
"testItemId": "card-123-flip-card", // 前端測驗項目ID
|
||
"sessionData": { // 會話數據 (可選)
|
||
"totalItems": 20,
|
||
"completedItems": 8,
|
||
"sessionScore": { "correct": 6, "total": 8 }
|
||
}
|
||
}
|
||
|
||
Response:
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"flashcardId": "550e8400-e29b-41d4-a716-446655440000",
|
||
"reviewId": "review-uuid-123",
|
||
"result": {
|
||
"isCorrect": true, // confidence >= 1 算答對
|
||
"newSuccessCount": 3, // 更新後的連續成功次數
|
||
"nextReviewDate": "2025-10-14T09:00:00Z", // 下次複習時間
|
||
"intervalDays": 8, // 間隔天數 (2^3)
|
||
"masteryProgress": 0.75, // 熟練度進度 (0-1)
|
||
"studyStreak": 8 // 學習連續天數
|
||
},
|
||
"statistics": {
|
||
"totalCorrectCount": 6, // 累計正確次數
|
||
"totalWrongCount": 2, // 累計錯誤次數
|
||
"totalSkipCount": 1, // 累計跳過次數
|
||
"averageResponseTime": 3800, // 平均回應時間
|
||
"lastCorrectStreak": 3 // 最近連續正確次數
|
||
}
|
||
},
|
||
"timestamp": "2025-10-06T09:15:00Z"
|
||
}
|
||
```
|
||
|
||
#### **1.3 獲取複習統計**
|
||
```http
|
||
GET /api/review/stats
|
||
Headers:
|
||
- Authorization: Bearer {token}
|
||
Query Parameters:
|
||
- period: string ("today" | "week" | "month" | "all")
|
||
|
||
Response:
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"today": {
|
||
"reviewed": 12,
|
||
"due": 15,
|
||
"accuracy": 0.83,
|
||
"averageTime": 3200
|
||
},
|
||
"week": {
|
||
"reviewed": 85,
|
||
"accuracy": 0.79,
|
||
"studyDays": 6,
|
||
"streak": 8
|
||
},
|
||
"overall": {
|
||
"totalReviews": 456,
|
||
"totalCards": 89,
|
||
"masteryLevel": 0.67,
|
||
"averageInterval": 12.5
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### **⚡ 階段2: 增強功能 (次要優先)**
|
||
|
||
#### **2.1 批量複習結果提交**
|
||
```http
|
||
POST /api/review/batch
|
||
Request Body:
|
||
{
|
||
"reviews": [
|
||
{
|
||
"flashcardId": "uuid-1",
|
||
"confidence": 2,
|
||
"reviewType": "flip-card",
|
||
"timestamp": "2025-10-06T09:10:00Z"
|
||
},
|
||
{
|
||
"flashcardId": "uuid-2",
|
||
"confidence": 0,
|
||
"reviewType": "vocab-choice",
|
||
"timestamp": "2025-10-06T09:12:00Z"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
#### **2.2 複習計劃推薦**
|
||
```http
|
||
GET /api/review/plan
|
||
Response:
|
||
{
|
||
"recommendedDailyGoal": 20,
|
||
"optimalStudyTime": "09:00-11:00",
|
||
"priorityCards": [...],
|
||
"estimatedCompletionTime": 15
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## ⏰ **間隔重複算法 (核心業務邏輯)**
|
||
|
||
### **算法公式 (配合前端信心度)**
|
||
```csharp
|
||
public class SpacedRepetitionAlgorithm
|
||
{
|
||
public ReviewResult ProcessReview(FlashcardReview review, int confidence, bool wasSkipped)
|
||
{
|
||
if (wasSkipped)
|
||
{
|
||
// 跳過: 不改變成功次數,明天再複習
|
||
review.TotalSkipCount++;
|
||
review.NextReviewDate = DateTime.UtcNow.AddDays(1);
|
||
}
|
||
else
|
||
{
|
||
var isCorrect = confidence >= 1; // 前端: 0=不熟悉, 1=一般, 2=熟悉
|
||
|
||
if (isCorrect)
|
||
{
|
||
// 答對: 增加成功次數,計算新間隔
|
||
review.SuccessCount++;
|
||
review.TotalCorrectCount++;
|
||
review.LastSuccessDate = DateTime.UtcNow;
|
||
|
||
// 核心公式: 間隔 = 2^成功次數 天
|
||
var intervalDays = Math.Pow(2, review.SuccessCount);
|
||
var maxInterval = 180; // 最大半年
|
||
var finalInterval = Math.Min(intervalDays, maxInterval);
|
||
|
||
review.NextReviewDate = DateTime.UtcNow.AddDays(finalInterval);
|
||
}
|
||
else
|
||
{
|
||
// 答錯: 重置成功次數,明天再複習
|
||
review.SuccessCount = 0;
|
||
review.TotalWrongCount++;
|
||
review.NextReviewDate = DateTime.UtcNow.AddDays(1);
|
||
}
|
||
}
|
||
|
||
review.LastReviewDate = DateTime.UtcNow;
|
||
review.UpdatedAt = DateTime.UtcNow;
|
||
|
||
return new ReviewResult
|
||
{
|
||
IsCorrect = !wasSkipped && confidence >= 1,
|
||
NewSuccessCount = review.SuccessCount,
|
||
NextReviewDate = review.NextReviewDate,
|
||
IntervalDays = CalculateIntervalDays(review.NextReviewDate)
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
### **信心度映射表**
|
||
| 前端信心度 | 標籤 | 後端判定 | 下次間隔 |
|
||
|-----------|------|----------|----------|
|
||
| 0 | 不熟悉 | ❌ 答錯 | 明天 (重置) |
|
||
| 1 | 一般 | ✅ 答對 | 2^(n+1) 天 |
|
||
| 2 | 熟悉 | ✅ 答對 | 2^(n+1) 天 |
|
||
| skip | 跳過 | ⏭️ 跳過 | 明天 (不變) |
|
||
|
||
### **間隔計算示例**
|
||
```
|
||
成功次數 0 → 1: 明天 (1天)
|
||
成功次數 1 → 2: 後天 (2天)
|
||
成功次數 2 → 3: 4天後
|
||
成功次數 3 → 4: 8天後
|
||
成功次數 4 → 5: 16天後
|
||
成功次數 5 → 6: 32天後
|
||
成功次數 6 → 7: 64天後
|
||
成功次數 7+: 128天後 (最大 180天)
|
||
```
|
||
|
||
---
|
||
|
||
## 🗃️ **數據庫設計**
|
||
|
||
### **FlashcardReviews 表**
|
||
```sql
|
||
CREATE TABLE FlashcardReviews (
|
||
Id UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWID(),
|
||
FlashcardId UNIQUEIDENTIFIER NOT NULL,
|
||
UserId UNIQUEIDENTIFIER NOT NULL,
|
||
|
||
-- 核心間隔重複欄位
|
||
SuccessCount INT DEFAULT 0, -- 連續成功次數
|
||
NextReviewDate DATETIME2 NOT NULL, -- 下次複習時間
|
||
LastReviewDate DATETIME2 NULL, -- 最後複習時間
|
||
LastSuccessDate DATETIME2 NULL, -- 最後成功時間
|
||
|
||
-- 統計欄位
|
||
TotalCorrectCount INT DEFAULT 0, -- 累計正確次數
|
||
TotalWrongCount INT DEFAULT 0, -- 累計錯誤次數
|
||
TotalSkipCount INT DEFAULT 0, -- 累計跳過次數
|
||
|
||
-- 系統欄位
|
||
CreatedAt DATETIME2 DEFAULT GETUTCDATE(),
|
||
UpdatedAt DATETIME2 DEFAULT GETUTCDATE(),
|
||
|
||
-- 外鍵約束
|
||
FOREIGN KEY (FlashcardId) REFERENCES Flashcards(Id),
|
||
FOREIGN KEY (UserId) REFERENCES Users(Id),
|
||
|
||
-- 唯一性約束 (每個用戶每張卡片只能有一條記錄)
|
||
UNIQUE(FlashcardId, UserId)
|
||
);
|
||
|
||
-- 性能索引
|
||
CREATE INDEX IX_FlashcardReviews_NextReviewDate ON FlashcardReviews(NextReviewDate);
|
||
CREATE INDEX IX_FlashcardReviews_UserId_NextReviewDate ON FlashcardReviews(UserId, NextReviewDate);
|
||
```
|
||
|
||
### **ReviewSessions 表 (可選)**
|
||
```sql
|
||
-- 會話記錄表 (用於分析和統計)
|
||
CREATE TABLE ReviewSessions (
|
||
Id UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWID(),
|
||
UserId UNIQUEIDENTIFIER NOT NULL,
|
||
StartTime DATETIME2 NOT NULL,
|
||
EndTime DATETIME2 NULL,
|
||
TotalItems INT DEFAULT 0,
|
||
CompletedItems INT DEFAULT 0,
|
||
CorrectItems INT DEFAULT 0,
|
||
SkippedItems INT DEFAULT 0,
|
||
AverageResponseTime INT NULL,
|
||
SessionType NVARCHAR(50) DEFAULT 'mixed', -- 'flip-only', 'choice-only', 'mixed'
|
||
CreatedAt DATETIME2 DEFAULT GETUTCDATE()
|
||
);
|
||
```
|
||
|
||
---
|
||
|
||
## 🎯 **業務邏輯服務設計**
|
||
|
||
### **IReviewService 接口**
|
||
```csharp
|
||
public interface IReviewService
|
||
{
|
||
// 核心功能
|
||
Task<ApiResponse<List<FlashcardDto>>> GetDueFlashcardsAsync(
|
||
string userId,
|
||
DueFlashcardsQuery query);
|
||
|
||
Task<ApiResponse<ReviewResult>> SubmitReviewAsync(
|
||
string userId,
|
||
Guid flashcardId,
|
||
ReviewRequest request);
|
||
|
||
// 統計功能
|
||
Task<ApiResponse<ReviewStats>> GetReviewStatsAsync(
|
||
string userId,
|
||
string period = "today");
|
||
|
||
// 批量處理
|
||
Task<ApiResponse<List<ReviewResult>>> SubmitBatchReviewAsync(
|
||
string userId,
|
||
List<ReviewRequest> reviews);
|
||
}
|
||
```
|
||
|
||
### **ReviewService 實作重點**
|
||
```csharp
|
||
public class ReviewService : IReviewService
|
||
{
|
||
public async Task<ApiResponse<List<FlashcardDto>>> GetDueFlashcardsAsync(
|
||
string userId, DueFlashcardsQuery query)
|
||
{
|
||
var now = DateTime.UtcNow;
|
||
|
||
// 1. 獲取用戶的詞卡
|
||
var flashcardsQuery = _context.Flashcards
|
||
.Where(f => f.UserId == Guid.Parse(userId) && !f.IsArchived);
|
||
|
||
// 2. Left Join 複習記錄
|
||
var flashcardsWithReviews = await flashcardsQuery
|
||
.GroupJoin(_context.FlashcardReviews,
|
||
f => f.Id,
|
||
r => r.FlashcardId,
|
||
(flashcard, reviews) => new {
|
||
Flashcard = flashcard,
|
||
Review = reviews.FirstOrDefault()
|
||
})
|
||
.Where(x =>
|
||
// 沒有複習記錄的新卡片
|
||
x.Review == null ||
|
||
// 或者到期需要複習的卡片
|
||
x.Review.NextReviewDate <= now.AddDays(query.IncludeToday ? 1 : 0))
|
||
.Take(query.Limit)
|
||
.ToListAsync();
|
||
|
||
// 3. 轉換為 DTO
|
||
return new ApiResponse<List<FlashcardDto>>
|
||
{
|
||
Success = true,
|
||
Data = flashcardsWithReviews.Select(x => new FlashcardDto
|
||
{
|
||
// 基本詞卡信息
|
||
Id = x.Flashcard.Id,
|
||
Word = x.Flashcard.Word,
|
||
// ... 其他欄位
|
||
|
||
// 複習信息
|
||
ReviewInfo = x.Review != null ? new ReviewInfo
|
||
{
|
||
SuccessCount = x.Review.SuccessCount,
|
||
NextReviewDate = x.Review.NextReviewDate,
|
||
LastReviewDate = x.Review.LastReviewDate,
|
||
// ... 統計信息
|
||
} : null
|
||
}).ToList()
|
||
};
|
||
}
|
||
|
||
public async Task<ApiResponse<ReviewResult>> SubmitReviewAsync(
|
||
string userId, Guid flashcardId, ReviewRequest request)
|
||
{
|
||
// 1. 獲取或創建複習記錄
|
||
var review = await GetOrCreateReviewAsync(userId, flashcardId);
|
||
|
||
// 2. 使用間隔重複算法處理
|
||
var algorithm = new SpacedRepetitionAlgorithm();
|
||
var result = algorithm.ProcessReview(review, request.Confidence, request.WasSkipped);
|
||
|
||
// 3. 保存到數據庫
|
||
_context.FlashcardReviews.Update(review);
|
||
await _context.SaveChangesAsync();
|
||
|
||
// 4. 返回結果
|
||
return new ApiResponse<ReviewResult>
|
||
{
|
||
Success = true,
|
||
Data = result
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🔄 **前端集成策略**
|
||
|
||
### **階段性集成計劃**
|
||
```typescript
|
||
// 階段1: API降級策略
|
||
const useReviewData = () => {
|
||
const [dataSource, setDataSource] = useState<'static' | 'api'>('static');
|
||
|
||
const loadFlashcards = async () => {
|
||
try {
|
||
// 嘗試 API 調用
|
||
const response = await fetch('/api/flashcards/due');
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
setDataSource('api');
|
||
return data.data.flashcards;
|
||
}
|
||
} catch (error) {
|
||
console.warn('API 不可用,使用靜態數據');
|
||
}
|
||
|
||
// 降級到靜態數據
|
||
setDataSource('static');
|
||
return SIMPLE_CARDS;
|
||
};
|
||
};
|
||
|
||
// 階段2: 複習結果同步
|
||
const submitReview = async (flashcardId: string, confidence: number) => {
|
||
// 立即更新前端狀態
|
||
updateLocalState(confidence);
|
||
|
||
// 異步提交到後端
|
||
if (dataSource === 'api') {
|
||
try {
|
||
await apiService.submitReview(flashcardId, {
|
||
confidence,
|
||
reviewType: currentTestItem.testType,
|
||
responseTimeMs: calculateResponseTime(),
|
||
wasSkipped: false
|
||
});
|
||
} catch (error) {
|
||
console.warn('API 提交失敗,保持本地狀態');
|
||
}
|
||
}
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## 📋 **開發優先級與時程**
|
||
|
||
### **🔥 第1週: 核心API (必須完成)**
|
||
- [x] FlashcardReview 實體設計 ✅
|
||
- [ ] FlashcardsController 基礎端點
|
||
- [ ] 間隔重複算法實作
|
||
- [ ] 數據庫遷移
|
||
|
||
### **⚡ 第2週: 前端集成 (關鍵)**
|
||
- [ ] API服務層封裝
|
||
- [ ] 前端降級處理
|
||
- [ ] 信心度映射統一
|
||
- [ ] 錯誤處理完善
|
||
|
||
### **📊 第3週: 優化與測試**
|
||
- [ ] 性能優化
|
||
- [ ] 批量處理
|
||
- [ ] 統計功能
|
||
- [ ] 完整測試
|
||
|
||
### **🎯 第4週: 完善與部署**
|
||
- [ ] 文檔更新
|
||
- [ ] 監控集成
|
||
- [ ] 生產部署
|
||
- [ ] 用戶驗收
|
||
|
||
---
|
||
|
||
## 🎯 **成功標準**
|
||
|
||
### **功能驗收標準**
|
||
- ✅ 前端可以從 API 獲取真實詞卡數據
|
||
- ✅ 複習結果正確提交並計算間隔時間
|
||
- ✅ 間隔重複算法按 2^n 公式運作
|
||
- ✅ API 失敗時前端仍可正常運作
|
||
- ✅ 多設備間複習進度同步
|
||
|
||
### **性能標準**
|
||
- ✅ 獲取詞卡 API < 500ms
|
||
- ✅ 提交複習結果 < 200ms
|
||
- ✅ 支援並發用戶 > 100
|
||
- ✅ 數據庫查詢優化 < 100ms
|
||
|
||
### **可靠性標準**
|
||
- ✅ API 可用性 > 99.5%
|
||
- ✅ 數據一致性保證
|
||
- ✅ 錯誤處理完善
|
||
- ✅ 監控和日誌完整
|
||
|
||
---
|
||
|
||
*此規格書基於前端實作需求撰寫,確保後端API能完美支援現有前端功能*
|
||
*維護責任: 後端開發團隊*
|
||
*更新觸發: 前端需求變更或API優化* |