# 架构设计 > 本文讲"怎么把技术栈搭起来":系统结构、前后端职责、数据模型、技术难点、开发顺序。 > 具体用了哪些框架 / 库 / 数据库 / 部署方式,见 [技术栈](03-tech-stack.md)。 ## 一、系统结构 ``` ┐ │ 用户浏览器 (大屏手机 / 平板) │ │ │ ▼ 安装为 PWA,离线缓存 │ 前端 go-app (app.wasm) │ - 课程列表 / 播放器 / 字幕同步 / 生词本 UI │ - Service Worker 缓存音频与文稿 │ │ │ ▼ REST API (JSON over HTTPS) │ 后端 Go net/http │ - 账号鉴权 / 进度 / 生词本 │ │ │ ▼ │ SQLite 数据库 ┘ ``` ## 二、前后端职责划分 **前端(go-app / wasm)** - 课程列表、音频播放器 - 字幕同步高亮 + 点击跳转(核心,自写) - 生词本界面 - PWA manifest + Service Worker **后端(net/http)** - 用户注册 / 登录 / 鉴权(httpOnly Session Cookie) - 生词本增删查 - 学习进度读写 - (静态音频与文稿可由 go-app Handler 或 CDN 提供) ## 三、数据模型 数据分两类:**内容数据(静态、只读)** 与 **用户数据(动态、需后端)**。 ### 3.1 内容数据 — 静态 `episodes.json`(前端直接读,不入库) 实际素材已存在:`family-album-usa/episodes.json`(约 700KB)+ `family-album-usa/audio/*.mp3`。 结构为 **episode(集)→ act(幕)→ line(句)** 三级(以下为真实结构): ```json { "meta": { "version": "1.0.0", "title": "走遍美国 Family Album U.S.A.", "totalEpisodes": 26, "totalActs": 78, "audioActs": 49, "lastUpdated": "2026-05-29" }, "episodes": [ { "id": 1, "title": "46 Linden Street", "titleCn": "林登大街46号", "acts": [ { "act": 1, "label": "Act I", "audio": "/family-album/audio/u0101.mp3", "hasAudio": true, "lineCount": 86, "lines": [ { "t": 34.18, "en": "Excuse me. My name is Richard Stewart." } ] } ] } ] } ``` 要点(**以实际数据为准,勿臆测字段**): - **集 episode**:`id` / `title` / `titleCn`(26 集**全部**有中文标题)/ `acts[]` - **幕 act**:`act` 序号 / `label` / `audio`(服务路径 `/family-album/audio/u{集}{幕}.mp3`)/ `hasAudio` / `lineCount` / `lines[]` - **句 line**:**只有 `{ t, en }` 两个字段**。`t` 为音频时间戳(秒),`en` 为英文原句。**无 `zh` 字段**——行级中文翻译目前不存在,未来要补需扩展 schema - 规模:26 集 / 78 个 act 条目,其中 49 个 `hasAudio=true`;`audio/` 目录共 50 个 mp3,合计 6335 句 - ⚠️ **路径映射**:JSON 里的 `/family-album/audio/...` 是**对外服务路径**,对应磁盘目录 `family-album-usa/audio/`,后端路由需做这层映射 - ⚠️ `audio/episode12_act2.mp3` 命名不符合 `u####.mp3` 规则且未被 JSON 引用,属游离文件,接入时需核对 > 多教材扩展:未来接入新概念英语等,可沿用「教材 → 单元 → 句子」三级抽象——「走遍美国」即一个教材。新教材未必每句有时间戳,line 的 `t` 允许缺省。第一版无需为此提前建库,前端按 JSON 渲染即可。 ### 3.2 用户数据 — SQLite(需账号,后端读写) 仅存「用户态」,**内容数据不入库**: **users** — id, email, password_hash, created_at **progress** — user_id, **course_id**, episode_id, act, last_position(秒), completed, updated_at **vocab** — id, user_id, **course_id**, word, episode_id, act, context_sentence, created_at > **`course_id` 是前向兼容的预留列**:MVP 只有「走遍美国」一个教材,建表时即带上此列、固定默认 `"family-album"`。 > 目的是避免将来接入新概念英语等时,对**已产生的用户进度/生词数据**做痛苦的 schema 迁移(用户数据不可重生,内容数据可重生)。 > ⚠️ MVP 阶段**仅留列、填默认值,不写任何多教材逻辑**(不建 courses 表、不改内容 JSON、不做按教材过滤)——多教材接入是 V2+,见 [任务看板](06-tasks.md) 待办池。 建表草案(SQLite,T-301 实现时以此为基准,可按需微调约束): ```sql CREATE TABLE users ( id INTEGER PRIMARY KEY, email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE progress ( user_id INTEGER NOT NULL, course_id TEXT NOT NULL DEFAULT 'family-album', episode_id INTEGER NOT NULL, act INTEGER NOT NULL, last_position REAL NOT NULL DEFAULT 0, -- 秒 completed INTEGER NOT NULL DEFAULT 0, -- 0/1 updated_at TEXT NOT NULL, PRIMARY KEY (user_id, course_id, episode_id, act) ); CREATE TABLE vocab ( id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, course_id TEXT NOT NULL DEFAULT 'family-album', word TEXT NOT NULL, episode_id INTEGER NOT NULL, act INTEGER NOT NULL, context_sentence TEXT, created_at TEXT NOT NULL ); ``` **功能模块化**:字幕同步、生词本、播放器做成可复用组件,不同教材按需启用(走遍美国用字幕同步;新概念可能更重单词表与语法)。 ## 四、关键技术难点 | 难点 | 说明 | 应对 | | --- | --- | --- | | 字幕同步 | 音频 currentTime 匹配 `t`,逐句高亮 + 点句跳转 | 先出最小原型验证 | | Audio API 互操 | go-app 需通过 syscall/js 调浏览器 | 封装成可复用组件 | | wasm 首屏体积 | Go wasm 最小约 2MB,首屏较慢 | 离线缓存后续访问快 | | 离线音频 | 大体积音频缓存策略 | Service Worker 按需缓存 | ### go-app 注意事项(避坑表) 实际写代码踩到的 go-app 框架行为,**持续累积**;动手前扫一眼,再踩到新坑往这里补: | 坑 | 现象 | 规避 | | --- | --- | --- | | Handler 只为**已注册路由**返回 app shell | 未注册的路径返回 `404 page not found`(不是页面) | 路由用 `app.Route` 注册,且放在 `init()` 里,确保「服务器渲染 / wasm 入口 / 测试」三处都生效(见 `main.go`) | | 前端/后端同包共存 | server-only 依赖(`net/http`、SQLite、`os`)误进 wasm 会构建失败或体积暴涨 | 后端代码用 `//go:build !wasm` 或单独包隔离,只被 server 入口引用(见第六节) | | wasm 由 `app.js` 引导 | app shell 里**没有**直接的 `app.wasm` script 标签,而是 `/wasm_exec.js` + `/app.js` | 验证引导是否注入时看这两个脚本,别找 `app.wasm` 标签 | | 服务器默认从 `web/` 读 `app.wasm` | 没把 wasm 构建到 `web/app.wasm` 时页面空白 | 起 server 前先 `GOOS=js GOARCH=wasm go build -o web/app.wasm` | | `Handler.Name` 不进 `` | 设了 `Name` 但页面标题为空 | 标题由组件的 `Title()` 等设置,不要指望 `Name` 自动成为 title | ## 五、推荐开发顺序 1. **先写「音频 + 时间戳字幕同步」最小原型**(验证最难一环) 2. 搭课程列表 + 播放页骨架 3. 后端账号体系 + 鉴权 4. 生词本(前端 UI + 后端 API) 5. 进度同步 6. PWA manifest + Service Worker 离线化 7. (V2)复读 / 测验 / 打卡 > 原则:先验证最难的字幕同步,跑通再往上搭架子,避免先搭一堆架构最后卡在核心功能。 ## 六、项目结构(建议包布局) go-app 同一份代码两处运行:编译成 **wasm 在浏览器**跑(`app.RunWhenOnBrowser()`),编译成**普通二进制在服务器**跑(起 `http.Server` + `app.Handler`)。因此前端组件包必须能进 wasm,而后端包(`net/http`、SQLite、文件 IO)**不能进 wasm**——用构建标签 `//go:build !wasm`(或单独的非 js 包,只被 server 入口引用)隔离。 建议布局(T-001 起逐步建立,不必一次建全): ``` lingo/ ├── go.mod ├── main.go # 装配入口:注册页面组件 + RunWhenOnBrowser;server 侧起 http + app.Handler ├── pages/ # go-app 前端组件(进 wasm) │ ├── list.go # 课程列表页(EpisodeList) │ ├── play.go # 播放页(TranscriptView) │ ├── player.go # AudioPlayer:syscall/js 封装浏览器 Audio API │ ├── vocab.go # 生词本页 │ └── auth.go # 登录/注册(AuthForm) ├── content/ # episodes.json 加载与类型定义(T-003,前后端共用,纯数据可进 wasm) │ └── content.go ├── server/ # 仅服务器端,//go:build !wasm 隔离,不进 wasm │ ├── api.go # /api/*(账号 / 生词 / 进度),合约见 docs/api.md │ ├── auth.go # httpOnly Session Cookie │ ├── db.go # SQLite 建表与读写(见 3.2 建表草案) │ └── static.go # /family-album/* → 磁盘 family-album-usa/ 的路径映射 └── web/ # 静态资源 ├── app.css # 纯 CSS(可迁移自 design_mockups/ 的 :root + 组件样式) ├── manifest.webmanifest └── sw.js # Service Worker(Phase 6) ``` - 页面路由与组件归属详见 [`routes.md`](routes.md);后端接口形状详见 [`api.md`](api.md)。 - `content/` 只放数据结构与加载,保持可进 wasm(前端列表/播放页要用),**不要**在其中引入 server-only 依赖。