Files
KennethCheng 7d5ab0a2ec fix(deploy): API_PORT 端口模板化,web upstream 不再写死 8080
NAS 部署时 POST /api/vehicles 返 502 Bad Gateway,根因是 compose 改
SERVER_ADDR: ":8081" 但 fuellog-web/nginx/web.conf 里 upstream 还写死
server api:8080 → nginx upstream 连不上,web 容器把请求反代失败,返 502。

修法:把端口做成 build arg + sed 替换:
- fuellog-web/Dockerfile 加 `ARG API_PORT=8080` + `sed` 在 build 时把
  web.conf 里的 __API_PORT__ 占位替换为真实端口
- deploy/docker-compose.yml:
  * api.SERVER_ADDR 改成 `:${API_PORT:-8080}`(默认仍是 8080)
  * api.expose / healthcheck 端口都改用 ${API_PORT:-8080}
  * web build args 传 API_PORT,容器内 web 镜像就有了对的 upstream
  * 新增 WEB_PORT 控制宿主机 → web 容器 :80 映射
- README 新增"改端口"小节,说明 API_PORT / WEB_PORT 用法;
  新增"排错:502 Bad Gateway"小节,把这次踩的坑(改 SERVER_ADDR 忘
  改 web upstream、忘重 build)写明,下次再撞就有现成排查路径

期间的小坑(都修了):
- web.conf 占位用 __API_PORT__(双下划线)而不是 ${API_PORT},避免
  nginx 把它当成变量语法尝试解析
- docker-compose 用 ${API_PORT:-8080} 而不是裸 ${API_PORT},这样
  用户不传环境变量也能跑(默认 8080)
- README 里 web/api 表格里"golang:1.22-alpine"也顺手改成 1.27-alpine
  跟实际 Dockerfile 一致(上次 f81047f 提过但 README 漏了)

未做(留待后续):
- 端口改成可改 web upstream 后,理论上仍然可能出现 build-time / run-time
  不一致(build args vs 环境变量);更稳的方案是给 web 加个 entrypoint 脚本,
  启动时再 sed 一次 env 变量到 nginx conf —— 留到真有"频繁换端口"的
  场景再做
- `.env.example` 没改,只加了 README 文档化的 .env 用法;compose 默认值
  已经是 8080,本地 dev 不需要 .env
2026-09-13 19:39:19 +08:00

340 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# FuelLog 加油记
> 轻量、精美、移动端优先的个人加油记录与车辆燃油成本分析工具。
[![Go](https://img.shields.io/badge/Go-1.22%2B-00ADD8)](fuellog-api/go.mod)
[![Vue](https://img.shields.io/badge/Vue-3.5-42b883)](fuellog-web/package.json)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](#license)
---
## ✨ 特性
- **10 秒录入一笔加油**:加油时间 → 里程 → 升数 → 总金额 → 油品 → 加油站 → 加满 → 备注,移动端底部固定保存按钮,实测从打开到保存 < 10s。
- **服务端实时算实际油价**:`price_per_liter = total_amount / liters`,前端只入 amount + liters,前端同步显示"实际油价 ¥X.XX/L"作为 UX 反馈,服务端是权威。
- **标准百公里油耗**:只在"连续两次 `is_full=true`"之间计算,半箱油不污染指标 —— 与 plan §6.3 / §19 严格对齐。
- **每公里成本 + 月度趋势**:Dashboard 主指标 + Statistics 4 张图(油价 / 油耗 / 月度费用 / 每公里成本)。
- **PWA**:可"安装"到手机主屏,离线时已访问页面仍可用;Service Worker 预缓存所有静态资源,API 走 NetworkFirst 兜底 cache。
- **多车辆 + 唯一默认**:可加多辆,默认车辆唯一,切换走事务。
---
## 🛠 技术栈
| 层 | 选型 | 理由 |
|---|---|---|
| 前端 | Vue 3 + Vite + TypeScript + Pinia + Vue Router + Tailwind CSS + Naive UI + ECharts | plan §3 锁定;轻量、Mobile First、TypeScript 严格、Naive UI 适配暗色 |
| 后端 | Go 1.22 + Gin + GORM | plan §3 锁定;单文件、编译快、goroutine 适合个人项目 |
| 数据库 | SQLite (glebarez/sqlite 纯 Go) | plan §3 锁定;零依赖、文件级持久化;WAL + 外键 + busy_timeout |
| 精度 | shopspring/decimal | plan §16;金额 / 油耗 / 成本用 decimal 避免 float 累计误差 |
| 部署 | Docker + Docker Compose + Nginx | plan §17;反代 + volume 持久化 SQLite |
---
## 🚀 快速开始(开发期)
### 前置
- Node.js ≥ 20(已测 24.15)
- pnpm ≥ 9(已测 9.7)或 npm
- Go ≥ 1.22(已测 1.27)
- git,可选:make
### 1. 克隆 + 装依赖
```bash
git clone https://gitea.kennethcheng.cc/KennethCheng/FuelLog.git
cd FuelLog
# 后端
cd fuellog-api
go mod tidy
cp .env.example .env
# 前端(另开一个 shell)
cd fuellog-web
pnpm install
cp .env.example .env.local
```
### 2. 启后端
```bash
cd fuellog-api
go run ./cmd/server # 监听 :8080
```
输出形如:
```
time=... msg="db ready" path=./data/fuellog.db
time=... msg="server starting" addr=:8080 mode=debug
```
### 3. 启前端
```bash
cd fuellog-web
pnpm dev # 监听 :5173,/api 自动代理到 :8080
```
打开 <http://127.0.0.1:5173> 即可。首次进会看到"还没有车辆,先去添加"的引导。
### 4. (可选)冒烟测试
```bash
cd fuellog-api
pwsh -ExecutionPolicy Bypass -File scripts/smoke.ps1
# 13/13 断言:建车 / 加油 / 油价 / 油耗 / 删记录 / 删车 全部命中 plan §6 数值
```
### 5. (可选)单测
```bash
cd fuellog-api
go test ./... # 22 decimal + 5 service
pnpm typecheck # 前端 vue-tsc
pnpm build # 前端 vite build
```
---
## 📁 仓库结构
```
FuelLog/
├── fuellog-api/ # Go 后端
│ ├── cmd/server/main.go # 入口
│ ├── internal/
│ │ ├── config/ # env 加载
│ │ ├── db/ # GORM open + migration
│ │ ├── handler/ # HTTP 入参 / 出参
│ │ ├── service/ # 业务逻辑 + 核心计算
│ │ ├── repository/ # GORM 数据访问
│ │ ├── model/ # GORM 实体
│ │ ├── dto/ # 请求/响应 DTO
│ │ ├── middleware/ # logger / recover / cors
│ │ └── router/ # Gin 路由
│ ├── pkg/
│ │ ├── apperror/ # 统一错误类型
│ │ └── decimal/ # 金额 / 油耗精度工具
│ ├── migrations/ # 迁移(目前 GORM AutoMigrate;后续手写)
│ ├── scripts/ # smoke.ps1 等
│ └── go.mod
│
├── fuellog-web/ # Vue 前端
│ ├── src/
│ │ ├── api/ # axios + 类型化 client
│ │ ├── components/ # 业务组件
│ │ ├── layouts/ # AppLayout(顶栏 + 移动端底部 Tab)
│ │ ├── pages/ # Dashboard / Refuels / RefuelNew / Vehicles / Statistics / NotFound
│ │ ├── router/ # Vue Router 配置
│ │ ├── stores/ # Pinia
│ │ ├── styles/ # tailwind.css + theme.css(CSS Variables)
│ │ ├── utils/ # decimal 工具
│ │ ├── App.vue
│ │ └── main.ts
│ ├── public/ # favicon / manifest / PWA icons
│ └── package.json
│
├── deploy/ # Docker 部署文件
│ ├── docker-compose.yml # 生产:api + web(nginx),宿主机 :8080
│ ├── docker-compose.dev.yml # 开发:api 容器 + bind mount 本地 dist
│ ├── nginx/web.conf # web 容器用 nginx 配置(/api 反代 + SPA fallback)
│ └── README.md # 部署说明
├── docs/
│ └── api.md # 完整 API 文档
├── data/ # SQLite 文件(默认 .gitignore,挂载 volume)
├── AGENTS.md # Agent 工程操作手册(规则、约束、Phase 顺序)
├── README.md # 本文件
└── .gitignore
```
---
## 🔢 核心计算规则
| 指标 | 公式 | 范围 |
|---|---|---|
| 实际油价 | `total_amount / liters` | 服务端权威,前端只展示 |
| 行驶里程 | `current.odometer - previous.odometer` | 负数 / 零视为无效 |
| 百公里油耗 | `liters / mileage × 100` | **只在 `is_full=true` 序列内采样** |
| 每公里成本 | `total_amount / mileage` | 同上 |
所有除法走 `shopspring/decimal`(后端) / 同步实现(前端 `utils/decimal.ts`)。
---
## 🧪 测试覆盖
- `pkg/decimal/money_test.go` — 22 个子测试,覆盖 plan §6.1-6.4 全部案例
- `internal/service/refuel_record_test.go` — 5 个测试,覆盖"半箱油不进入标准油耗"硬规则
- `scripts/smoke.ps1` — 13 步端到端冒烟,跑通"建车 → 加 3 次油 → 统计 → 删记录 → 删车"
---
## 🐳 部署
生产一条命令起 api + web,数据持久化到 named volume,前置 Nginx Proxy Manager
或 Traefik 接 SSL 即可对外。
### 快速部署
```bash
# 在仓库根跑(任选一种)
docker compose -f deploy/docker-compose.yml up -d
# 浏览器访问 http://your-host:8080
```
### 改端口(默认 8080 冲突时)
如果宿主机 8080 已被占用(NAS、其它服务),用一个环境变量一起改 api
内部监听 + web 容器 upstream + 宿主机映射:
```bash
# 例:把整套改成 8081
API_PORT=8081 WEB_PORT=8081 \
docker compose -f deploy/docker-compose.yml up -d --build
```
`API_PORT` 控制:
- `api.SERVER_ADDR`(api 容器内监听)
- `api.expose` / `healthcheck`(容器间网络)
- `web` build 阶段的 nginx upstream(`web.conf` 里 `server api:__API_PORT__` 在
build 时 sed 替换)
`WEB_PORT` 控制宿主机到 web 容器 80 端口的映射。
`.env` 文件也能放(放在仓库根,自动被 compose 读):
```
API_PORT=8081
WEB_PORT=8081
```
> **改完端口不重 build 是不生效的**,因为 web 镜像里的 nginx conf 是
> build 时定的。`up -d --build` 才能拿到新镜像。
### 部署拓扑
```
┌─────────────────┐ ┌─────────────────┐
│ Nginx Proxy Mgr │────▶│ fuellog-web:80 │
│ (TLS / 反代) │ │ (nginx:alpine) │
└─────────────────┘ │ ├ 静态 SPA │
│ └ /api/* → api │
└────────┬────────┘
│ fuellog-net
┌────────▼────────┐
│ fuellog-api:8080│
│ (golang alpine)│
│ ├ GORM + SQLite │
│ └ /data 卷 │
└─────────────────┘
```
### 镜像要点
| 镜像 | 基础 | 关键配置 |
|---|---|---|
| `fuellog/api` | 多阶段 `golang:1.27-alpine` → `alpine:3.20` | CGO=0 出纯静态二进制 / tini 收僵尸 / 非 root / 时区 Asia/Shanghai / HEALTHCHECK |
| `fuellog/web` | 多阶段 `node:20-alpine` → `nginx:1.27-alpine` | corepack 启 pnpm / `pnpm build` → nginx 静态 / gzip + 30 天 immutable cache / `API_PORT` build arg 注入 upstream |
### 数据持久化
- 容器内 `DB_PATH=/data/fuellog.db`
- 宿主机 docker volume 名:`fuellog-data`
- 容器删了重建,数据还在;真正要备份:`docker run --rm -v fuellog-data:/data -v $(pwd):/backup alpine cp /data/fuellog.db /backup/`
### 反代建议(不要直接把对外端口暴露公网)
```
Internet ──TLS──▶ Nginx Proxy Manager ──127.0.0.1:WEB_PORT──▶ fuellog-web (容器)
```
容器内 API 不直接对外,符合 plan §17 "容器内 API 默认只监听内网"。
`WEB_PORT` 是宿主机 listen 端口(默认 8080),跟 `API_PORT`(容器内 api
监听,默认 8080)是两个独立变量,大部分场景保持一致。
### 健康检查
- api:`wget http://127.0.0.1:${API_PORT}/health`,30s 间隔,启动 10s 后开始
- web:`wget http://127.0.0.1/`,30s 间隔
- docker-compose 用 `depends_on: condition: service_healthy` 保证 web 等 api 健康后才起
### 排错:502 Bad Gateway
最常见原因:`API_PORT` 与 `api` 容器实际 listen 端口不一致。
`fuellog-web/nginx/web.conf` 里 upstream 写的是 `server api:__API_PORT__`,
由 build 阶段用 `docker compose` 传入的 `API_PORT` 替换。如果改了
`SERVER_ADDR` 端口但忘改 build arg,或者改了 build arg 但没重 build,
nginx 就会 upstream 连不上返 502。
排查步骤:
```bash
# 1. 看 api 实际 listen 端口
docker exec fuellog-api sh -c 'wget -qO- http://127.0.0.1:8080/health 2>&1 || echo 8080_FAIL'
docker exec fuellog-api sh -c 'wget -qO- http://127.0.0.1:8081/health 2>&1 || echo 8081_FAIL'
# 2. 看 web 容器内 nginx conf 写的是哪个端口
docker exec fuellog-web cat /etc/nginx/conf.d/web.conf | grep 'server api:'
# 3. 不一致就重 build(只 up -d 不会重 build)
API_PORT=8081 docker compose -f deploy/docker-compose.yml up -d --build
```
### 开发模式(不想本地装 Go 时用)
```bash
# 1. 本地 build 前端
cd fuellog-web && pnpm install && pnpm build
# 2. 启容器(nginx bind mount 上面 build 出的 dist)
cd .. && docker compose -f deploy/docker-compose.dev.yml up
# 3. 改前端 -> pnpm build -> docker compose restart web
```
日常前端调试还是推荐直接 `pnpm dev`(Vite :5173 自带 /api 代理),上面这套
是为了"不想在 Windows 装 Go 也能跑后端"准备的。
详细配置在 [`deploy/README.md`](./deploy/README.md)。
---
## 🗺 路线图
- [x] Phase 0~1:初始化 + 后端骨架 + 数据层 + API + 单测 + 冒烟
- [x] Phase 2~3:前端骨架 + 4 个业务页
- [x] Phase 4:PWA(manifest / SW / 添加到主屏)
- [x] Phase 5:Docker 部署 + 反代(API / web 多阶段镜像 + nginx + volume)
- [ ] Phase 6:数据导入导出(JSON / CSV)
- [ ] Phase 7:多用户 / 鉴权(目前是个人单机)
- [ ] Phase 8:充电 / 保养 / 停车等扩展
---
## 📐 设计原则
- **Mobile first**:360 / 390 / 430 三档断点先验证;布局用 `grid + md:`,CSS Variables 切暗色。
- **不写死颜色 / 间距 / 字号**:Tailwind theme + `:root` CSS Variables 双轨;`brand` / `ink` / `surface` 都是变量,组件层一律 `text-brand` / `bg-surface`。
- **Handler 不写业务**:Handler 只做参数解析、错误码映射;Service 持有 Repository 接口,便于单测替换。
- **金额 decimal**:任何除法都不直接用 float64。
- **半箱油不污染油耗**:plan §6.3 + §19 验收的硬规则,单测里有专门 case。
- **错误统一格式**:`{ "code": "INVALID_ARGUMENT", "message": "..." }`,前端 axios 拦截器解到 `Error.cause`。
- **PWA 添加到主屏**:`display: standalone` + maskable icon + SW 预缓存,真像 app。
---
## 📄 License
MIT
---
## 📎 相关
- [AGENTS.md](./AGENTS.md) — Agent 工程操作手册(规则、约束、Phase 顺序、Definition of Done)
- [docs/api.md](./docs/api.md) — 完整 API 文档
- [FuelLog_PLAN.md](./FuelLog_PLAN.md) — 原始产品 / 技术决策文档(仓库内)