- 04-architecture 第四节:新增「go-app 注意事项(避坑表)」,收录 Handler 仅为已注册路由返回 app shell(否则 404)、前后端同包需 //go:build 隔离、wasm 由 app.js 引导、web/ 读 app.wasm、Name 不进 title 等坑 - 05-coding-rules 第 5 节:加指引——写 go-app 代码前先扫避坑表,踩到新坑往那补 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
9.4 KiB
架构设计
本文讲"怎么把技术栈搭起来":系统结构、前后端职责、数据模型、技术难点、开发顺序。 具体用了哪些框架 / 库 / 数据库 / 部署方式,见 技术栈。
一、系统结构
┐
│ 用户浏览器 (大屏手机 / 平板)
│ │
│ ▼ 安装为 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(句) 三级(以下为真实结构):
{
"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+,见 任务看板 待办池。
建表草案(SQLite,T-301 实现时以此为基准,可按需微调约束):
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 不进 <title> |
设了 Name 但页面标题为空 |
标题由组件的 Title() 等设置,不要指望 Name 自动成为 title |
五、推荐开发顺序
- 先写「音频 + 时间戳字幕同步」最小原型(验证最难一环)
- 搭课程列表 + 播放页骨架
- 后端账号体系 + 鉴权
- 生词本(前端 UI + 后端 API)
- 进度同步
- PWA manifest + Service Worker 离线化
- (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)