diff --git a/README.md b/README.md new file mode 100644 index 0000000..3dfeb32 --- /dev/null +++ b/README.md @@ -0,0 +1,217 @@ +# 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 +``` + +打开 即可。首次进会看到"还没有车辆,先去添加"的引导。 + +### 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-compose + nginx +├── 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 次油 → 统计 → 删记录 → 删车" + +--- + +## 🐳 部署(待完成) + +```bash +cd deploy +docker compose up -d +# 浏览器访问 http://your-host +``` + +详见 `deploy/` 目录(待补充)。 + +--- + +## 🗺 路线图 + +- [x] Phase 0~1:初始化 + 后端骨架 + 数据层 + API + 单测 + 冒烟 +- [x] Phase 2~3:前端骨架 + 4 个业务页 +- [x] Phase 4:PWA(manifest / SW / 添加到主屏) +- [ ] Phase 5:数据导入导出(JSON / CSV) +- [ ] Phase 6:多用户 / 鉴权(目前是个人单机) +- [ ] Phase 7:Docker 部署 + 反代 +- [ ] 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) — 原始产品 / 技术决策文档(仓库内) diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..4f7ce71 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,184 @@ +# FuelLog API 文档 + +> Base path: `/api` +> 错误统一格式: `{ "code": "INVALID_ARGUMENT", "message": "..." }` +> 鉴权: MVP 阶段不要求,MVP 之后会在 `Authorization: Bearer ` 头里加。 + +--- + +## 通用约定 + +- 所有时间字段统一 **UTC** 存库;请求时传 RFC3339 字符串,响应回 RFC3339。 +- 金额 / 加油量 / 里程 / 油耗:**浮点**;金额计算在服务端用 `shopspring/decimal` 避免累计误差。 +- 列表接口默认空数组(不返回 `null`)。 +- 删除为硬删(数据库里 `DELETE`);二次确认在前端做。 +- 默认车辆唯一,切换走事务,避免出现两个 default。 + +错误码字典(在 `pkg/apperror`): + +| code | HTTP | 含义 | +|---|---|---| +| `INTERNAL` | 500 | 兜底,未预期 | +| `INVALID_ARGUMENT` | 400 | 参数校验失败 | +| `NOT_FOUND` | 404 | 资源不存在 | +| `CONFLICT` | 409 | 资源冲突(预留) | +| `UNAUTHORIZED` | 401 | 未鉴权(预留) | +| `FORBIDDEN` | 403 | 无权限(预留) | +| `UNAVAILABLE` | 503 | 暂不可用(预留) | + +--- + +## 健康检查 + +### `GET /health` + +```json +{ "status": "ok", "time": "2026-09-13T16:11:32+08:00" } +``` + +--- + +## 车辆 `/api/vehicles` + +### `GET /api/vehicles` + +列出所有车辆,按 `default_vehicle DESC, id ASC` 排序。 + +**Response 200** +```json +[ + { + "id": 1, + "name": "我的夏", + "brand": "比亚迪", + "model": "秦 PLUS DM-i", + "plate_number": "京A12345", + "fuel_type": "hybrid", + "tank_capacity": 48, + "current_odometer": 10000, + "default_vehicle": true, + "created_at": "2026-09-13T16:00:00+08:00", + "updated_at": "2026-09-13T16:00:00+08:00" + } +] +``` + +### `POST /api/vehicles` + +创建车辆。 + +**Request** +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `name` | string | ✅ | 车辆名称,max 64 | +| `brand` | string | | max 64 | +| `model` | string | | max 64 | +| `plate_number` | string | | max 32 | +| `fuel_type` | string | ✅ | gasoline / diesel / hybrid / electric / plug_in_hybrid | +| `tank_capacity` | number | | 升,`>= 0` | +| `current_odometer` | number | | 公里,`>= 0` | +| `default_vehicle` | bool | | 设为默认时,旧默认会被清掉(事务) | + +**Response 200** — 完整 Vehicle 对象 + +### `GET /api/vehicles/:id` + +返回单辆;不存在返 404。 + +### `PUT /api/vehicles/:id` + +**Patch 语义**:`*string` / `*number` / `*bool` 表示可选,未传的字段不更新。 +其它校验同 `POST`。 + +**Response 200** — 更新后的 Vehicle + +### `DELETE /api/vehicles/:id` + +硬删。返 `204 No Content`。 + +### `POST /api/vehicles/:id/default` + +设为唯一默认。事务内先清旧默认,再置新默认。 + +**Response 200** — 更新后的 Vehicle + +--- + +## 加油记录 `/api/refuels` + +### `POST /api/refuels` + +**Request** +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `vehicle_id` | uint64 | ✅ | 关联车辆 | +| `refuel_time` | RFC3339 | ✅ | 加油时间,UTC 存库 | +| `odometer` | number | ✅ | 加油时里程,`>= 0` | +| `fuel_type` | string | ✅ | 油品 | +| `liters` | number | ✅ | 加油量,**> 0** | +| `total_amount` | number | ✅ | 总金额,`>= 0` | +| `station_name` | string | | max 128 | +| `is_full` | bool | | 是否加满(决定是否进入标准油耗) | +| `remark` | string | | max 512 | + +**注意**:`price_per_liter` 不入参,由服务端用 `total_amount / liters` 实时算出,落库保留历史真实单价。 + +**Response 200** — 完整 RefuelRecord + +### `GET /api/refuels/:id` + +### `PUT /api/refuels/:id` + +Patch 语义。`liters` / `total_amount` 任一更新都会重新算 `price_per_liter`。 + +### `DELETE /api/refuels/:id` + +返 `204 No Content`。 + +### `GET /api/refuels?vehicle_id=...&start_date=...&end_date=...` + +| 参数 | 必填 | 说明 | +|---|---|---| +| `vehicle_id` | ✅ | uint64 | +| `start_date` | | RFC3339,只返 refuel_time >= 它的 | +| `end_date` | | RFC3339,只返 refuel_time <= 它的 | + +按 `refuel_time DESC, id DESC` 排序。 + +**Response 200** — RefuelRecord 数组 + +--- + +## 统计 `/api/vehicles/:id/statistics` + +单车的关键统计指标,服务端正序遍历 refuel_time 倒序列表,只在 +"连续两次 `is_full=true`"之间采样油耗 / 每公里成本;半箱油记录不会污染。 + +**Response 200** +```json +{ + "vehicle_id": 1, + "total_records": 3, + "total_liters": 82.0, + "total_amount": 656.0, + "avg_price": 8.0, + "avg_consumption": 6.4, + "cost_per_km": 0.51, + "last_refuel": { /* RefuelRecord 或 null */ } +} +``` + +字段含义: +- `avg_price` 全部记录的实际油价算术平均 +- `avg_consumption` 标准百公里油耗 L/100km(仅 is_full 序列的样本平均) +- `cost_per_km` 每公里燃油成本 元/km(仅 is_full 序列的样本平均) +- `last_refuel` 最近一次加油记录(按 refuel_time DESC 第一个) + +空车辆返 `{ total_records: 0, ...全 0, last_refuel: null }`。 + +--- + +## 索引 + +- 车辆:`vehicle.id` PK +- 加油记录:`refuel_record.id` PK;`(vehicle_id, refuel_time)` 与 `vehicle_id` 各自索引