657 lines
17 KiB
Markdown
657 lines
17 KiB
Markdown
# 複習系統後端規格書
|
||
|
||
**版本**: 1.0
|
||
**對應**: 前端規格.md + 技術實作規格.md
|
||
**技術棧**: .NET 8 + Entity Framework + SQLite
|
||
**架構**: RESTful API + Clean Architecture
|
||
**最後更新**: 2025-10-03
|
||
|
||
---
|
||
|
||
## 🏗️ **API端點設計**
|
||
|
||
### **階段1: 純靜態 (當前MVP)**
|
||
```
|
||
前端: 使用 api_seeds.json 靜態數據
|
||
後端: 完全不需要開發
|
||
狀態: 前端React useState管理所有邏輯
|
||
目的: 驗證用戶體驗和延遲計數系統
|
||
```
|
||
|
||
### **階段2: 基礎持久化 (用戶要求時)**
|
||
```
|
||
前端: 同階段1,但添加localStorage持久化
|
||
後端: 仍然不需要開發
|
||
新增: 本地進度保存和恢復
|
||
觸發: 用戶反饋希望保存進度
|
||
```
|
||
|
||
### **階段3: API集成 (遠期需求)**
|
||
|
||
**獲取複習卡片**:
|
||
```http
|
||
GET /api/flashcards/due
|
||
Query Parameters:
|
||
- limit: number (default: 10)
|
||
- userId: string (from auth)
|
||
|
||
Response:
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"flashcards": [
|
||
{
|
||
"id": "uuid",
|
||
"word": "evidence",
|
||
"definition": "facts indicating truth",
|
||
"partOfSpeech": "noun",
|
||
"pronunciation": "/ˈevɪdəns/",
|
||
"example": "There was evidence of...",
|
||
"exampleTranslation": "有...的證據",
|
||
"cefr": "B2",
|
||
"difficultyLevelNumeric": 4,
|
||
"isFavorite": true,
|
||
"hasExampleImage": false,
|
||
"primaryImageUrl": null,
|
||
"createdAt": "2025-10-01T12:48:11Z",
|
||
"updatedAt": "2025-10-01T13:37:22Z"
|
||
}
|
||
],
|
||
"count": 4
|
||
},
|
||
"message": null,
|
||
"timestamp": "2025-10-03T18:57:25Z"
|
||
}
|
||
```
|
||
|
||
**記錄複習結果**:
|
||
```http
|
||
POST /api/flashcards/{flashcardId}/review
|
||
Headers:
|
||
- Authorization: Bearer {token}
|
||
- Content-Type: application/json
|
||
|
||
Request Body:
|
||
{
|
||
"confidence": 2, // 1=模糊, 2=一般, 3=熟悉
|
||
"isCorrect": true, // 基於 confidence >= 2 判斷
|
||
"reviewType": "flip-memory",
|
||
"responseTimeMs": 3500, // 回應時間
|
||
"wasSkipped": false, // 是否是跳過的題目
|
||
"skipCount": 0, // 該卡片的跳過次數 (前端統計)
|
||
"wrongCount": 1 // 該卡片的答錯次數 (前端統計)
|
||
}
|
||
|
||
Response:
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"flashcardId": "uuid",
|
||
"newSuccessCount": 3,
|
||
"nextReviewDate": "2025-10-08T12:00:00Z",
|
||
"masteryLevelChange": 0.1
|
||
},
|
||
"timestamp": "2025-10-03T19:00:00Z"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## ⏰ **核心算法: 間隔重複系統**
|
||
|
||
### **複習時間計算公式** (您的需求)
|
||
```csharp
|
||
// 基礎公式: 下一次複習時間 = 2^成功複習次數
|
||
public DateTime CalculateNextReviewDate(int successCount)
|
||
{
|
||
// 計算間隔天數
|
||
var intervalDays = Math.Pow(2, successCount);
|
||
|
||
// 設定最大間隔限制 (避免間隔過長)
|
||
var maxIntervalDays = 180; // 最多半年
|
||
var finalInterval = Math.Min(intervalDays, maxIntervalDays);
|
||
|
||
return DateTime.UtcNow.AddDays(finalInterval);
|
||
}
|
||
```
|
||
|
||
### **具體計算範例**
|
||
```csharp
|
||
// 學習進度示例
|
||
成功次數 0: 下次複習 = 今天 + 2^0 = 明天 (1天後)
|
||
成功次數 1: 下次複習 = 今天 + 2^1 = 後天 (2天後)
|
||
成功次數 2: 下次複習 = 今天 + 2^2 = 4天後
|
||
成功次數 3: 下次複習 = 今天 + 2^3 = 8天後
|
||
成功次數 4: 下次複習 = 今天 + 2^4 = 16天後
|
||
成功次數 5: 下次複習 = 今天 + 2^5 = 32天後
|
||
成功次數 6: 下次複習 = 今天 + 2^6 = 64天後
|
||
成功次數 7: 下次複習 = 今天 + 2^7 = 128天後
|
||
成功次數 8+: 下次複習 = 今天 + 180天 (上限)
|
||
```
|
||
|
||
### **成功計數更新邏輯**
|
||
```csharp
|
||
public async Task<ReviewResult> ProcessReviewAttemptAsync(string flashcardId, ReviewAttemptRequest request)
|
||
{
|
||
var stats = await GetFlashcardStatsAsync(flashcardId, request.UserId);
|
||
|
||
if (request.IsCorrect)
|
||
{
|
||
// 答對: 增加成功次數,計算新的複習時間
|
||
stats.SuccessCount++;
|
||
stats.NextReviewDate = CalculateNextReviewDate(stats.SuccessCount);
|
||
stats.LastSuccessDate = DateTime.UtcNow;
|
||
}
|
||
else
|
||
{
|
||
// 答錯或跳過: 重置成功次數,明天再複習
|
||
stats.SuccessCount = 0;
|
||
stats.NextReviewDate = DateTime.UtcNow.AddDays(1);
|
||
}
|
||
|
||
// 記錄延遲統計 (配合前端延遲計數)
|
||
if (request.WasSkipped)
|
||
{
|
||
stats.TotalSkipCount += 1;
|
||
}
|
||
|
||
if (!request.IsCorrect && !request.WasSkipped)
|
||
{
|
||
stats.TotalWrongCount += 1;
|
||
}
|
||
|
||
await SaveStatsAsync(stats);
|
||
|
||
return new ReviewResult
|
||
{
|
||
FlashcardId = flashcardId,
|
||
NewSuccessCount = stats.SuccessCount,
|
||
NextReviewDate = stats.NextReviewDate,
|
||
IntervalDays = CalculateIntervalDays(stats.SuccessCount)
|
||
};
|
||
}
|
||
```
|
||
|
||
### **與前端延遲計數的配合**
|
||
```csharp
|
||
// 前端延遲計數系統的後端記錄
|
||
public class FlashcardSessionState
|
||
{
|
||
public string FlashcardId { get; set; }
|
||
public int SessionSkipCount { get; set; } // 本次會話跳過次數
|
||
public int SessionWrongCount { get; set; } // 本次會話答錯次數
|
||
public DateTime LastAttemptTime { get; set; }
|
||
}
|
||
|
||
// 處理會話中的延遲行為
|
||
public void RecordSessionDelay(string flashcardId, bool wasSkipped, bool wasWrong)
|
||
{
|
||
var sessionState = GetOrCreateSessionState(flashcardId);
|
||
|
||
if (wasSkipped) sessionState.SessionSkipCount++;
|
||
if (wasWrong) sessionState.SessionWrongCount++;
|
||
|
||
sessionState.LastAttemptTime = DateTime.UtcNow;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🗃️ **數據庫設計**
|
||
|
||
### **現有表格** (已存在)
|
||
```sql
|
||
-- Flashcards 表 (主要詞卡信息)
|
||
CREATE TABLE Flashcards (
|
||
Id UNIQUEIDENTIFIER PRIMARY KEY,
|
||
Word NVARCHAR(100) NOT NULL,
|
||
Definition NVARCHAR(500),
|
||
PartOfSpeech NVARCHAR(50),
|
||
Pronunciation NVARCHAR(100),
|
||
Example NVARCHAR(500),
|
||
ExampleTranslation NVARCHAR(500),
|
||
Cefr NVARCHAR(10),
|
||
DifficultyLevelNumeric INT,
|
||
IsFavorite BIT DEFAULT 0,
|
||
CreatedAt DATETIME2,
|
||
UpdatedAt DATETIME2
|
||
)
|
||
```
|
||
|
||
### **極簡數據庫設計** (階段3需要時)
|
||
```sql
|
||
-- 只需要一個簡單的複習記錄表
|
||
CREATE TABLE FlashcardReviews (
|
||
Id UNIQUEIDENTIFIER PRIMARY KEY,
|
||
FlashcardId UNIQUEIDENTIFIER NOT NULL,
|
||
UserId UNIQUEIDENTIFIER NOT NULL,
|
||
|
||
-- 核心欄位 (您的算法需要)
|
||
SuccessCount INT DEFAULT 0, -- 連續成功次數 (用於2^n計算)
|
||
NextReviewDate DATETIME2, -- 下次複習時間
|
||
LastReviewDate DATETIME2, -- 最後複習時間
|
||
|
||
-- 創建和更新時間
|
||
CreatedAt DATETIME2 DEFAULT GETUTCDATE(),
|
||
UpdatedAt DATETIME2 DEFAULT GETUTCDATE(),
|
||
|
||
FOREIGN KEY (FlashcardId) REFERENCES Flashcards(Id),
|
||
UNIQUE(FlashcardId, UserId)
|
||
)
|
||
|
||
-- 就這樣!不需要複雜的Sessions和Attempts表
|
||
-- 前端延遲計數在前端處理,不需要後端記錄
|
||
```
|
||
|
||
---
|
||
|
||
## 🎯 **業務邏輯服務**
|
||
|
||
### **極簡ReviewService**
|
||
```csharp
|
||
// 簡化的介面 (只保留核心功能)
|
||
public interface IReviewService
|
||
{
|
||
Task<ApiResponse<FlashcardDto[]>> GetDueFlashcardsAsync(string userId, int limit = 10);
|
||
Task<ApiResponse<ReviewResult>> UpdateReviewStatusAsync(string flashcardId, bool isCorrect, string userId);
|
||
}
|
||
|
||
// 極簡實作 (專注核心邏輯)
|
||
public class ReviewService : IReviewService
|
||
{
|
||
public async Task<ApiResponse<ReviewResult>> UpdateReviewStatusAsync(
|
||
string flashcardId,
|
||
bool isCorrect,
|
||
string userId)
|
||
{
|
||
// 1. 獲取或創建複習記錄
|
||
var review = await GetOrCreateReviewAsync(flashcardId, userId);
|
||
|
||
// 2. 更新成功次數 (您的核心算法)
|
||
if (isCorrect)
|
||
{
|
||
review.SuccessCount++;
|
||
}
|
||
else
|
||
{
|
||
review.SuccessCount = 0; // 重置
|
||
}
|
||
|
||
// 3. 計算下次複習時間 (您的公式)
|
||
if (isCorrect)
|
||
{
|
||
var intervalDays = Math.Pow(2, review.SuccessCount);
|
||
review.NextReviewDate = DateTime.UtcNow.AddDays(intervalDays);
|
||
}
|
||
else
|
||
{
|
||
review.NextReviewDate = DateTime.UtcNow.AddDays(1); // 明天再試
|
||
}
|
||
|
||
review.LastReviewDate = DateTime.UtcNow;
|
||
review.UpdatedAt = DateTime.UtcNow;
|
||
|
||
await _context.SaveChangesAsync();
|
||
|
||
return new ApiResponse<ReviewResult>
|
||
{
|
||
Success = true,
|
||
Data = new ReviewResult
|
||
{
|
||
FlashcardId = flashcardId,
|
||
SuccessCount = review.SuccessCount,
|
||
NextReviewDate = review.NextReviewDate,
|
||
IntervalDays = (int)Math.Pow(2, review.SuccessCount)
|
||
}
|
||
};
|
||
}
|
||
|
||
// 獲取到期詞卡 (簡化邏輯)
|
||
public async Task<ApiResponse<FlashcardDto[]>> GetDueFlashcardsAsync(string userId, int limit = 10)
|
||
{
|
||
var dueCards = await _context.Flashcards
|
||
.Where(f => f.UserId == userId)
|
||
.LeftJoin(_context.FlashcardReviews,
|
||
f => f.Id,
|
||
r => r.FlashcardId,
|
||
(flashcard, review) => new { flashcard, review })
|
||
.Where(x => x.review == null || x.review.NextReviewDate <= DateTime.UtcNow)
|
||
.Select(x => x.flashcard)
|
||
.Take(limit)
|
||
.ToArrayAsync();
|
||
|
||
return new ApiResponse<FlashcardDto[]>
|
||
{
|
||
Success = true,
|
||
Data = dueCards.Select(MapToDto).ToArray()
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 📊 **階段性實作計劃**
|
||
|
||
### **階段1: 純前端 (當前)**
|
||
```
|
||
實作範圍: 無後端開發
|
||
前端邏輯: 延遲計數系統、排序邏輯都在React中
|
||
數據來源: api_seeds.json 靜態文件
|
||
```
|
||
|
||
### **階段2: 本地持久化**
|
||
```
|
||
實作範圍: 仍無後端開發
|
||
前端增強: localStorage 保存學習進度
|
||
觸發條件: 用戶要求保存進度
|
||
```
|
||
|
||
### **階段3: 基礎API**
|
||
```
|
||
實作範圍: 極簡後端API
|
||
核心功能:
|
||
- GET /api/flashcards/due (獲取到期詞卡)
|
||
- POST /api/flashcards/{id}/review (更新複習狀態)
|
||
- 簡單的NextReviewDate計算
|
||
避免功能:
|
||
- 詳細的統計分析
|
||
- 複雜的會話管理
|
||
- 用戶行為追蹤
|
||
```
|
||
|
||
---
|
||
|
||
## 🔐 **安全性規格**
|
||
|
||
### **身份驗證和授權**
|
||
```csharp
|
||
// JWT Token 驗證
|
||
[Authorize]
|
||
[Route("api/flashcards")]
|
||
public class FlashcardsController : ControllerBase
|
||
{
|
||
// 確保用戶只能存取自己的數據
|
||
private async Task<bool> ValidateUserAccessAsync(string flashcardId)
|
||
{
|
||
var userId = User.GetUserId();
|
||
return await _flashcardService.BelongsToUserAsync(flashcardId, userId);
|
||
}
|
||
}
|
||
|
||
// 資料驗證
|
||
public class ReviewAttemptRequest
|
||
{
|
||
[Range(1, 3)]
|
||
public int Confidence { get; set; }
|
||
|
||
[Range(0, 300000)] // 最多5分鐘
|
||
public int ResponseTimeMs { get; set; }
|
||
|
||
[Range(0, int.MaxValue)]
|
||
public int SkipCount { get; set; }
|
||
|
||
[Range(0, int.MaxValue)]
|
||
public int WrongCount { get; set; }
|
||
}
|
||
```
|
||
|
||
### **資料保護**
|
||
```csharp
|
||
// 個人資料保護
|
||
public class PrivacyService
|
||
{
|
||
// 匿名化統計數據
|
||
public async Task<AnonymizedStats> GetAnonymizedStatsAsync()
|
||
{
|
||
// 移除個人識別信息的統計
|
||
}
|
||
|
||
// 資料導出 (GDPR)
|
||
public async Task<UserDataExport> ExportUserDataAsync(string userId)
|
||
{
|
||
return new UserDataExport
|
||
{
|
||
ReviewSessions = await GetUserReviewSessions(userId),
|
||
Flashcards = await GetUserFlashcards(userId),
|
||
LearningStats = await GetUserStats(userId)
|
||
};
|
||
}
|
||
|
||
// 資料刪除
|
||
public async Task DeleteUserDataAsync(string userId)
|
||
{
|
||
// 完全刪除用戶所有學習數據
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## ⚡ **性能優化規格**
|
||
|
||
### **資料庫優化**
|
||
```sql
|
||
-- 重要索引
|
||
CREATE INDEX IX_ReviewAttempts_FlashcardId_UserId
|
||
ON ReviewAttempts (FlashcardId, UserId)
|
||
|
||
CREATE INDEX IX_FlashcardReviewStats_UserId_NextReviewDate
|
||
ON FlashcardReviewStats (UserId, NextReviewDate)
|
||
|
||
CREATE INDEX IX_ReviewSessions_UserId_StartTime
|
||
ON ReviewSessions (UserId, StartTime)
|
||
```
|
||
|
||
### **API性能目標**
|
||
```csharp
|
||
// 性能指標
|
||
public static class PerformanceTargets
|
||
{
|
||
public const int GetDueCardsMaxMs = 500; // 獲取卡片 < 500ms
|
||
public const int RecordAttemptMaxMs = 200; // 記錄結果 < 200ms
|
||
public const int DatabaseQueryMaxMs = 100; // 資料庫查詢 < 100ms
|
||
}
|
||
|
||
// 快取策略
|
||
[ResponseCache(Duration = 300)] // 5分鐘快取
|
||
public async Task<ActionResult<ApiResponse<DueCardsResult>>> GetDueFlashcards()
|
||
{
|
||
// 實作快取邏輯
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 📊 **階段性實作計劃**
|
||
|
||
### **階段1: 靜態數據 (已完成)**
|
||
```
|
||
前端: 使用 api_seeds.json
|
||
後端: 無需開發
|
||
目的: 驗證前端邏輯
|
||
```
|
||
|
||
### **階段2: 基礎API (階段3觸發時)**
|
||
```
|
||
實作範圍:
|
||
✅ GET /api/flashcards/due (基礎版)
|
||
✅ POST /api/flashcards/{id}/review (簡化版)
|
||
✅ 基礎的錯誤處理
|
||
|
||
不實作:
|
||
❌ 複雜的快取機制
|
||
❌ 高級統計分析
|
||
❌ 複雜的權限控制
|
||
```
|
||
|
||
### **階段3: 完整API (遠期)**
|
||
```
|
||
實作範圍:
|
||
✅ 完整的用戶權限驗證
|
||
✅ 詳細的學習統計
|
||
✅ 系統監控和分析
|
||
✅ 性能優化和快取
|
||
```
|
||
|
||
---
|
||
|
||
## 🔧 **開發環境設置**
|
||
|
||
### **API開發工具**
|
||
```csharp
|
||
// Swagger文檔配置
|
||
services.AddSwaggerGen(c =>
|
||
{
|
||
c.SwaggerDoc("v1", new OpenApiInfo
|
||
{
|
||
Title = "DramaLing Review API",
|
||
Version = "v1"
|
||
});
|
||
|
||
// 加入JWT驗證
|
||
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||
{
|
||
In = ParameterLocation.Header,
|
||
Description = "Please enter JWT token",
|
||
Name = "Authorization",
|
||
Type = SecuritySchemeType.Http,
|
||
BearerFormat = "JWT",
|
||
Scheme = "bearer"
|
||
});
|
||
});
|
||
```
|
||
|
||
### **測試環境**
|
||
```csharp
|
||
// 測試資料庫
|
||
public class TestDbContext : DbContext
|
||
{
|
||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||
{
|
||
optionsBuilder.UseInMemoryDatabase("TestReviewDb");
|
||
}
|
||
}
|
||
|
||
// API測試
|
||
[TestClass]
|
||
public class ReviewApiTests
|
||
{
|
||
[TestMethod]
|
||
public async Task GetDueCards_ShouldReturnUserCards()
|
||
{
|
||
// 準備測試數據
|
||
// 調用API
|
||
// 驗證結果
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 📋 **部署和監控規格**
|
||
|
||
### **部署配置**
|
||
```yaml
|
||
# Docker配置
|
||
services:
|
||
review-api:
|
||
image: dramaling/review-api:latest
|
||
environment:
|
||
- ConnectionStrings__DefaultConnection=${DB_CONNECTION}
|
||
- JwtSettings__Secret=${JWT_SECRET}
|
||
ports:
|
||
- "5008:8080"
|
||
depends_on:
|
||
- database
|
||
|
||
database:
|
||
image: postgres:15
|
||
environment:
|
||
- POSTGRES_DB=dramaling_review
|
||
- POSTGRES_USER=${DB_USER}
|
||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||
```
|
||
|
||
### **監控指標**
|
||
```csharp
|
||
// 健康檢查
|
||
services.AddHealthChecks()
|
||
.AddDbContextCheck<ApplicationDbContext>()
|
||
.AddCheck<ApiHealthCheck>("api-health");
|
||
|
||
// 性能監控
|
||
public class PerformanceMiddleware
|
||
{
|
||
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
|
||
{
|
||
var stopwatch = Stopwatch.StartNew();
|
||
await next(context);
|
||
stopwatch.Stop();
|
||
|
||
// 記錄慢查詢
|
||
if (stopwatch.ElapsedMilliseconds > 1000)
|
||
{
|
||
_logger.LogWarning($"Slow request: {context.Request.Path} took {stopwatch.ElapsedMilliseconds}ms");
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🚨 **錯誤處理規格**
|
||
|
||
### **統一錯誤回應**
|
||
```csharp
|
||
public class ApiResponse<T>
|
||
{
|
||
public bool Success { get; set; }
|
||
public T? Data { get; set; }
|
||
public string? Message { get; set; }
|
||
public string? Error { get; set; }
|
||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||
}
|
||
|
||
// 錯誤類型
|
||
public enum ErrorType
|
||
{
|
||
Validation, // 資料驗證錯誤
|
||
NotFound, // 資源不存在
|
||
Unauthorized, // 權限不足
|
||
RateLimit, // 請求頻率限制
|
||
ServerError // 服務器錯誤
|
||
}
|
||
```
|
||
|
||
### **錯誤處理中介軟體**
|
||
```csharp
|
||
public class ErrorHandlingMiddleware
|
||
{
|
||
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
|
||
{
|
||
try
|
||
{
|
||
await next(context);
|
||
}
|
||
catch (ValidationException ex)
|
||
{
|
||
await HandleValidationErrorAsync(context, ex);
|
||
}
|
||
catch (UnauthorizedAccessException ex)
|
||
{
|
||
await HandleUnauthorizedErrorAsync(context, ex);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
await HandleGenericErrorAsync(context, ex);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
*後端規格維護: 後端開發團隊*
|
||
*實作觸發: 階段3 API集成需求確認時*
|
||
*目標: 支持前端複習功能的後端服務* |