停过各种各样的停车场,每次从停车场出来总是在想,这里面的计费能力是怎么设计的?有没有一种通用的结构能满足市面上几乎所有的停车计费规则,只需更改少量配置即可修改停车策略。本文从业务问题出发,深入分析停车计费这一场景的业务问题,提出了一种高拓展性的解决方案。
文章目录
1.业务分析
不难发现,市面上大多使用的停车计费系统都是购买的三方服务,如宜泊、智泊等,有少量的商城会自己开发停车计费的能力(主要为了对接自家的会员和积分权益),如龙湖天街等。
从这些三方服务的公司考虑,他们追求的就是系统拓展性非常强,多一个购买方,只需修改少量配置即可完成交付,这样成本才是最低的。
我们可以从日常生活中所看到的各种各样的停车计费规则去倒退一下,他们的计费能力,到底是怎样设计的,下面列举一些常见的停车计费规则:
- 1.按次计费。(如 xx 景区,一次 20 块,一般来说也会有有效时期,比如一天算一次,本质来说也是按时间计费,只不过计费的单位比较长)
- 2.时间均等计费:3 元一小时或者 2 元半小时等等。(主要是计费的最小单位不同,有的按 30 分钟计费,有的按 60 分钟计费,还有的按 120 分钟甚至 240 分钟计费)
- 3.时间差异计费:首小时 5 元,后续每 半小时 2 元等等。(主要是有一个首段计费差异)
- 4.分时段收费:白天(8:00-19:00)按首 5 元,后续半小时 2 元,一晚上(8:00-19:00)10 元等等。(主要是分时的策略不同)
- 5.按天计费:停车时间到当日 24:00 为一日。
参考一些图片:
还有一些其他的附属规则,如不满 15 分钟免费,每天封顶 100 元,永久封顶 2000 元等。缴费后 15 分钟内离场不属于停车计费核心能力设计一环,缴费后该段计费已经结束,只需在离场的时候判断是不是在 15 分钟内,超过了重新计费即可。
需要注意的是,这只是分析了一些常见的计费规则,也有一些非常罕见并且和上述差异比较大的一些规则,工作日和节假日计费完全不同,不同等级的会员免费的时长不一样。
这些规则与常见的规则不太相同,如果要想在一个通用的设计中兼容这些规则,会使整个设计变得很复杂,软件设计也需要在拓展性和复杂性之间有所平衡。
我们可以使用通用设计+额外特殊逻辑的方式来解决这些比较少见的规则,比如:
- 1.计费前依据是否是节假日进入不同配置的计费逻辑中。
- 2.计费后依据会员的等级来进行一定费用的减免。
对于不同类型的车辆同样也适用:如蓝牌、绿牌调用的策略不同。
等等还有很多,如果通用设计+额外特殊逻辑的方式仍然无法解决的,那最好的办法是加一种策略的代码,比如在 java 中多增加一个策略的实现类,成本也不会太高,因为需要添加的代码的场景本身就非常之少。
2.核心配置设计
综合上面的分析,实际上第一种情况按次计费可以算入时间均等计费中(24 小时算一次),第二种又可以算入第三种中(首小时的计费规则和后续规则都相同),第三种又可以算入第四种中(白天和晚上的计费规则相同),第五种因为时间维度变为自然日了,有所区别。
需要思考的问题是,是不是要将前四种都统一到一套规则中?
我认为最好不要统一,原因如下:
- 1.统一会使得逻辑太过通用,代码可读性差,遇到问题更难解决。
- 2.这些规则理论上属于不同的实体,当面向用户展示的时候,直接展示不同的计费规则更符合用户的需求。(如果按照一套通用规则使用,则面向用户展示的时候还需要转换成更易理解的文字计费规则)
那么我们可以将上述的五套规则中的核心变量提取出来:
- 1.按次计费。核心变量是单次有效期和单次费用。
- 2.时间均等计费。核心变量是计费单位和单位费用。
- 3.时间差异计费。核心变量是首次计费单位、首次费用、后续计费单位、后续费用。(市面上大部分都只有这两个差异,如果有更多差异,需要额外增加一种策略。)
- 4.分时段收费。核心变量是分时段 1(例如白天)、分时段 1 计费规则(上述 2 或3)、分时段 2(例如晚上)、分时段 2 计费规则(上述 2 或 3)。
- 5.自然日收费。核心变量是单自然日费用。
- 其余统一的核心变量是免费时长、每日封顶费用、永久封顶费用。
核心配置如下:
{
"billingType": "time", // 计费类型,可选 "perEntry", "time", "variableTime", "timePeriod", "daily"
"perEntryConfig": {
"singleEntryDurationMinutes": 1440, // 单次有效期,单位为分钟,1440 表示1天
"singleEntryFee": 20.0 // 单次费用
},
"timeConfig": {
"billingUnitMinutes": 60, // 计费单位,例如 60 表示每小时
"unitFee": 3.0 // 每单位费用
},
"variableTimeConfig": {
"firstUnitMinutes": 60, // 首次计费单位,例如 60 表示首小时
"firstUnitFee": 5.0, // 首次计费的费用
"subsequentUnitMinutes": 30, // 后续计费单位,例如 30 表示每半小时
"subsequentUnitFee": 2.0 // 后续计费的费用
},
"timePeriodConfig": {
"periods": [
{
"startTime": "08:00", // 分时段开始时间
"endTime": "19:00", // 分时段结束时间
"billingType": "variableTime", // 该时段的计费类型,可选 "time" 或 "variableTime"
"billingConfig": { // 时段内的计费配置,根据类型而定
"firstUnitMinutes": 60,
"firstUnitFee": 5.0,
"subsequentUnitMinutes": 30,
"subsequentUnitFee": 2.0
}
},
{
"startTime": "19:00",
"endTime": "08:00",
"billingType": "time",
"billingConfig": {
"billingUnitMinutes": 60,
"unitFee": 10.0
}
}
]
},
"dailyConfig": {
"dailyFee": 50.0 // 每天封顶费用
},
"additionalRules": {
"freeMinutes": 15, // 免费时长
"dailyCap": 100.0, // 每日封顶费用
"totalCap": 2000.0 // 永久封顶费用
}
}
3.代码实现
我们可以搭配策略模式和装饰器模式将上述的核心配置进行落地实现。这里配置从 json 文件中读取,实际业务中应该是从配置中心拉取。
3.1 停车计费策略接口
/**
* @ClassName: ParkingFeeStrategy
* @description: 停车计费策略接口
* @author: atfwus
* @date: 2024.10.22
* @version: 1.0
*/
public interface ParkingFeeStrategy {
BigDecimal calculateFee(LocalDateTime entryTime, LocalDateTime exitTime);
}
3.2 按次计费策略
/**
* @ClassName: PerEntryRateStrategy
* @description: 按次计费策略
* @author: atfwus
* @date: 2024.10.22
* @version: 1.0
*/
import java.math.BigDecimal;
import java.time.Duration;
import java.time.LocalDateTime;
public class PerEntryRateStrategy implements ParkingFeeStrategy {
private final long validDurationMinutes;
private final BigDecimal perEntryFee;
public PerEntryRateStrategy(long validDurationMinutes, BigDecimal perEntryFee) {
this.validDurationMinutes = validDurationMinutes;
this.perEntryFee = perEntryFee;
}
@Override
public BigDecimal calculateFee(LocalDateTime entryTime, LocalDateTime exitTime) {
long totalMinutes = Duration.between(entryTime, exitTime).toMinutes();
long entries = (totalMinutes / validDurationMinutes) + 1; // 超过一个有效期按多次计算
return perEntryFee.multiply(BigDecimal.valueOf(entries));
}
}
3.3 时间均等计费策略
/**
* @ClassName: FixedIntervalRateStrategy
* @description: 时间均等计费策略
* @author: atfwus
* @date: 2024.10.22
* @version: 1.0
*/
import java.math.BigDecimal;
import java.time.Duration;
import java.time.LocalDateTime;
public class FixedIntervalRateStrategy implements ParkingFeeStrategy {
private final BigDecimal unitFee;
private final long unitDurationMinutes;
public FixedIntervalRateStrategy(BigDecimal unitFee, long unitDurationMinutes) {
this.unitFee = unitFee;
this.unitDurationMinutes = unitDurationMinutes;
}
@Override
public BigDecimal calculateFee(LocalDateTime entryTime, LocalDateTime exitTime) {
long totalMinutes = Duration.between(entryTime, exitTime).toMinutes();
long units = (totalMinutes + unitDurationMinutes - 1) / unitDurationMinutes; // 向上取整
return unitFee.multiply(BigDecimal.valueOf(units));
}
}
3.4 时间差异计费策略
/**
* @ClassName: VariableRateStrategy
* @description: 时间差异计费策略
* @author: atfwus
* @date: 2024.10.22
* @version: 1.0
*/
import java.math.BigDecimal;
import java.time.Duration;
import java.time.LocalDateTime;
public class VariableRateStrategy implements ParkingFeeStrategy {
private final BigDecimal firstUnitFee;
private final BigDecimal subsequentUnitFee;
private final long firstUnitMinutes;
private final long subsequentUnitMinutes;
public VariableRateStrategy(BigDecimal firstUnitFee, BigDecimal subsequentUnitFee, long firstUnitMinutes, long subsequentUnitMinutes) {
this.firstUnitFee = firstUnitFee;
this.subsequentUnitFee = subsequentUnitFee;
this.firstUnitMinutes = firstUnitMinutes;
this.subsequentUnitMinutes = subsequentUnitMinutes;
}
@Override
public BigDecimal calculateFee(LocalDateTime entryTime, LocalDateTime exitTime) {
long totalMinutes = Duration.between(entryTime, exitTime).toMinutes();
if (totalMinutes <= firstUnitMinutes) {
return firstUnitFee;
}
long remainingMinutes = totalMinutes - firstUnitMinutes;
long subsequentUnits = (remainingMinutes + subsequentUnitMinutes - 1) / subsequentUnitMinutes; // 向上取整
return firstUnitFee.add(subsequentUnitFee.multiply(BigDecimal.valueOf(subsequentUnits)));
}
}
3.5 分时段计费
/**
* @ClassName: TimePeriodRateStrategy
* @description: 分时段计费策略
* @author: atfwus
* @date: 2024.10.22
* @version: 1.0
*/
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List;
public class TimePeriodRateStrategy implements ParkingFeeStrategy {
private final List<TimePeriod> timePeriods;
public TimePeriodRateStrategy(List<TimePeriod> timePeriods) {
this.timePeriods = timePeriods;
}
@Override
public BigDecimal calculateFee(LocalDateTime entryTime, LocalDateTime exitTime) {
BigDecimal totalFee = BigDecimal.ZERO;
LocalDateTime currentTime = entryTime;
// 按时段计算停车费用
while (currentTime.isBefore(exitTime)) {
for (TimePeriod period : timePeriods) {
if (period.isWithinPeriod(currentTime.toLocalTime())) {
// 计算当前时段的结束时间
LocalDateTime periodEndTime = currentTime.with(period.getEndTime());
if (periodEndTime.isAfter(exitTime)) {
periodEndTime = exitTime;
}
// 计算该时段内的费用
BigDecimal feeForPeriod = period.getBillingStrategy().calculateFee(currentTime, periodEndTime);
totalFee = totalFee.add(feeForPeriod);
currentTime = periodEndTime;
break;
}
}
}
return totalFee;
}
// 内部类定义时段和对应的计费规则
public static class TimePeriod {
private final LocalTime startTime;
private final LocalTime endTime;
private final ParkingFeeStrategy billingStrategy;
public TimePeriod(LocalTime startTime, LocalTime endTime, ParkingFeeStrategy billingStrategy) {
this.startTime = startTime;
this.endTime = endTime;
this.billingStrategy = billingStrategy;
}
public boolean isWithinPeriod(LocalTime time) {
if (startTime.isBefore(endTime)) {
return time.isAfter(startTime) && time.isBefore(endTime);
} else {
// 跨天情况,例如 20:00 到次日 08:00
return time.isAfter(startTime) || time.isBefore(endTime);
}
}
public LocalTime getEndTime() {
return endTime;
}
public ParkingFeeStrategy getBillingStrategy() {
return billingStrategy;
}
}
}
3.6 自然日计费策略
/**
* @ClassName: PerDayRateStrategy
* @description: 自然日计费策略
* @author: atfwus
* @date: 2024.10.22
* @version: 1.0
*/
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
public class PerDayRateStrategy implements ParkingFeeStrategy {
private final BigDecimal dailyRate;
public PerDayRateStrategy(BigDecimal dailyRate) {
this.dailyRate = dailyRate;
}
@Override
public BigDecimal calculateFee(LocalDateTime entryTime, LocalDateTime exitTime) {
long days = ChronoUnit.DAYS.between(entryTime.toLocalDate(), exitTime.toLocalDate());
return dailyRate.multiply(BigDecimal.valueOf(days + 1)); // +1 是因为跨天停车也算一天
}
}
3.7 免费时长装饰器
/**
* @ClassName: FreeMinutesDecorator
* @description: 免费时长装饰器
* @author: atfwus
* @date: 2024.10.22
* @version: 1.0
*/
import java.math.BigDecimal;
import java.time.Duration;
import java.time.LocalDateTime;
public class FreeMinutesDecorator implements ParkingFeeStrategy {
private final ParkingFeeStrategy wrappedStrategy;
private final long freeMinutes;
public FreeMinutesDecorator(ParkingFeeStrategy wrappedStrategy, long freeMinutes) {
this.wrappedStrategy = wrappedStrategy;
this.freeMinutes = freeMinutes;
}
@Override
public BigDecimal calculateFee(LocalDateTime entryTime, LocalDateTime exitTime) {
long totalMinutes = Duration.between(entryTime, exitTime).toMinutes();
if (totalMinutes <= freeMinutes) {
return BigDecimal.ZERO;
}
return wrappedStrategy.calculateFee(entryTime, exitTime);
}
}
3.8 每日封顶装饰器
/**
* @ClassName: DailyCappedFeeDecorator
* @description: 每日封顶装饰器
* @author: atfwus
* @date: 2024.10.22
* @version: 1.0
*/
import java.math.BigDecimal;
import java.time.LocalDateTime;
public class DailyCappedFeeDecorator implements ParkingFeeStrategy {
private final ParkingFeeStrategy wrappedStrategy;
private final BigDecimal dailyCap;
public DailyCappedFeeDecorator(ParkingFeeStrategy wrappedStrategy, BigDecimal dailyCap) {
this.wrappedStrategy = wrappedStrategy;
this.dailyCap = dailyCap;
}
@Override
public BigDecimal calculateFee(LocalDateTime entryTime, LocalDateTime exitTime) {
BigDecimal fee = wrappedStrategy.calculateFee(entryTime, exitTime);
return fee.min(dailyCap);
}
}
3.9 总封顶装饰器
/**
* @ClassName: TotalCappedFeeDecorator
* @description: 总封顶装饰器
* @author: atfwus
* @date: 2024.10.22
* @version: 1.0
*/
import java.math.BigDecimal;
import java.time.LocalDateTime;
public class TotalCappedFeeDecorator implements ParkingFeeStrategy {
private final ParkingFeeStrategy wrappedStrategy;
private final BigDecimal totalCap;
public TotalCappedFeeDecorator(ParkingFeeStrategy wrappedStrategy, BigDecimal totalCap) {
this.wrappedStrategy = wrappedStrategy;
this.totalCap = totalCap;
}
@Override
public BigDecimal calculateFee(LocalDateTime entryTime, LocalDateTime exitTime) {
BigDecimal fee = wrappedStrategy.calculateFee(entryTime, exitTime);
return fee.min(totalCap);
}
}
3.10 停车计费配置
/**
* @ClassName: ParkingConfig
* @description: 停车计费配置
* @author: atfwus
* @date: 2024.10.22
* @version: 1.0
*/
import java.math.BigDecimal;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.List;
public class ParkingConfig {
public String billingType; // "perEntry", "time", "variableTime", "timePeriod", "daily"
public PerEntryConfig perEntryConfig;
public TimeConfig timeConfig;
public VariableTimeConfig variableTimeConfig;
public TimePeriodConfig timePeriodConfig;
public DailyConfig dailyConfig;
public AdditionalRules additionalRules;
// 根据配置构建相应的计费策略
public ParkingFeeStrategy buildStrategy() {
ParkingFeeStrategy strategy = null;
switch (billingType) {
case "perEntry":
strategy = new PerEntryRateStrategy(
perEntryConfig.singleEntryDurationMinutes,
BigDecimal.valueOf(perEntryConfig.singleEntryFee)
);
break;
case "time":
strategy = new FixedIntervalRateStrategy(
BigDecimal.valueOf(timeConfig.unitFee),
timeConfig.billingUnitMinutes
);
break;
case "variableTime":
strategy = new VariableRateStrategy(
BigDecimal.valueOf(variableTimeConfig.firstUnitFee),
BigDecimal.valueOf(variableTimeConfig.subsequentUnitFee),
variableTimeConfig.firstUnitMinutes,
variableTimeConfig.subsequentUnitMinutes
);
break;
case "timePeriod":
List<TimePeriodRateStrategy.TimePeriod> timePeriods = new ArrayList<>();
for (TimePeriodConfig.Period period : timePeriodConfig.periods) {
ParkingFeeStrategy periodStrategy;
if ("time".equals(period.billingType)) {
periodStrategy = new FixedIntervalRateStrategy(
BigDecimal.valueOf(period.billingConfig.unitFee),
period.billingConfig.billingUnitMinutes
);
} else {
periodStrategy = new VariableRateStrategy(
BigDecimal.valueOf(period.billingConfig.firstUnitFee),
BigDecimal.valueOf(period.billingConfig.subsequentUnitFee),
period.billingConfig.firstUnitMinutes,
period.billingConfig.subsequentUnitMinutes
);
}
timePeriods.add(new TimePeriodRateStrategy.TimePeriod(
LocalTime.parse(period.startTime),
LocalTime.parse(period.endTime),
periodStrategy
));
}
strategy = new TimePeriodRateStrategy(timePeriods);
break;
case "daily":
strategy = new PerDayRateStrategy(
BigDecimal.valueOf(dailyConfig.dailyFee)
);
break;
}
// 应用附加规则
if (strategy != null) {
if (additionalRules != null) {
if (additionalRules.freeMinutes > 0) {
strategy = new FreeMinutesDecorator(strategy, additionalRules.freeMinutes);
}
if (additionalRules.dailyCap != null) {
strategy = new DailyCappedFeeDecorator(strategy, BigDecimal.valueOf(additionalRules.dailyCap));
}
if (additionalRules.totalCap != null) {
strategy = new TotalCappedFeeDecorator(strategy, BigDecimal.valueOf(additionalRules.totalCap));
}
}
}
return strategy;
}
// 内部类用于解析 JSON 配置
public static class PerEntryConfig {
public long singleEntryDurationMinutes;
public double singleEntryFee;
}
public static class TimeConfig {
public long billingUnitMinutes;
public double unitFee;
}
public static class VariableTimeConfig {
public long firstUnitMinutes;
public double firstUnitFee;
public long subsequentUnitMinutes;
public double subsequentUnitFee;
}
public static class TimePeriodConfig {
public List<Period> periods;
public static class Period {
public String startTime;
public String endTime;
public String billingType;
public BillingConfig billingConfig;
}
public static class BillingConfig {
public long billingUnitMinutes;
public double unitFee;
public long firstUnitMinutes;
public double firstUnitFee;
public long subsequentUnitMinutes;
public double subsequentUnitFee;
}
}
public static class DailyConfig {
public double dailyFee;
}
public static class AdditionalRules {
public int freeMinutes;
public Double dailyCap;
public Double totalCap;
}
}
3.11 停车计费配置文件
{
"billingType": "variableTime",
"perEntryConfig": {
"singleEntryDurationMinutes": 1440,
"singleEntryFee": 20.0
},
"timeConfig": {
"billingUnitMinutes": 60,
"unitFee": 3.0
},
"variableTimeConfig": {
"firstUnitMinutes": 60,
"firstUnitFee": 10.0,
"subsequentUnitMinutes": 30,
"subsequentUnitFee": 3.0
},
"timePeriodConfig": {
"periods": [
{
"startTime": "08:00",
"endTime": "19:00",
"billingType": "variableTime",
"billingConfig": {
"firstUnitMinutes": 60,
"firstUnitFee": 5.0,
"subsequentUnitMinutes": 30,
"subsequentUnitFee": 2.0
}
},
{
"startTime": "19:00",
"endTime": "08:00",
"billingType": "time",
"billingConfig": {
"billingUnitMinutes": 60,
"unitFee": 10.0
}
}
]
},
"dailyConfig": {
"dailyFee": 50.0
},
"additionalRules": {
"freeMinutes": 15,
"dailyCap": 100.0,
"totalCap": 2000.0
}
}
3.12 测试
/**
* @ClassName: Main
* @description: 测试
* @author: atfwus
* @date: 2024.10.22
* @version: 1.0
*/
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.File;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public class Main {
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
File jsonFile = new File("parkingConfig.json");
// 解析 JSON 文件
ParkingConfig config = mapper.readValue(jsonFile, ParkingConfig.class);
// 根据配置生成计费策略
ParkingFeeStrategy strategy = config.buildStrategy();
// 测试计费
LocalDateTime entryTime = LocalDateTime.of(2024, 10, 15, 10, 0);
LocalDateTime exitTime = LocalDateTime.of(2024, 10, 15, 18, 0);
BigDecimal fee = strategy.calculateFee(entryTime, exitTime);
System.out.println("总费用: " + fee);
}
}
3.13 测试结果
总费用: 52.0
注意:上述代码未经过完善的单元测试和系统测试,如要用于生产环境,请先进行完善的测试。
4.总结
这种设计的高拓展性在于:
- JSON 配置可以轻松设置和调整不同的计费规则,包括按次、时间均等、时间差异、分时段、按天等各种策略。
- 策略模式和装饰器模式结合使得每种计费策略可以灵活组合,也可以添加额外规则如免费时长、封顶费用等。
ATFWUS 2024-10-22