diff --git a/fuellog-api/cmd/server/main.go b/fuellog-api/cmd/server/main.go index 2b10606..7bd564e 100644 --- a/fuellog-api/cmd/server/main.go +++ b/fuellog-api/cmd/server/main.go @@ -4,7 +4,7 @@ // 1. 加载 .env // 2. 解析配置 // 3. 打开 SQLite + 运行迁移 -// 4. 装配路由 +// 4. 装配 repo / service / handler / router // 5. 监听并优雅退出 package main @@ -23,7 +23,11 @@ import ( "github.com/kennethcheng/fuellog-api/internal/config" "github.com/kennethcheng/fuellog-api/internal/db" + "github.com/kennethcheng/fuellog-api/internal/handler" "github.com/kennethcheng/fuellog-api/internal/middleware" + "github.com/kennethcheng/fuellog-api/internal/repository" + "github.com/kennethcheng/fuellog-api/internal/router" + "github.com/kennethcheng/fuellog-api/internal/service" "github.com/kennethcheng/fuellog-api/pkg/apperror" ) @@ -55,6 +59,7 @@ func main() { slog.Info("db ready", "path", cfg.DBPath) app := &application{cfg: cfg, db: gormDB} + app.wire() srv := &http.Server{ Addr: cfg.ServerAddr, @@ -89,11 +94,22 @@ func main() { type application struct { cfg *config.Config db *gorm.DB - // Phase 1.3 起注入:vehicleRepo / refuelRepo / vehicleSvc / refuelSvc + + vehicleRepo repository.VehicleRepository + refuelRepo repository.RefuelRecordRepository + vehicleSvc service.VehicleService + refuelSvc service.RefuelRecordService +} + +// wire 装配 repo / service / handler。 +func (a *application) wire() { + a.vehicleRepo = repository.NewVehicleRepository(a.db) + a.refuelRepo = repository.NewRefuelRecordRepository(a.db) + a.vehicleSvc = service.NewVehicleService(a.vehicleRepo, a.db) + a.refuelSvc = service.NewRefuelRecordService(a.refuelRepo, a.vehicleRepo) } // router 构建并返回 Gin 引擎。 -// 当前阶段只暴露 /health;Phase 1.3 起逐步接 /api/vehicles、/api/refuels。 func (a *application) router() *gin.Engine { r := gin.New() r.Use( @@ -116,10 +132,10 @@ func (a *application) router() *gin.Engine { }) }) - // /api 路由组(Phase 1.3 起在此挂载业务路由) - api := r.Group("/api") - _ = api // 占位,避免未使用告警 - + router.Build(r, + handler.NewVehicleHandler(a.vehicleSvc), + handler.NewRefuelHandler(a.refuelSvc), + ) return r } diff --git a/fuellog-api/internal/handler/common.go b/fuellog-api/internal/handler/common.go new file mode 100644 index 0000000..630c9ff --- /dev/null +++ b/fuellog-api/internal/handler/common.go @@ -0,0 +1,91 @@ +// Package handler 提供 HTTP 入口,只做参数解析 / 序列化 / 错误码映射, +//不写业务逻辑。所有业务计算都走 service 层。 +package handler + +import ( + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "github.com/kennethcheng/fuellog-api/pkg/apperror" +) + +// writeError 把任意 error 渲染成统一 JSON 响应 + 对应 HTTP 状态码。 +// 已知 *apperror.Error 用其 HTTPStatus,其它归 500 + INTERNAL。 +func writeError(c *gin.Context, err error) { + if appErr, ok := apperror.As(err); ok { + c.JSON(appErr.HTTPStatus(), gin.H{ + "code": appErr.Code, + "message": appErr.Message, + }) + return + } + // gin-validator 抛的 binding 错误 + if isValidationErr(err) { + c.JSON(http.StatusBadRequest, gin.H{ + "code": apperror.CodeInvalidArg, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusInternalServerError, gin.H{ + "code": apperror.CodeInternal, + "message": "服务器开了小差,请稍后再试", + }) +} + +func isValidationErr(err error) bool { + if err == nil { + return false + } + // 走错误信息启发式判断;真正严谨的做法是导入 validator/types + // 引入额外依赖不划算,Phase 2 内部接口稳定后切换 + msg := err.Error() + return containsAny(msg, []string{"validation", "Field validation", "required", "invalid"}) +} + +func containsAny(s string, subs []string) bool { + for _, sub := range subs { + if len(sub) <= len(s) { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + } + } + return false +} + +// parseUint64 解析路径里的 uint64 id。失败返 INVALID_ARGUMENT。 +func parseUint64(c *gin.Context, key string) (uint64, error) { + raw := c.Param(key) + v, err := strconv.ParseUint(raw, 10, 64) + if err != nil || v == 0 { + return 0, apperror.New(apperror.CodeInvalidArg, "无效的 id: "+raw) + } + return v, nil +} + +// bindJSON 把请求体解析到 dst;validator 失败自动转为 INVALID_ARGUMENT。 +func bindJSON(c *gin.Context, dst any) error { + if err := c.ShouldBindJSON(dst); err != nil { + return apperror.Wrap(apperror.CodeInvalidArg, "请求体不合法: "+err.Error(), err) + } + return nil +} + +// sendJSON 统一回写 200。 +func sendJSON(c *gin.Context, data any) { + c.JSON(http.StatusOK, data) +} + +// errBind 内部错误快速生成函数(减少模板代码)。 +func errBind(err error) error { + if err == nil { + return nil + } + return apperror.Wrap(apperror.CodeInvalidArg, err.Error(), errors.Unwrap(err)) +} diff --git a/fuellog-api/internal/handler/errors.go b/fuellog-api/internal/handler/errors.go new file mode 100644 index 0000000..41c4624 --- /dev/null +++ b/fuellog-api/internal/handler/errors.go @@ -0,0 +1,6 @@ +package handler + +import "github.com/kennethcheng/fuellog-api/pkg/apperror" + +func errInvalid(msg string) error { return apperror.New(apperror.CodeInvalidArg, msg) } +func errInvalidVehicleID() error { return errInvalid("缺少或非法的 vehicle_id") } diff --git a/fuellog-api/internal/handler/refuel_record.go b/fuellog-api/internal/handler/refuel_record.go new file mode 100644 index 0000000..ebe1e40 --- /dev/null +++ b/fuellog-api/internal/handler/refuel_record.go @@ -0,0 +1,159 @@ +package handler + +import ( + "time" + + "github.com/gin-gonic/gin" + + "github.com/kennethcheng/fuellog-api/internal/dto" + "github.com/kennethcheng/fuellog-api/internal/model" + "github.com/kennethcheng/fuellog-api/internal/service" +) + +// RefuelHandler 加油记录路由。 +type RefuelHandler struct{ svc service.RefuelRecordService } + +func NewRefuelHandler(svc service.RefuelRecordService) *RefuelHandler { + return &RefuelHandler{svc: svc} +} + +// POST /api/refuels +func (h *RefuelHandler) Create(c *gin.Context) { + var in dto.RefuelCreate + if err := bindJSON(c, &in); err != nil { + writeError(c, err) + return + } + rec, err := h.svc.Create(c.Request.Context(), &in) + if err != nil { + writeError(c, err) + return + } + sendJSON(c, rec) +} + +// GET /api/refuels/:id +func (h *RefuelHandler) Get(c *gin.Context) { + id, err := parseUint64(c, "id") + if err != nil { + writeError(c, err) + return + } + rec, err := h.svc.Get(c.Request.Context(), id) + if err != nil { + writeError(c, err) + return + } + sendJSON(c, rec) +} + +// PUT /api/refuels/:id +func (h *RefuelHandler) Update(c *gin.Context) { + id, err := parseUint64(c, "id") + if err != nil { + writeError(c, err) + return + } + var in dto.RefuelUpdate + if err := bindJSON(c, &in); err != nil { + writeError(c, err) + return + } + rec, err := h.svc.Update(c.Request.Context(), id, &in) + if err != nil { + writeError(c, err) + return + } + sendJSON(c, rec) +} + +// DELETE /api/refuels/:id +func (h *RefuelHandler) Delete(c *gin.Context) { + id, err := parseUint64(c, "id") + if err != nil { + writeError(c, err) + return + } + if err := h.svc.Delete(c.Request.Context(), id); err != nil { + writeError(c, err) + return + } + c.Status(204) +} + +// GET /api/refuels?vehicle_id=...&start_date=...&end_date=... +func (h *RefuelHandler) List(c *gin.Context) { + vehicleIDStr := c.Query("vehicle_id") + if vehicleIDStr == "" { + writeError(c, errInvalidVehicleID()) + return + } + vehicleID, err := parseUint64Query(vehicleIDStr) + if err != nil { + writeError(c, err) + return + } + var startPtr, endPtr *time.Time + if s := c.Query("start_date"); s != "" { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + writeError(c, errInvalid("start_date 需为 RFC3339 时间")) + return + } + startPtr = &t + } + if s := c.Query("end_date"); s != "" { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + writeError(c, errInvalid("end_date 需为 RFC3339 时间")) + return + } + endPtr = &t + } + records, err := h.svc.ListByVehicle(c.Request.Context(), vehicleID, startPtr, endPtr) + if err != nil { + writeError(c, err) + return + } + if records == nil { + records = []model.RefuelRecord{} + } + sendJSON(c, records) +} + +// GET /api/vehicles/:id/statistics +func (h *RefuelHandler) Statistics(c *gin.Context) { + vehicleID, err := parseUint64(c, "id") + if err != nil { + writeError(c, err) + return + } + stats, err := h.svc.Statistics(c.Request.Context(), vehicleID) + if err != nil { + writeError(c, err) + return + } + sendJSON(c, stats) +} + +func parseUint64Query(s string) (uint64, error) { + v, err := parseUint64Impl(s) + if err != nil { + return 0, errInvalid("vehicle_id 必须是正整数") + } + return v, nil +} + +func parseUint64Impl(s string) (uint64, error) { + var v uint64 + for _, c := range s { + if c < '0' || c > '9' { + return 0, errInvalid("非数字字符") + } + v = v*10 + uint64(c-'0') + } + if v == 0 { + return 0, errInvalid("id 不能为 0") + } + return v, nil +} diff --git a/fuellog-api/internal/handler/vehicle.go b/fuellog-api/internal/handler/vehicle.go new file mode 100644 index 0000000..eaaf140 --- /dev/null +++ b/fuellog-api/internal/handler/vehicle.go @@ -0,0 +1,108 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "github.com/kennethcheng/fuellog-api/internal/dto" + "github.com/kennethcheng/fuellog-api/internal/model" + "github.com/kennethcheng/fuellog-api/internal/service" +) + +// VehicleHandler 车辆路由。 +type VehicleHandler struct{ svc service.VehicleService } + +func NewVehicleHandler(svc service.VehicleService) *VehicleHandler { + return &VehicleHandler{svc: svc} +} + +// POST /api/vehicles +func (h *VehicleHandler) Create(c *gin.Context) { + var in dto.VehicleCreate + if err := bindJSON(c, &in); err != nil { + writeError(c, err) + return + } + v, err := h.svc.Create(c.Request.Context(), &in) + if err != nil { + writeError(c, err) + return + } + sendJSON(c, v) +} + +// GET /api/vehicles +func (h *VehicleHandler) List(c *gin.Context) { + vs, err := h.svc.List(c.Request.Context()) + if err != nil { + writeError(c, err) + return + } + if vs == nil { + vs = []model.Vehicle{} // 避免前端收到 null,统一空数组 + } + sendJSON(c, vs) +} + +// GET /api/vehicles/:id +func (h *VehicleHandler) Get(c *gin.Context) { + id, err := parseUint64(c, "id") + if err != nil { + writeError(c, err) + return + } + v, err := h.svc.Get(c.Request.Context(), id) + if err != nil { + writeError(c, err) + return + } + sendJSON(c, v) +} + +// PUT /api/vehicles/:id +func (h *VehicleHandler) Update(c *gin.Context) { + id, err := parseUint64(c, "id") + if err != nil { + writeError(c, err) + return + } + var in dto.VehicleUpdate + if err := bindJSON(c, &in); err != nil { + writeError(c, err) + return + } + v, err := h.svc.Update(c.Request.Context(), id, &in) + if err != nil { + writeError(c, err) + return + } + sendJSON(c, v) +} + +// DELETE /api/vehicles/:id +func (h *VehicleHandler) Delete(c *gin.Context) { + id, err := parseUint64(c, "id") + if err != nil { + writeError(c, err) + return + } + if err := h.svc.Delete(c.Request.Context(), id); err != nil { + writeError(c, err) + return + } + c.Status(204) +} + +// POST /api/vehicles/:id/default +func (h *VehicleHandler) SetDefault(c *gin.Context) { + id, err := parseUint64(c, "id") + if err != nil { + writeError(c, err) + return + } + v, err := h.svc.SetDefault(c.Request.Context(), id) + if err != nil { + writeError(c, err) + return + } + sendJSON(c, v) +} diff --git a/fuellog-api/internal/router/router.go b/fuellog-api/internal/router/router.go new file mode 100644 index 0000000..d94e979 --- /dev/null +++ b/fuellog-api/internal/router/router.go @@ -0,0 +1,31 @@ +// Package router 负责组装 Gin 路由表。 +package router + +import ( + "github.com/gin-gonic/gin" + + "github.com/kennethcheng/fuellog-api/internal/handler" +) + +// Build 在传入的 gin.Engine 上挂载 /api 业务路由。 +// 引擎本身的 /health 与 NoRoute 由调用方(main)处理。 +func Build(r *gin.Engine, vh *handler.VehicleHandler, rh *handler.RefuelHandler) { + api := r.Group("/api") + + // 车辆 + api.POST("/vehicles", vh.Create) + api.GET("/vehicles", vh.List) + api.GET("/vehicles/:id", vh.Get) + api.PUT("/vehicles/:id", vh.Update) + api.DELETE("/vehicles/:id", vh.Delete) + api.POST("/vehicles/:id/default", vh.SetDefault) + // 统计(挂在车辆下,语义清晰) + api.GET("/vehicles/:id/statistics", rh.Statistics) + + // 加油记录 + api.POST("/refuels", rh.Create) + api.GET("/refuels", rh.List) + api.GET("/refuels/:id", rh.Get) + api.PUT("/refuels/:id", rh.Update) + api.DELETE("/refuels/:id", rh.Delete) +} diff --git a/fuellog-api/scripts/smoke.ps1 b/fuellog-api/scripts/smoke.ps1 new file mode 100644 index 0000000..6d6da50 --- /dev/null +++ b/fuellog-api/scripts/smoke.ps1 @@ -0,0 +1,204 @@ +# FuelLog API smoke test (UTF-8 with BOM) +# Usage: powershell -ExecutionPolicy Bypass -File scripts/smoke.ps1 + +$ErrorActionPreference = 'Stop' +$base = 'http://127.0.0.1:8080' + +function Step($name, $scriptBlock) { + Write-Host '' + Write-Host "=== $name ===" -ForegroundColor Cyan + & $scriptBlock +} + +function Hit($method, $path, $body) { + $params = @{ + Uri = "$base$path" + Method = $method + UseBasicParsing = $true + ContentType = 'application/json' + } + if ($null -ne $body) { $params.Body = (ConvertTo-Json -InputObject $body -Depth 10) } + try { + $r = Invoke-WebRequest @params -TimeoutSec 10 + return @{ Status = [int]$r.StatusCode; Body = $r.Content } + } catch { + $resp = $_.Exception.Response + if ($resp) { + $stream = $resp.GetResponseStream() + $reader = New-Object System.IO.StreamReader($stream) + return @{ Status = [int]$resp.StatusCode; Body = $reader.ReadToEnd() } + } + throw + } +} + +function Check($label, $actual, $expected) { + if ($actual -eq $expected) { + Write-Host " OK ${label} = $actual" -ForegroundColor Green + } else { + Write-Host " FAIL ${label}: expected $expected, got $actual" -ForegroundColor Red + exit 1 + } +} + +# 1) health +Step 'health' { + $r = Hit GET '/health' $null + Check 'status' $r.Status 200 + Write-Host " body: $($r.Body)" +} + +# 2) list (counted, not asserted empty -- DB may carry data from prior runs) +Step 'list vehicles' { + $r = Hit GET '/api/vehicles' $null + Check 'status' $r.Status 200 + $list = $r.Body | ConvertFrom-Json + Write-Host " initial count = $($list.Count) (may be > 0 from prior runs)" +} + +# 3) create vehicle +$vehicle = $null +Step 'create vehicle' { + $body = @{ + name = 'My Car' + brand = 'BYD' + model = 'Qin PLUS DM-i' + plate_number = 'Jing-A12345' + fuel_type = 'hybrid' + tank_capacity = 48 + current_odometer = 10000 + default_vehicle = $true + } + $r = Hit POST '/api/vehicles' $body + Check 'status' $r.Status 200 + $v = $r.Body | ConvertFrom-Json + $script:vehicle = $v + Write-Host " id=$($v.id) name=$($v.name) default=$($v.default_vehicle)" +} + +# 4) verify created vehicle shows up in list +Step 'list contains new vehicle' { + $r = Hit GET '/api/vehicles' $null + Check 'status' $r.Status 200 + $list = $r.Body | ConvertFrom-Json + $found = $list | Where-Object { $_.id -eq $vehicle.id } + if (-not $found) { Write-Host " FAIL: new vehicle not in list"; exit 1 } + Write-Host " OK new vehicle id=$($vehicle.id) found in list of $($list.Count)" +} + +# 5) refuel #1 (full) +$refuel1 = $null +Step 'refuel #1 (full)' { + $body = @{ + vehicle_id = $vehicle.id + refuel_time = '2025-08-25T10:00:00+08:00' + odometer = 10500 + fuel_type = 'hybrid' + liters = 40 + total_amount = 320 + station_name = 'Sinopec Wangjing' + is_full = $true + remark = 'first full' + } + $r = Hit POST '/api/refuels' $body + Check 'status' $r.Status 200 + $rec = $r.Body | ConvertFrom-Json + $script:refuel1 = $rec + Write-Host " price_per_liter = $($rec.price_per_liter) (expect 8.0)" + if ([math]::Abs([double]$rec.price_per_liter - 8.0) -gt 0.001) { Write-Host 'FAIL price'; exit 1 } +} + +# 6) refuel #2 (full) +$refuel2 = $null +Step 'refuel #2 (full)' { + $body = @{ + vehicle_id = $vehicle.id + refuel_time = '2025-09-10T10:00:00+08:00' + odometer = 11000 + fuel_type = 'hybrid' + liters = 32 + total_amount = 256 + station_name = 'Sinopec Wangjing' + is_full = $true + } + $r = Hit POST '/api/refuels' $body + Check 'status' $r.Status 200 + $rec = $r.Body | ConvertFrom-Json + $script:refuel2 = $rec + Write-Host " price_per_liter = $($rec.price_per_liter) (expect 8.0)" +} + +# 7) refuel #3 (half - should NOT affect avg consumption) +Step 'refuel #3 (half)' { + $body = @{ + vehicle_id = $vehicle.id + refuel_time = '2025-09-15T10:00:00+08:00' + odometer = 11200 + fuel_type = 'hybrid' + liters = 10 + total_amount = 80 + station_name = 'Sinopec Wangjing' + is_full = $false + } + $r = Hit POST '/api/refuels' $body + Check 'status' $r.Status 200 +} + +# 8) statistics +Step 'statistics' { + $r = Hit GET "/api/vehicles/$($vehicle.id)/statistics" $null + Check 'status' $r.Status 200 + $s = $r.Body | ConvertFrom-Json + Write-Host " total_records = $($s.total_records) (expect 3)" + Write-Host " total_liters = $($s.total_liters) (expect 82)" + Write-Host " total_amount = $($s.total_amount) (expect 656)" + Write-Host " avg_price = $($s.avg_price) (expect ~8.0)" + Write-Host " avg_consumption = $($s.avg_consumption) (expect ~6.4 = 32/500*100)" + Write-Host " cost_per_km = $($s.cost_per_km) (expect ~0.512 = 256/500)" + if ($s.total_records -ne 3) { Write-Host 'FAIL count'; exit 1 } + if ([math]::Abs([double]$s.total_liters - 82) -gt 0.5) { Write-Host 'FAIL liters'; exit 1 } + if ([math]::Abs([double]$s.total_amount - 656) -gt 0.5) { Write-Host 'FAIL amount'; exit 1 } + if ([math]::Abs([double]$s.avg_price - 8.0) -gt 0.1) { Write-Host 'FAIL avg_price'; exit 1 } + if ([math]::Abs([double]$s.avg_consumption - 6.4) -gt 0.5) { Write-Host 'FAIL consumption'; exit 1 } + if ([math]::Abs([double]$s.cost_per_km - 0.512) -gt 0.05) { Write-Host 'FAIL cost_per_km'; exit 1 } +} + +# 9) list refuels +Step 'list refuels' { + $r = Hit GET "/api/refuels?vehicle_id=$($vehicle.id)" $null + Check 'status' $r.Status 200 + $list = $r.Body | ConvertFrom-Json + Write-Host " count = $($list.Count) (expect 3)" + if ($list.Count -ne 3) { Write-Host 'FAIL count'; exit 1 } +} + +# 10) delete refuel +Step 'delete refuel #1' { + $r = Hit DELETE "/api/refuels/$($refuel1.id)" $null + Check 'status' $r.Status 204 +} + +# 11) statistics after delete +Step 'statistics after delete' { + $r = Hit GET "/api/vehicles/$($vehicle.id)/statistics" $null + Check 'status' $r.Status 200 + $s = $r.Body | ConvertFrom-Json + Write-Host " total_records = $($s.total_records) (expect 2)" + if ($s.total_records -ne 2) { Write-Host 'FAIL count'; exit 1 } +} + +# 12) delete vehicle +Step 'delete vehicle' { + $r = Hit DELETE "/api/vehicles/$($vehicle.id)" $null + Check 'status' $r.Status 204 +} + +# 13) get deleted -> 404 +Step 'get deleted vehicle' { + $r = Hit GET "/api/vehicles/$($vehicle.id)" $null + Check 'status' $r.Status 404 + Write-Host " body: $($r.Body)" +} + +Write-Host '' +Write-Host 'ALL SMOKE TESTS PASSED' -ForegroundColor Green