北川广海の梦

北川广海の梦

Anki-Go 开发计划

web
4
2026-01-21

Anki Web 开发计划

anki 依赖PC或者移动客户端,官方anki-web适用于deck分享下载。基于个人需求设计 anki-go,核心目标为实现基于浏览器的学习功能。

一、技术架构

前端 (React)

  • React 18 + TypeScript
  • 状态管理: Zustand / Redux Toolkit
  • UI 组件: Ant Design / shadcn
  • 富文本编辑: TipTap / Milkdown (Markdown)
  • 构建工具: Vite

后端 (Golang)

  • Web框架: Gin / Echo
  • ORM: GORM
  • 认证: JWT
  • 配置: Viper

数据库 (PostgreSQL/MySQL)

  • users: 用户表
  • decks: 牌组表
  • cards: 卡片表
  • reviews: 复习记录表

二、功能模块

模块功能点优先级
用户系统注册、登录、JWT认证P0
牌组管理创建、编辑、删除、列表P0
卡片管理创建、编辑、删除、批量导入P0
学习系统卡片展示、翻转、评分、间隔重复算法P0
数据统计学习进度、复习热力图、正确率P1
导入导出.apkg 文件导入、CSV导入P1
多媒体图片上传、音频支持P2
分享功能公开牌组、牌组市场P2

三、数据库设计

-- 用户表
CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    username VARCHAR(64) NOT NULL UNIQUE,
    email VARCHAR(128) NOT NULL UNIQUE,
    password_hash VARCHAR(256) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 牌组表
CREATE TABLE decks (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL,
    name VARCHAR(255) NOT NULL,
    description TEXT,
    is_public BOOLEAN DEFAULT FALSE,
    card_count INT DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 卡片表
CREATE TABLE cards (
    id BIGSERIAL PRIMARY KEY,
    deck_id BIGINT NOT NULL,
    front TEXT NOT NULL,                    -- 正面内容(支持Markdown/HTML)
    back TEXT NOT NULL,                     -- 反面内容
    tags VARCHAR(255),                      -- 标签,逗号分隔
    ease_factor DECIMAL(5,3) DEFAULT 2.500, -- 难度系数
    interval_days INT DEFAULT 0,            -- 当前间隔(天)
    repetitions INT DEFAULT 0,              -- 连续正确次数
    next_review TIMESTAMP,                  -- 下次复习时间
    last_review TIMESTAMP,                  -- 上次复习时间
    status SMALLINT DEFAULT 0,              -- 0:新卡片 1:学习中 2:已掌握
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 复习记录表
CREATE TABLE reviews (
    id BIGSERIAL PRIMARY KEY,
    card_id BIGINT NOT NULL,
    user_id BIGINT NOT NULL,
    quality SMALLINT NOT NULL,              -- 评分 1:Again 2:Hard 3:Good 4:Easy
    interval_days INT,                      -- 本次计算的间隔
    ease_factor DECIMAL(5,3),               -- 本次计算后的难度系数
    review_duration INT,                    -- 复习耗时(秒)
    reviewed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 索引
CREATE INDEX idx_decks_user_id ON decks(user_id);
CREATE INDEX idx_cards_deck_id ON cards(deck_id);
CREATE INDEX idx_cards_next_review ON cards(next_review);
CREATE INDEX idx_reviews_card_id ON reviews(card_id);
CREATE INDEX idx_reviews_user_id_date ON reviews(user_id, reviewed_at);

-- 注:级联删除通过应用层代码实现,不使用数据库外键约束

四、API 设计

4.1 用户模块

方法路径描述
POST/api/auth/register用户注册
POST/api/auth/login用户登录
GET/api/auth/profile获取用户信息
PUT/api/auth/profile更新用户信息

4.2 牌组模块

方法路径描述
GET/api/decks获取牌组列表
POST/api/decks创建牌组
GET/api/decks/:id获取牌组详情
PUT/api/decks/:id更新牌组
DELETE/api/decks/:id删除牌组
GET/api/decks/:id/stats获取牌组统计

4.3 卡片模块

方法路径描述
GET/api/decks/:id/cards获取牌组下的卡片
POST/api/decks/:id/cards创建卡片
PUT/api/cards/:id更新卡片
DELETE/api/cards/:id删除卡片
POST/api/decks/:id/cards/batch批量导入卡片

4.4 学习模块

方法路径描述
GET/api/study/:deckId/next获取下一张待复习卡片
POST/api/study/:cardId/review提交复习结果
GET/api/study/:deckId/summary获取学习会话统计

4.5 统计模块

方法路径描述
GET/api/stats/overview学习概览
GET/api/stats/heatmap复习热力图数据
GET/api/stats/forecast未来复习预测

五、开发排期

第一阶段:基础框架(3天)

天数任务产出
Day 1后端项目初始化、数据库设计、用户模块注册/登录/JWT认证
Day 2牌组CRUD、卡片CRUD基础数据管理API
Day 3前端项目初始化、路由、基础布局React框架搭建

第二阶段:核心功能(5天)

天数任务产出
Day 4SM-2算法实现、学习API间隔重复核心逻辑
Day 5前端登录注册页面用户认证界面
Day 6前端牌组管理页面牌组列表/创建/编辑
Day 7前端卡片管理页面卡片列表/创建/编辑
Day 8前端学习页面(核心)卡片翻转/评分交互

第三阶段:完善体验(4天)

天数任务产出
Day 9富文本/Markdown编辑器集成卡片内容支持格式化
Day 10学习统计后端 + 前端图表进度可视化
Day 11批量导入(CSV)、导出数据导入导出
Day 12UI美化、响应式适配、Bug修复产品打磨

第四阶段:进阶功能(可选,5天)

天数任务产出
Day 13-14.apkg 文件解析与导入Anki牌组兼容
Day 15图片上传、媒体支持多媒体卡片
Day 16公开牌组、分享功能社区功能
Day 17性能优化、部署文档上线准备

总计

阶段工时累计
基础框架3天3天
核心功能5天8天
完善体验4天12天
进阶功能5天17天

MVP版本:12天(约2.5周)
完整版本:17天(约3.5周)

六、重点难点分析

难点1:SM-2 间隔重复算法

难度:⭐⭐

说明: 这是Anki的核心,决定了卡片复习的时机。

实现思路:

package algorithm

import (
    "math"
    "time"
)

// Quality 评分等级
const (
    QualityAgain = 1 // 完全忘记
    QualityHard  = 2 // 困难
    QualityGood  = 3 // 正常
    QualityEasy  = 4 // 简单
)

// ReviewResult 复习结果
type ReviewResult struct {
    NextReview  time.Time
    Interval    int
    EaseFactor  float64
    Repetitions int
}

// SM2 计算下次复习时间
func SM2(easeFactor float64, interval int, repetitions int, quality int) ReviewResult {
    var newInterval int
    var newReps int
    var newEF float64

    // 根据评分判断是否记住
    if quality < QualityGood {
        // 忘记了,重置
        newInterval = 1
        newReps = 0
        newEF = easeFactor
    } else {
        // 记住了,计算新间隔
        if repetitions == 0 {
            newInterval = 1
        } else if repetitions == 1 {
            newInterval = 6
        } else {
            newInterval = int(math.Round(float64(interval) * easeFactor))
        }
        newReps = repetitions + 1

        // 调整难度系数 (EF)
        // EF' = EF + (0.1 - (5-q) * (0.08 + (5-q) * 0.02))
        q := float64(quality + 1) // 转换为0-5标准
        newEF = easeFactor + (0.1 - (5-q)*(0.08+(5-q)*0.02))
        if newEF < 1.3 {
            newEF = 1.3 // 最小值限制
        }
    }

    // Easy 评分额外奖励
    if quality == QualityEasy {
        newInterval = int(float64(newInterval) * 1.3)
    }

    return ReviewResult{
        NextReview:  time.Now().AddDate(0, 0, newInterval),
        Interval:    newInterval,
        EaseFactor:  newEF,
        Repetitions: newReps,
    }
}

难点2:学习队列管理

难度:⭐⭐⭐

说明: 一次学习会话中,需要合理安排新卡片、复习卡片、重学卡片的顺序。

当前实现:

  • ✅ 采用数据库查询方案,每次获取卡片时直接查库
  • ✅ 优先级:到期复习 > 新卡片(通过 SQL ORDER BY 实现)
  • ✅ 实现简单,无需额外依赖,适合初期开发

后续优化方案(可配置切换):

  • 方案1(当前):直接查数据库 - 适合小规模应用
  • 方案2(可选):Redis 内存队列 - 批量预取,减少数据库查询,适合高并发场景

理想实现思路(内存队列版本):

package study

import (
    "context"
    "time"
)

// CardQueue 学习队列
type CardQueue struct {
    NewCards     []*Card // 新卡片
    ReviewCards  []*Card // 待复习卡片
    RelearningCards []*Card // 重学卡片(本次会话中答错的)
}

// StudySession 学习会话
type StudySession struct {
    DeckID      uint64
    Queue       *CardQueue
    DailyNewLimit    int // 每日新卡片上限,默认20
    DailyReviewLimit int // 每日复习上限,默认200
    NewCount    int // 今日已学新卡片数
    ReviewCount int // 今日已复习数
}

// GetNextCard 获取下一张卡片
// 优先级:重学卡片 > 复习卡片 > 新卡片
func (s *StudySession) GetNextCard() *Card {
    // 1. 优先处理重学卡片
    if len(s.Queue.RelearningCards) > 0 {
        card := s.Queue.RelearningCards[0]
        s.Queue.RelearningCards = s.Queue.RelearningCards[1:]
        return card
    }

    // 2. 复习卡片
    if len(s.Queue.ReviewCards) > 0 && s.ReviewCount < s.DailyReviewLimit {
        card := s.Queue.ReviewCards[0]
        s.Queue.ReviewCards = s.Queue.ReviewCards[1:]
        return card
    }

    // 3. 新卡片
    if len(s.Queue.NewCards) > 0 && s.NewCount < s.DailyNewLimit {
        card := s.Queue.NewCards[0]
        s.Queue.NewCards = s.Queue.NewCards[1:]
        s.NewCount++
        return card
    }

    return nil // 学习完成
}

// OnReview 处理复习结果
func (s *StudySession) OnReview(card *Card, quality int) {
    if quality < QualityGood {
        // 答错,加入重学队列
        s.Queue.RelearningCards = append(s.Queue.RelearningCards, card)
    }
    s.ReviewCount++
}

// LoadQueue 加载学习队列
func LoadQueue(ctx context.Context, deckID uint64) (*CardQueue, error) {
    queue := &CardQueue{}
    
    // 查询待复习卡片(next_review <= now)
    // SELECT * FROM cards WHERE deck_id = ? AND next_review <= NOW() ORDER BY next_review
    
    // 查询新卡片(status = 0)
    // SELECT * FROM cards WHERE deck_id = ? AND status = 0 LIMIT ?
    
    return queue, nil
}

难点3:.apkg 文件解析

难度:⭐⭐⭐⭐

说明: .apkg 是 Anki 的专有格式,本质是 zip 包含 SQLite 数据库。

实现思路:

package importer

import (
    "archive/zip"
    "database/sql"
    "io"
    "os"
    "path/filepath"

    _ "github.com/mattn/go-sqlite3"
)

// ApkgImporter Anki牌组导入器
type ApkgImporter struct {
    tempDir string
}

// Import 导入 .apkg 文件
func (a *ApkgImporter) Import(apkgPath string) (*ImportResult, error) {
    // 1. 解压 .apkg (本质是zip)
    tempDir, err := os.MkdirTemp("", "anki_import_")
    if err != nil {
        return nil, err
    }
    defer os.RemoveAll(tempDir)

    if err := unzip(apkgPath, tempDir); err != nil {
        return nil, err
    }

    // 2. 打开 SQLite 数据库 (collection.anki2)
    dbPath := filepath.Join(tempDir, "collection.anki2")
    db, err := sql.Open("sqlite3", dbPath)
    if err != nil {
        return nil, err
    }
    defer db.Close()

    // 3. 读取牌组信息 (col 表的 decks 字段,JSON格式)
    // 4. 读取笔记 (notes 表)
    // 5. 读取卡片 (cards 表)
    // 6. 处理媒体文件 (media 文件是JSON映射)

    return parseAnkiDB(db)
}

// Anki 数据库结构
// notes 表: id, guid, mid(模板id), mod, usn, tags, flds(字段,用\x1f分隔), sfld, csum, flags, data
// cards 表: id, nid(note id), did(deck id), ord, mod, usn, type, queue, due, ivl, factor, reps, lapses, left, odue, odid, flags, data

func parseAnkiDB(db *sql.DB) (*ImportResult, error) {
    result := &ImportResult{}
    
    // 读取笔记
    rows, _ := db.Query("SELECT id, flds, tags FROM notes")
    for rows.Next() {
        var id int64
        var flds, tags string
        rows.Scan(&id, &flds, &tags)
        
        // flds 字段用 \x1f (ASCII 31) 分隔
        // 第一个字段通常是正面,第二个是反面
        fields := strings.Split(flds, "\x1f")
        if len(fields) >= 2 {
            result.Cards = append(result.Cards, &ImportedCard{
                Front: fields[0],
                Back:  fields[1],
                Tags:  tags,
            })
        }
    }
    
    return result, nil
}

难点4:前端卡片翻转交互

难度:⭐⭐

说明: 卡片翻转动画 + 键盘快捷键 + 触摸支持

实现思路:

// components/StudyCard.tsx
import React, { useState, useEffect } from 'react';
import './StudyCard.css';

interface StudyCardProps {
  front: string;
  back: string;
  onRate: (quality: number) => void;
}

export const StudyCard: React.FC<StudyCardProps> = ({ front, back, onRate }) => {
  const [flipped, setFlipped] = useState(false);

  // 键盘快捷键
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (!flipped) {
        if (e.code === 'Space' || e.code === 'Enter') {
          setFlipped(true);
        }
      } else {
        switch (e.code) {
          case 'Digit1': onRate(1); break; // Again
          case 'Digit2': onRate(2); break; // Hard
          case 'Digit3': onRate(3); break; // Good
          case 'Digit4': onRate(4); break; // Easy
        }
      }
    };
    
    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [flipped, onRate]);

  return (
    <div className="study-container">
      <div className={`card ${flipped ? 'flipped' : ''}`} onClick={() => !flipped && setFlipped(true)}>
        <div className="card-face card-front">
          <div dangerouslySetInnerHTML={{ __html: front }} />
        </div>
        <div className="card-face card-back">
          <div dangerouslySetInnerHTML={{ __html: back }} />
        </div>
      </div>

      {!flipped ? (
        <button className="show-answer-btn" onClick={() => setFlipped(true)}>
          显示答案 (Space)
        </button>
      ) : (
        <div className="rating-buttons">
          <button onClick={() => onRate(1)} className="btn-again">
            重来 (1)<br/><small>&lt;1分钟</small>
          </button>
          <button onClick={() => onRate(2)} className="btn-hard">
            困难 (2)<br/><small>&lt;6分钟</small>
          </button>
          <button onClick={() => onRate(3)} className="btn-good">
            良好 (3)<br/><small>10分钟</small>
          </button>
          <button onClick={() => onRate(4)} className="btn-easy">
            简单 (4)<br/><small>4天</small>
          </button>
        </div>
      )}
    </div>
  );
};
/* StudyCard.css */
.card {
  width: 100%;
  max-width: 600px;
  height: 400px;
  perspective: 1000px;
  cursor: pointer;
}

.card-face {
  position: absolute;
  width: 100%;
  height: 100%;
  backface-visibility: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  border-radius: 12px;
  box-shadow: 0 4px 12px rgba(0,0,0,0.1);
  transition: transform 0.6s;
}

.card-front {
  background: white;
}

.card-back {
  background: #f8f9fa;
  transform: rotateY(180deg);
}

.card.flipped .card-front {
  transform: rotateY(180deg);
}

.card.flipped .card-back {
  transform: rotateY(0deg);
}

.rating-buttons {
  display: flex;
  gap: 10px;
  margin-top: 20px;
}

.rating-buttons button {
  flex: 1;
  padding: 12px;
  border: none;
  border-radius: 8px;
  cursor: pointer;
}

.btn-again { background: #dc3545; color: white; }
.btn-hard { background: #fd7e14; color: white; }
.btn-good { background: #28a745; color: white; }
.btn-easy { background: #007bff; color: white; }

七、项目目录结构

anki-web/
├── backend/                    # Go 后端
│   ├── cmd/
│   │   └── server/
│   │       └── main.go
│   ├── internal/
│   │   ├── config/            # 配置
│   │   ├── handler/           # HTTP handlers
│   │   ├── middleware/        # JWT等中间件
│   │   ├── model/             # 数据模型
│   │   ├── repository/        # 数据库操作
│   │   ├── service/           # 业务逻辑
│   │   │   ├── algorithm/     # SM-2算法
│   │   │   ├── study/         # 学习逻辑
│   │   │   └── importer/      # 导入功能
│   │   └── router/            # 路由
│   ├── pkg/                   # 公共包
│   ├── go.mod
│   └── go.sum
│
├── frontend/                   # React 前端
│   ├── src/
│   │   ├── components/        # 组件
│   │   │   ├── StudyCard/
│   │   │   ├── DeckList/
│   │   │   └── CardEditor/
│   │   ├── pages/             # 页面
│   │   │   ├── Login/
│   │   │   ├── Decks/
│   │   │   ├── Study/
│   │   │   └── Stats/
│   │   ├── services/          # API调用
│   │   ├── stores/            # 状态管理
│   │   ├── hooks/             # 自定义hooks
│   │   ├── App.tsx
│   │   └── main.tsx
│   ├── package.json
│   └── vite.config.ts
│
├── docs/                       # 文档
│   └── api.md
├── docker-compose.yml
└── README.md

八、里程碑

里程碑完成标志预计时间
M1: 可运行能登录、创建牌组、添加卡片Day 5
M2: 可学习能进行完整的学习流程(翻卡、评分、算法生效)Day 8
M3: 可使用UI完善、统计功能、导入导出Day 12
M4: 可发布.apkg支持、性能优化、部署完成Day 17

文档生成时间:2026-01-20
预计开发周期:12-17天