diff --git a/fuellog-api/go.mod b/fuellog-api/go.mod index de1e017..7ee1ba6 100644 --- a/fuellog-api/go.mod +++ b/fuellog-api/go.mod @@ -4,7 +4,10 @@ go 1.26.0 require ( github.com/gin-gonic/gin v1.10.0 + github.com/glebarez/sqlite v1.11.0 github.com/joho/godotenv v1.5.1 + github.com/shopspring/decimal v1.4.0 + gorm.io/gorm v1.31.2 ) require ( @@ -12,12 +15,17 @@ require ( github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -25,6 +33,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect @@ -34,4 +43,8 @@ require ( golang.org/x/text v0.42.0 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect ) diff --git a/fuellog-api/go.sum b/fuellog-api/go.sum index b456a6b..da088f1 100644 --- a/fuellog-api/go.sum +++ b/fuellog-api/go.sum @@ -68,6 +68,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= diff --git a/fuellog-api/internal/dto/dto.go b/fuellog-api/internal/dto/dto.go new file mode 100644 index 0000000..52b0961 --- /dev/null +++ b/fuellog-api/internal/dto/dto.go @@ -0,0 +1,61 @@ +// Package dto 持有 HTTP 入参与返回 DTO。 +// +// 与 model 的区别: +// - model 反映数据库一行;DTO 反映一次 HTTP 请求/响应 +// - DTO 字段做基础校验(必填 / 范围) +// - 字段命名贴近用户语言(中文含义在 JSON tag 上保持英文) +package dto + +import "time" + +// VehicleCreate 新建车辆请求。 +type VehicleCreate struct { + Name string `json:"name" binding:"required,max=64"` + Brand string `json:"brand" binding:"max=64"` + Model string `json:"model" binding:"max=64"` + PlateNumber string `json:"plate_number" binding:"max=32"` + FuelType string `json:"fuel_type" binding:"required,max=32"` + TankCapacity float64 `json:"tank_capacity" binding:"gte=0,lte=9999"` + CurrentOdometer float64 `json:"current_odometer" binding:"gte=0"` + DefaultVehicle bool `json:"default_vehicle"` +} + +// VehicleUpdate 更新车辆请求。所有字段可选(零值表示"不改")。 +// 这里采用 patch 语义:仅传了的字段会被更新。 +type VehicleUpdate struct { + Name *string `json:"name"` + Brand *string `json:"brand"` + Model *string `json:"model"` + PlateNumber *string `json:"plate_number"` + FuelType *string `json:"fuel_type"` + TankCapacity *float64 `json:"tank_capacity"` + CurrentOdometer *float64 `json:"current_odometer"` + DefaultVehicle *bool `json:"default_vehicle"` +} + +// RefuelCreate 新建加油记录请求。 +// 实际油价(price_per_liter)由服务端实时计算,不入参。 +type RefuelCreate struct { + VehicleID uint64 `json:"vehicle_id" binding:"required"` + RefuelTime time.Time `json:"refuel_time" binding:"required"` + Odometer float64 `json:"odometer" binding:"required,gte=0"` + FuelType string `json:"fuel_type" binding:"required,max=32"` + Liters float64 `json:"liters" binding:"required,gt=0"` + TotalAmount float64 `json:"total_amount" binding:"gte=0"` + StationName string `json:"station_name" binding:"max=128"` + IsFull bool `json:"is_full"` + Remark string `json:"remark" binding:"max=512"` +} + +// RefuelUpdate 更新加油记录请求。 +// 同 VehicleUpdate,patch 语义;Liters / TotalAmount 任一变化都触发 price 重新计算。 +type RefuelUpdate struct { + RefuelTime *time.Time `json:"refuel_time"` + Odometer *float64 `json:"odometer"` + FuelType *string `json:"fuel_type"` + Liters *float64 `json:"liters"` + TotalAmount *float64 `json:"total_amount"` + StationName *string `json:"station_name"` + IsFull *bool `json:"is_full"` + Remark *string `json:"remark"` +} diff --git a/fuellog-api/internal/service/refuel_record.go b/fuellog-api/internal/service/refuel_record.go new file mode 100644 index 0000000..8ef2cfe --- /dev/null +++ b/fuellog-api/internal/service/refuel_record.go @@ -0,0 +1,208 @@ +package service + +import ( + "context" + "strings" + "time" + + "github.com/kennethcheng/fuellog-api/internal/dto" + "github.com/kennethcheng/fuellog-api/internal/model" + "github.com/kennethcheng/fuellog-api/internal/repository" + "github.com/kennethcheng/fuellog-api/pkg/apperror" + money "github.com/kennethcheng/fuellog-api/pkg/decimal" +) + +// RefuelRecordService 加油记录业务逻辑。 +type RefuelRecordService interface { + Create(ctx context.Context, in *dto.RefuelCreate) (*model.RefuelRecord, error) + Get(ctx context.Context, id uint64) (*model.RefuelRecord, error) + Update(ctx context.Context, id uint64, in *dto.RefuelUpdate) (*model.RefuelRecord, error) + Delete(ctx context.Context, id uint64) error + ListByVehicle(ctx context.Context, vehicleID uint64, start, end *time.Time) ([]model.RefuelRecord, error) + // Statistics 返回单车的关键统计指标(供 Dashboard 使用)。 + Statistics(ctx context.Context, vehicleID uint64) (*RefuelStatistics, error) +} + +func NewRefuelRecordService( + recRepo repository.RefuelRecordRepository, + vehicleRepo repository.VehicleRepository, +) RefuelRecordService { + return &refuelService{recRepo: recRepo, vehicleRepo: vehicleRepo} +} + +type refuelService struct { + recRepo repository.RefuelRecordRepository + vehicleRepo repository.VehicleRepository +} + +// RefuelStatistics 单车统计指标。 +type RefuelStatistics struct { + VehicleID uint64 `json:"vehicle_id"` + TotalRecords int `json:"total_records"` + TotalLiters float64 `json:"total_liters"` + TotalAmount float64 `json:"total_amount"` + AvgPrice float64 `json:"avg_price"` // 实际平均油价 + AvgConsumption float64 `json:"avg_consumption"` // 标准百公里油耗(仅 is_full=true 序列) + CostPerKm float64 `json:"cost_per_km"` // 每公里成本 + LastRefuel *model.RefuelRecord `json:"last_refuel,omitempty"` +} + +func (s *refuelService) Create(ctx context.Context, in *dto.RefuelCreate) (*model.RefuelRecord, error) { + // 校验车辆存在 + if _, err := s.vehicleRepo.Get(ctx, in.VehicleID); err != nil { + return nil, err + } + if in.Liters <= 0 { + return nil, apperror.New(apperror.CodeInvalidArg, "加油量必须大于 0") + } + if in.TotalAmount < 0 { + return nil, apperror.New(apperror.CodeInvalidArg, "总金额不能小于 0") + } + // 时区规整:统一转 UTC 存库 + t := in.RefuelTime.UTC() + if t.IsZero() { + return nil, apperror.New(apperror.CodeInvalidArg, "加油时间不能为空") + } + + rec := &model.RefuelRecord{ + VehicleID: in.VehicleID, + RefuelTime: t, + Odometer: in.Odometer, + FuelType: strings.TrimSpace(in.FuelType), + Liters: in.Liters, + PricePerLiter: money.PricePerLiter(in.TotalAmount, in.Liters), + TotalAmount: in.TotalAmount, + StationName: strings.TrimSpace(in.StationName), + IsFull: in.IsFull, + Remark: in.Remark, + } + if err := s.recRepo.Create(ctx, rec); err != nil { + return nil, err + } + return rec, nil +} + +func (s *refuelService) Get(ctx context.Context, id uint64) (*model.RefuelRecord, error) { + return s.recRepo.Get(ctx, id) +} + +func (s *refuelService) Update(ctx context.Context, id uint64, in *dto.RefuelUpdate) (*model.RefuelRecord, error) { + rec, err := s.recRepo.Get(ctx, id) + if err != nil { + return nil, err + } + if in.RefuelTime != nil { + rec.RefuelTime = in.RefuelTime.UTC() + } + if in.Odometer != nil { + rec.Odometer = *in.Odometer + } + if in.FuelType != nil { + rec.FuelType = strings.TrimSpace(*in.FuelType) + } + if in.Liters != nil { + if *in.Liters <= 0 { + return nil, apperror.New(apperror.CodeInvalidArg, "加油量必须大于 0") + } + rec.Liters = *in.Liters + } + if in.TotalAmount != nil { + if *in.TotalAmount < 0 { + return nil, apperror.New(apperror.CodeInvalidArg, "总金额不能小于 0") + } + rec.TotalAmount = *in.TotalAmount + } + if in.StationName != nil { + rec.StationName = strings.TrimSpace(*in.StationName) + } + if in.IsFull != nil { + rec.IsFull = *in.IsFull + } + if in.Remark != nil { + rec.Remark = *in.Remark + } + // 实际油价随 liters / total_amount 变化 + rec.PricePerLiter = money.PricePerLiter(rec.TotalAmount, rec.Liters) + + if err := s.recRepo.Update(ctx, rec); err != nil { + return nil, err + } + return rec, nil +} + +func (s *refuelService) Delete(ctx context.Context, id uint64) error { + return s.recRepo.Delete(ctx, id) +} + +func (s *refuelService) ListByVehicle(ctx context.Context, vehicleID uint64, start, end *time.Time) ([]model.RefuelRecord, error) { + if _, err := s.vehicleRepo.Get(ctx, vehicleID); err != nil { + return nil, err + } + return s.recRepo.ListByVehicle(ctx, vehicleID, start, end) +} + +func (s *refuelService) Statistics(ctx context.Context, vehicleID uint64) (*RefuelStatistics, error) { + if _, err := s.vehicleRepo.Get(ctx, vehicleID); err != nil { + return nil, err + } + records, err := s.recRepo.ListByVehicle(ctx, vehicleID, nil, nil) + if err != nil { + return nil, err + } + stats := &RefuelStatistics{VehicleID: vehicleID, TotalRecords: len(records)} + if len(records) == 0 { + return stats, nil + } + + var ( + totalLiters float64 + totalAmount float64 + prices []float64 + consumptions []float64 + costPerKmSample []float64 + ) + + // ListByVehicle 默认按 refuel_time DESC;遍历一次累加,同时算油耗序列 + // 标准油耗只在"连续两次 is_full=true"之间计算 + var prevFull *model.RefuelRecord + for i := range records { + r := records[i] + totalLiters += r.Liters + totalAmount += r.TotalAmount + prices = append(prices, r.PricePerLiter) + + if r.IsFull { + if prevFull != nil { + mileage := money.Mileage(r.Odometer, prevFull.Odometer) + cons := money.ConsumptionL100km(r.Liters, mileage) + cost := money.CostPerKm(r.TotalAmount, mileage) + if cons > 0 { + consumptions = append(consumptions, cons) + } + if cost > 0 { + costPerKmSample = append(costPerKmSample, cost) + } + } + // 浅拷贝保留 prev + cp := r + prevFull = &cp + } + } + + stats.TotalLiters = round2(totalLiters) + stats.TotalAmount = round2(totalAmount) + stats.AvgPrice = money.AvgFloat64(prices) + stats.AvgConsumption = money.AvgFloat64(consumptions) + stats.CostPerKm = money.AvgFloat64(costPerKmSample) + + // 最近一次加油:列表已按时间倒序,第一条即最近 + last := records[0] + stats.LastRefuel = &last + return stats, nil +} + +func round2(x float64) float64 { + // 简单两位四舍五入,避免 import math 而本文件只用一次 + // 实际更推荐用 shopspring/decimal.Decimal,这里精度损失可接受 + return float64(int64(x*100+0.5)) / 100 +} diff --git a/fuellog-api/internal/service/vehicle.go b/fuellog-api/internal/service/vehicle.go new file mode 100644 index 0000000..9826a49 --- /dev/null +++ b/fuellog-api/internal/service/vehicle.go @@ -0,0 +1,171 @@ +// Package service 持有业务逻辑,只依赖 repository 接口,不直接碰 GORM。 +// +// 计算类逻辑(如油价/油耗/成本)放这里,不放在 handler,便于单测。 +package service + +import ( + "context" + "strings" + + "gorm.io/gorm" + + "github.com/kennethcheng/fuellog-api/internal/dto" + "github.com/kennethcheng/fuellog-api/internal/model" + "github.com/kennethcheng/fuellog-api/internal/repository" + "github.com/kennethcheng/fuellog-api/pkg/apperror" +) + +// VehicleService 车辆业务逻辑。 +type VehicleService interface { + Create(ctx context.Context, in *dto.VehicleCreate) (*model.Vehicle, error) + Get(ctx context.Context, id uint64) (*model.Vehicle, error) + List(ctx context.Context) ([]model.Vehicle, error) + Update(ctx context.Context, id uint64, in *dto.VehicleUpdate) (*model.Vehicle, error) + Delete(ctx context.Context, id uint64) error + SetDefault(ctx context.Context, id uint64) (*model.Vehicle, error) +} + +func NewVehicleService(repo repository.VehicleRepository, db *gorm.DB) VehicleService { + return &vehicleService{repo: repo, db: db} +} + +type vehicleService struct { + repo repository.VehicleRepository + db *gorm.DB +} + +func (s *vehicleService) Create(ctx context.Context, in *dto.VehicleCreate) (*model.Vehicle, error) { + v := &model.Vehicle{ + Name: strings.TrimSpace(in.Name), + Brand: strings.TrimSpace(in.Brand), + Model: strings.TrimSpace(in.Model), + PlateNumber: strings.TrimSpace(in.PlateNumber), + FuelType: strings.TrimSpace(in.FuelType), + TankCapacity: in.TankCapacity, + CurrentOdometer: in.CurrentOdometer, + DefaultVehicle: in.DefaultVehicle, + } + if v.Name == "" { + return nil, apperror.New(apperror.CodeInvalidArg, "车辆名称不能为空") + } + if v.FuelType == "" { + return nil, apperror.New(apperror.CodeInvalidArg, "能源类型不能为空") + } + + // 任何显式设为默认的车辆,都要先清掉其它默认(在事务里完成) + if v.DefaultVehicle { + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := s.repo.ClearDefault(ctx, tx); err != nil { + return err + } + if err := tx.WithContext(ctx).Create(v).Error; err != nil { + return apperror.Wrap(apperror.CodeInternal, "创建车辆失败", err) + } + return nil + }) + if err != nil { + if appErr, ok := apperror.As(err); ok { + return nil, appErr + } + return nil, err + } + return v, nil + } + + if err := s.repo.Create(ctx, v); err != nil { + return nil, err + } + return v, nil +} + +func (s *vehicleService) Get(ctx context.Context, id uint64) (*model.Vehicle, error) { + return s.repo.Get(ctx, id) +} + +func (s *vehicleService) List(ctx context.Context) ([]model.Vehicle, error) { + return s.repo.List(ctx) +} + +func (s *vehicleService) Update(ctx context.Context, id uint64, in *dto.VehicleUpdate) (*model.Vehicle, error) { + v, err := s.repo.Get(ctx, id) + if err != nil { + return nil, err + } + if in.Name != nil { + v.Name = strings.TrimSpace(*in.Name) + } + if in.Brand != nil { + v.Brand = strings.TrimSpace(*in.Brand) + } + if in.Model != nil { + v.Model = strings.TrimSpace(*in.Model) + } + if in.PlateNumber != nil { + v.PlateNumber = strings.TrimSpace(*in.PlateNumber) + } + if in.FuelType != nil { + v.FuelType = strings.TrimSpace(*in.FuelType) + } + if in.TankCapacity != nil { + v.TankCapacity = *in.TankCapacity + } + if in.CurrentOdometer != nil { + v.CurrentOdometer = *in.CurrentOdometer + } + if v.Name == "" { + return nil, apperror.New(apperror.CodeInvalidArg, "车辆名称不能为空") + } + if v.FuelType == "" { + return nil, apperror.New(apperror.CodeInvalidArg, "能源类型不能为空") + } + + // 切换默认车辆同样要在事务里:先清默认,再置新默认 + if in.DefaultVehicle != nil && *in.DefaultVehicle && !v.DefaultVehicle { + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := s.repo.ClearDefault(ctx, tx); err != nil { + return err + } + v.DefaultVehicle = true + if err := tx.WithContext(ctx).Save(v).Error; err != nil { + return apperror.Wrap(apperror.CodeInternal, "更新车辆失败", err) + } + return nil + }) + if err != nil { + if appErr, ok := apperror.As(err); ok { + return nil, appErr + } + return nil, err + } + return v, nil + } + + if err := s.repo.Update(ctx, v); err != nil { + return nil, err + } + return v, nil +} + +func (s *vehicleService) Delete(ctx context.Context, id uint64) error { + return s.repo.Delete(ctx, id) +} + +func (s *vehicleService) SetDefault(ctx context.Context, id uint64) (*model.Vehicle, error) { + // 校验存在 + if _, err := s.repo.Get(ctx, id); err != nil { + return nil, err + } + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := s.repo.ClearDefault(ctx, tx); err != nil { + return err + } + return s.repo.SetDefault(ctx, tx, id) + }) + if err != nil { + if appErr, ok := apperror.As(err); ok { + return nil, appErr + } + return nil, err + } + return s.repo.Get(ctx, id) +} diff --git a/fuellog-api/pkg/decimal/money.go b/fuellog-api/pkg/decimal/money.go new file mode 100644 index 0000000..89a79e7 --- /dev/null +++ b/fuellog-api/pkg/decimal/money.go @@ -0,0 +1,69 @@ +// Package decimal 提供金额与油耗计算相关的精度保护。 +// +// 计算原则(严格按 FuelLog_PLAN.md §6): +// - 实际油价 = 总金额 / 加油量 +// - 行驶里程 = 本次里程 - 上一次有效里程 +// - 百公里油耗 = 加油量 / 行驶里程 * 100 +// - 每公里燃油成本 = 燃油费用 / 行驶里程 +// +// 所有除法走 shopspring/decimal,避免 float64 在金额上累计误差。 +package decimal + +import ( + "github.com/shopspring/decimal" +) + +// PricePerLiter 计算实际油价(元/升)。 +// 若 liters <= 0,返回零值;调用方应保证 liters > 0(后端已强校验)。 +func PricePerLiter(totalAmount, liters float64) float64 { + if liters <= 0 { + return 0 + } + t := decimal.NewFromFloat(totalAmount) + l := decimal.NewFromFloat(liters) + return t.Div(l).Round(4).InexactFloat64() +} + +// ConsumptionL100km 计算百公里油耗(升/100 公里)。 +// 若 mileage <= 0,返回 0;调用方需保证 mileage > 0(半箱油场景应在 service 跳过)。 +func ConsumptionL100km(liters, mileage float64) float64 { + if mileage <= 0 { + return 0 + } + l := decimal.NewFromFloat(liters) + m := decimal.NewFromFloat(mileage) + // L/100km = liters / mileage * 100 + return l.Div(m).Mul(decimal.NewFromInt(100)).Round(2).InexactFloat64() +} + +// CostPerKm 计算每公里燃油成本(元/公里)。 +// 若 mileage <= 0,返回 0。 +func CostPerKm(totalAmount, mileage float64) float64 { + if mileage <= 0 { + return 0 + } + t := decimal.NewFromFloat(totalAmount) + m := decimal.NewFromFloat(mileage) + return t.Div(m).Round(4).InexactFloat64() +} + +// Mileage 计算两次加油之间的行驶里程。负数 / 零视为无效,返回 0。 +func Mileage(current, previous float64) float64 { + d := current - previous + if d <= 0 { + return 0 + } + return d +} + +// AvgFloat64 返回一组数的算术平均值,空切片返回 0。 +func AvgFloat64(xs []float64) float64 { + if len(xs) == 0 { + return 0 + } + sum := decimal.Zero + for _, x := range xs { + sum = sum.Add(decimal.NewFromFloat(x)) + } + return sum.Div(decimal.NewFromInt(int64(len(xs)))).Round(2).InexactFloat64() +}