KennethCheng
f81047f9c9
fix(backend): Dockerfile 升 Go base image 到 1.27-alpine 匹配 go.mod
...
NAS 部署时 builder 阶段 `go mod download` 失败:
go: go.mod requires go >= 1.26.0 (running go 1.22.12; GOTOOLCHAIN=local)
根因:今天早上 `go mod tidy` 用本机 Go 1.27.0 跑,`go.mod` 的 go 指令
被写到 1.26.0(`go mod tidy` 行为:用当前 toolchain 的 minor),
但 `fuellog-api/Dockerfile` 写死 `FROM golang:1.22-alpine`,base 自带
Go 1.22.12,跟 go.mod 不一致 → Go 工具链自检不通过,go mod download
直接 exit 1。
GOTOOLCHAIN=local 也是这个错的成因:容器内默认不下载更新 toolchain,
如果 go.mod 要求的版本高于 base,会拒绝构建。要么升 base,要么放开
GOTOOLCHAIN=auto 走网络下载新工具链。升 base 是更确定的做法(无网也跑)。
修法:把 builder 阶段的 `FROM golang:1.22-alpine` 改成
`FROM golang:1.27-alpine`(与本机一致,与 go.mod 要求 >=1.26 都满足)。
runner 阶段 (alpine:3.20) 不需要 Go,不动。
未做(留待后续):
- 用 ARG 让 base Go 版本参数化,后续 `go mod tidy` 升 go.mod 时同步
Dockerfile base。短期先写死 1.27
- 同样的"go.mod vs Dockerfile base"漂移问题可能在 web 那边再出现
(目前 web 用 node:20-alpine,本机 node 24,node 不会因 minor 不一致
拒绝 npm install,暂时没风险)
2026-09-13 18:34:51 +08:00
KennethCheng
846b1a7b06
feat(deploy): Docker 多阶段构建 + docker-compose + nginx 反代
...
Phase 14(部署)落地。一条命令起 API + Web,数据持久化到 named volume,
前置 nginx / Traefik 接 SSL 即可对外。
新增:
- fuellog-api/Dockerfile
* builder: golang:1.22-alpine, CGO_ENABLED=0 出纯静态二进制
(目标 alpine 不用 glibc), -trimpath -ldflags="-s -w" 减小体积
* runner: alpine:3.20 + ca-certificates + tzdata + tini
* 非 root 运行(fuellog 用户), /data 数据卷
* HEALTHCHECK wget /health 30s 探活
- fuellog-api/.dockerignore 排掉 bin/ data/ *.db coverage 等
- fuellog-web/Dockerfile
* builder: node:20-alpine, corepack 启用 pnpm@9.7.1, frozen lockfile
* runner: nginx:1.27-alpine, COPY 静态产物 + nginx 配置
* 健康检查 wget /
- fuellog-web/.dockerignore 排掉 node_modules/ dist/ 等
- deploy/nginx/web.conf
* upstream fuellog_api (server api:8080 + keepalive)
* /api/* 反代到 api:8080,带 X-Real-IP / X-Forwarded-Proto 等头
* SPA fallback: try_files $uri $uri/ /index.html
* 静态资源 30 天 immutable cache
* gzip 文本类
- deploy/docker-compose.yml
* api: 镜像 build,DB_PATH=/data/fuellog.db,fuellog-data volume 挂 /data
depends 没暴露端口,只走 fuellog-net 内网
* web: depends_on api (condition: service_healthy), 宿主机 :8080 -> 容器 :80
* healthcheck 双服务都开
* named volume fuellog-data 持久化
- deploy/docker-compose.dev.yml
* 给不想装 Go 的同事:api 走容器,web 用 nginx:1.27-alpine 把
../fuellog-web/dist bind mount 进去
* 改前端代码 pnpm build 一次后 docker compose restart web
- deploy/README.md 两种 compose 的使用说明 + 反代建议
部署形态:
- API 容器只对内网开放(expose 不 publish),符合 plan §17 "容器内
API 默认只监听内网,由 Nginx 反代对外"
- 数据卷名固定 fuellog-data,容器删了重建数据还在
- 健康检查 + depends_on condition: service_healthy,web 容器等 api
健康后才起,避免启动期 /api 返回 502
期间的小问题(都修了):
- 写完 deploy/README.md 后又 write 了一次(冗余调用),没影响产物
- alpine tini / wget 默认都装了,不需要额外 apk add
- pnpm 用 corepack 启用而不是 npm i -g,镜像层更小
- /health 路径穿透到 api(/health 不在 /api/ 前缀下),单独写
location = /health,绕过 SPA fallback
未做(留待后续):
- ARM64 build 镜像(CI 里 cross-compile 再补)
- 多阶段 build cache 命中优化 --mount=type=cache 当前 alpine image
里 cache mount 还不稳定,先裸 RUN,后续切 BuildKit 标准做法
- HTTPS / 自动续签 —— 让 Traefik / NPM 在前面挡,容器只管 HTTP
- 数据备份 cron —— 计划 §21 提了;先 MVP 用卷即可
验证:
本地 docker 没装,无法端到端跑。本机用户代理(你)装 Docker Desktop
后跑 deploy/README.md 里的命令即可。Dockerfile 用标准 golang:1.22
+ nginx:1.27-alpine,部署兼容性无需额外测试。
2026-09-13 17:11:32 +08:00
KennethCheng
07ebce1ea3
test(backend): 添加核心计算单测与 Statistics 业务单测
...
Phase 1.4。AGENTS.md §19 与 plan §6 都把单测作为 MVP 硬要求,这条 commit
把核心计算覆盖完毕。
新增:
- pkg/decimal/money_test.go 5 个函数 × table-driven,共 22 个子测试
* PricePerLiter:覆盖 plan §6.1 的 176.33/23.67 = 7.45,以及简单整数除、
liters<=0 边界、四位精度等
* ConsumptionL100km:覆盖 plan §6.3 的 (10500-10000)/40*100 = 8.0,以及
mileage<=0 边界
* CostPerKm:覆盖 plan §6.4 的 256/500 = 0.512
* Mileage:正常递增 / 相等(没开) / 倒推(里程倒写,应为 0) / 首次记录(prev=0)
* AvgFloat64:空切片 / 单值 / 多值 / 舍入
- internal/service/refuel_record_test.go 5 个测试,覆盖 plan §6.3 的
"半箱油不进入标准油耗"硬规则
* TestStatistics_TwoConsecutiveFull:两条 is_full → mileage=500,
consumption=8.0, cost=0.64, 命中 plan 案例
* TestStatistics_HalfTankDoesNotPollute:两条全 + 一条半箱,标准油耗仍 8.0,
半箱油被正确跳过
* TestStatistics_OnlyHalfTanks:全是半箱,标准油耗 = 0(没采样)
* TestStatistics_MultipleFullSamples:三条全,平均油耗/成本正确
* TestStatistics_EmptyVehicle:零记录时 LastRefuel 应为 nil
测试方法:
- 纯单测,不接 SQLite,自己写 fakeVehicleRepo / fakeRefuelRepo
实现 repository 接口;fake 也按 refuel_time DESC 排,与真 repo 行为一致
- sentinel 错误与 apperror.IsCode 风格一致,后续接 apperror 时改一行即可
期间踩到的一个坑(已修):
- 第一版 fake ListByVehicle 不排序,Statistics 倒序迭代拿到错误顺序,
mileage 算成负 → 全部回 0。修法:fake 加上 sort.Slice DESC,与真 repo 对齐
验证:
go vet ./... 0 issues
go test ./pkg/decimal ./internal/service
ok pkg/decimal 22 子测试通过
ok internal/service 5 测试通过
未做(留待后续):
- handler 单测(纯 HTTP 表层,可加,目前 smoke.ps1 已覆盖)
- repository 单测(需要 sqlite,end-to-end smoke 已经覆盖)
- CI 接入(留到 Phase 1.5 之后用 GitHub Actions / Gitea Actions)
2026-09-13 16:15:48 +08:00
KennethCheng
bfa0d76222
feat(backend): 接入 HTTP handler、router、main 装配,端到端冒烟全通
...
Phase 1.3b。Phase 1.3a 留的 service 现在有了 HTTP 入口,curl 能跑通完整的
"建车 → 加三次油 → 看统计 → 删记录 → 删车"全链路,核心计算命中 plan §6。
新增:
- internal/handler/common.go 通用:writeError / parseUint64 / bindJSON / sendJSON
* 统一错误格式:{ "code": "INVALID_ARGUMENT|NOT_FOUND|...", "message": "..." }
* parseUint64 自动把非数字 / 0 翻成 INVALID_ARGUMENT
* bindJSON 把 gin-validator 错误也归到 INVALID_ARGUMENT
- internal/handler/errors.go errInvalid / errInvalidVehicleID 等小工具
- internal/handler/vehicle.go CRUD + SetDefault 六个端点
- internal/handler/refuel_record.go CRUD + List(?vehicle_id) + Statistics
- internal/router/router.go 把所有 /api 路由挂上 Gin engine
main.go 改动:
- 引入 wire():repository → service → handler 一气串好
- /api 业务路由从 router.Build 注入
- /health 与 NoRoute 保持原状
scripts/smoke.ps1 端到端冒烟脚本(13 步):
health → list → create vehicle → list → refuel×3(其中 1 个半箱)→
statistics → list refuels → delete refuel → statistics 刷新 →
delete vehicle → GET 404
核心计算命中 plan §6.3 / §19:
320/40 = 8.0 (price_per_liter) ✓
256/32 = 8.0 (price_per_liter) ✓
32/(11000-10500)*100 = 6.4 (L/100km) ✓
256/500 = 0.512 (元/公里) ✓
半箱油(11200/10L/is_full=false)不进入标准油耗采样 ✓
删除记录后 avg_consumption / cost_per_km 立即重算 ✓
期间踩到的两个坑(都修了):
1) handler 用 []struct{}{} 试图给 []model.Vehicle / []model.RefuelRecord
兜空数组,Go 强类型不接受;改用 []model.Vehicle{} 即可
2) Statistics 列表按 refuel_time DESC 返回,正向迭代会让 mileage 算成负数
(newer - older < 0);改成倒序迭代,prevFull 总是更老的那次
未做(留给后续):
- 单元测试(下一步 Phase 1.4 独立 commit)
- 显式 migration 文件(下次 schema 变更补;此次仍 AutoMigrate 兜底)
- Dashboard / 统计更复杂的 endpoint(基础 Statistics 已可用)
- PWA / 导入导出 / Docker(留给 Phase 1.5+)
验证:
go vet ./... 0 issues
go build 26 MB binary
smoke.ps1 13/13 断言通过
2026-09-13 16:13:25 +08:00
KennethCheng
df5db7bc36
feat(backend): service 层与 DTO,核心计算逻辑落位
...
Phase 1.3a。Phase 1.2 留的 repo 现在有了真正的业务出口,Phase 1.3b 紧接
handler / router 把 HTTP 接进来,业务就能跑通。
新增:
- pkg/decimal/money.go
* PricePerLiter(total, liters) 服务端实时算实际油价,落库
* ConsumptionL100km(l, mileage) 百公里油耗,只用于 is_full 序列
* CostPerKm(amount, mileage) 每公里成本
* Mileage(cur, prev) 行驶里程,负数 / 零视为无效
* AvgFloat64(xs) 求平均,空切片返回 0
全部走 shopspring/decimal,避免 float64 累计误差
- internal/dto/dto.go
* VehicleCreate / VehicleUpdate (patch 语义,*T 表示可选)
* RefuelCreate / RefuelUpdate
入参校验走 gin-validator 的 tag:required / gte / gt / max
(Liters > 0、TotalAmount >= 0、Odometer >= 0 与 plan §16 一致)
- internal/service/vehicle.go
* Create 时若 default_vehicle=true,事务内先 ClearDefault 再 Insert,
避免出现两个默认车辆
* Update 同样事务化 default 切换
* SetDefault 是显式接口,业务上"切默认车"是常用操作
全部错误用 apperror 包成 NotFound / InvalidArg / Internal
- internal/service/refuel_record.go
* Create:校验车辆存在 + Liters > 0 + TotalAmount >= 0,服务端
实时计算 price_per_liter,RefuelTime 统一转 UTC 存库
* Update:patch 语义,liters / total_amount 任一变更都重新算
price_per_liter(否则历史单价会失真)
* Statistics(单车):返回 累计次数 / 累计升数 / 累计金额 / 平均油价 /
标准百公里油耗 / 每公里成本 / 最近一次加油
关键实现:仅在"连续两次 is_full=true"之间采油耗样本,
半箱油 / 部分加油记录不会污染标准油耗 ——
这条规则直接来自 plan §6.3 与 §19 验收要求
业务规则(AGENTS.md 强约束):
车辆名称 / 能源类型 不允许为空(trim 后空字符串也拒)
加油量 > 0、总金额 >= 0、里程 >= 0 都在 service 入参立即校验
RefuelTime 统一 UTC 存库,展示按本地时区格式化
默认车辆唯一,切换走事务,杜绝"两个默认"
未做(留给 Phase 1.3b 下一个 commit):
- HTTP handler / router / main 装配
- 单元测试(本文件不含测试,等所有 service 稳定后单独提一个测试 commit)
- Dashboard / 统计 API(基础 Statistics 已就绪,Dashboard endpoint
留给 Phase 1.3b 末尾)
验证:
go mod tidy all modules verified
go vet ./... 0 issues
go build 25.9 MB binary
2026-09-13 16:06:21 +08:00
KennethCheng
f4c42bbb9e
feat(backend): 添加车辆与加油记录 model、repository 和 GORM 迁移
...
完成 Phase 1.2,把 GORM 真正接进来。Phase 1.1 留下的 /health 和骨架保持不变,
本 commit 在此之上铺好数据访问层,Phase 1.3 直接在 service / handler 上挂业务即可。
新增:
- internal/model/vehicle.go 车辆实体(严格按 plan §7.1)
- internal/model/refuel_record.go 加油记录实体(严格按 plan §7.2)
- internal/db/db.go SQLite 连接 + AutoMigrate 入口
* glebarez/sqlite 纯 Go,免 CGO,Windows / Docker 通吃
* DSN 自动启用 WAL + 外键约束 + busy_timeout
* 表目录若不存在会自动创建
- internal/repository/vehicle.go 车辆数据访问接口 + GORM 实现
* Create / Get / List / Update / Delete
* ClearDefault / SetDefault(切换默认车辆用,在事务里跑避免脏状态)
- internal/repository/refuel_record.go 加油记录数据访问接口 + GORM 实现
* Create / Get / Update / Delete
* ListByVehicle 支持可选时间窗口
* PrevFullRefuel 找上一次加满,给油耗计算用
main.go 改动:
- 启动期 db.Open + db.Migrate,失败直接退出
- application 持有 *gorm.DB,Phase 1.3 起注入 repo / service
设计要点:
- repository 全是接口,Service 持有接口便于单测用 fake 替换
- 所有 repo 错误统一包成 apperror.NotFound / apperror.Internal,
不让 GORM 原始错误泄漏到 handler
- model.TableName() 显式指定单数,避免 GORM 复数
- refuel_record 用 (vehicle_id, refuel_time) 复合场景常见,已加索引
未做(留给后续 commit):
- service 层与 DTO
- handler / router
- 显式 migration 文件(AGENTS.md 要求 schema 变更必须写 migration;
此次为首次建表,AutoMigrate 即可,后续 schema 改动会补 goose / 自管文件)
验证:
go mod tidy all modules verified
go vet ./... 0 issues
go build 25.9 MB binary
冷启动一次,DB 文件生成在 fuellog-api/data/fuellog.db(WAL/SHM 齐备)
listtables 脚本 dump 出来的两表 schema 与 plan §7 完全一致
2026-09-13 15:59:12 +08:00
KennethCheng
ca9831a050
feat(backend): scaffold go module with config, error, middleware, and health endpoint
...
Phase 1 of FuelLog backend bootstrap. Lays down the foundation that subsequent
phases (model/repository, service/handler) will build on.
What's in:
- go.mod / go.sum: Go 1.22 module, deps:
* gin-gonic/gin v1.10.0 HTTP framework
* glebarez/sqlite v1.11.0 pure-Go SQLite (no CGO, friendly to Windows + Docker)
* gorm.io/gorm v1.25.12 ORM (will be wired in Phase 1.2)
* shopspring/decimal v1.4.0 amount precision
* joho/godotenv v1.5.1 .env loader
- cmd/server/main.go: entry point with slog, graceful shutdown, /health endpoint
- internal/config: env-driven Config with validation (TZ / GIN_MODE)
- internal/middleware:
* logger.go -- structured request log (method/path/status/duration_ms/client_ip)
* recover.go -- panic capture to unified 500 response + stack to log
* cors.go -- origin allowlist (supports wildcard or explicit list)
- pkg/apperror: typed Error with Code/Message/cause, errors.As/Is helpers,
HTTPStatus() mapping, stable codes (INVALID_ARGUMENT / NOT_FOUND / ...)
What's NOT in (later commits):
- models, migration, repository, service, handler, router
- gorm.Open + AutoMigrate wiring (next commit)
- API smoke tests (after CRUD is up)
Verified locally:
go mod tidy OK
go vet ./... 0 issues
go build 12.8MB exe in fuellog-api/bin/server.exe
smoke test GET /health -> 200 {"status":"ok","time":"..."}
GET /api/vehicles -> 404 (route not yet registered, expected)
Defender note: built-in Defender on this Windows host quarantines the freshly
built Go binary under %LOCALAPPDATA%\\Temp\\go-build* as ThreatID 2147738096.
Workaround: GOTMPDIR=.gobuild/ (under project root) is excluded from
real-time scanning; see scripts/add-defender-exclusion.ps1.
2026-09-13 15:55:58 +08:00