PAYATHON 2026

C# class design:如何设计 flexible payment rules

支付小周

结论

不要让一个 PaymentRule 同时处理区间判断、计费方式、规则组合和数据库映射。可以把 royalty 拆成三个概念:

  • 适用范围:按 QuantitySold 或 Revenue 划分区间。
  • 计算方式:按收入百分比或销量固定金额计算。
  • 组合方式:同一合同下的多条规则可以叠加,同一组阶梯规则则按区间分别计算。

数据库仍可使用统一的规则表,但领域模型不必与表结构一一对应。这样既方便持久化,也不会让 CalculateRoyaltyDue 充斥越来越多的 if、switch 和类型判断。

先明确阶梯规则的计算语义

“销量达到 1000 后按 12%”可能表示两种不同的计算方式:

  1. 累进阶梯:前 1000 件对应的收入按 15% 计算,超过部分按 12% 计算。
  2. 整体换档:销量超过 1000 件后,全部收入都按 12% 计算。

两种方式的结果完全不同。下面采用较常见的累进阶梯,并作以下假设:

  • Minimum 包含在区间内;
  • Maximum 不包含在区间内,区间写作 [Minimum, Maximum);
  • 按销量分段收入时,系统可以取得每个销量区间对应的收入;
  • 同一 royalty plan 下的独立规则默认可以叠加。

如果系统只有总销量和总收入,却没有各销量区间产生的收入,就无法精确计算“前 1000 件对应收入的 15%”,除非业务允许使用平均单价估算。

领域模型设计

先用枚举代替字符串,也不要用 decimal 表示类型:

public enum RangeBasis
{
    QuantitySold,
    Revenue
}

public enum PaymentBasis
{
    AmountPerUnit,
    RevenuePercentage
}

计算所需的数据可以放在上下文对象中:

public sealed record RoyaltyContext(
    int QuantitySold,
    decimal Revenue,
    IReadOnlyList<SaleLine> Sales);

public sealed record SaleLine(
    int Quantity,
    decimal Revenue);

每条规则只描述一个区间和一种计算方式:

public sealed class RoyaltyRule
{
    public required RangeBasis RangeBasis { get; init; }
    public required PaymentBasis PaymentBasis { get; init; }

    public decimal Minimum { get; init; }
    public decimal? Maximum { get; init; }

    public decimal RateOrAmount { get; init; }

    public decimal Calculate(RoyaltyContext context)
    {
        Validate();

        return (RangeBasis, PaymentBasis) switch
        {
            (RangeBasis.QuantitySold, PaymentBasis.AmountPerUnit)
                => CalculateAmountPerUnit(context),

            (RangeBasis.QuantitySold, PaymentBasis.RevenuePercentage)
                => CalculatePercentageForQuantityRange(context),

            (RangeBasis.Revenue, PaymentBasis.RevenuePercentage)
                => CalculatePercentageForRevenueRange(context),

            _ => throw new NotSupportedException(
                $"Unsupported rule combination: {RangeBasis}/{PaymentBasis}")
        };
    }

    private decimal CalculateAmountPerUnit(RoyaltyContext context)
    {
        decimal unitsInRange = PortionInRange(
            context.QuantitySold,
            Minimum,
            Maximum);

        return unitsInRange * RateOrAmount;
    }

    private decimal CalculatePercentageForQuantityRange(
        RoyaltyContext context)
    {
        decimal revenueInRange = GetRevenueForQuantityRange(
            context.Sales,
            Minimum,
            Maximum);

        return revenueInRange * RateOrAmount;
    }

    private decimal CalculatePercentageForRevenueRange(
        RoyaltyContext context)
    {
        decimal revenueInRange = PortionInRange(
            context.Revenue,
            Minimum,
            Maximum);

        return revenueInRange * RateOrAmount;
    }

    private static decimal PortionInRange(
        decimal total,
        decimal minimum,
        decimal? maximum)
    {
        decimal upper = maximum.HasValue
            ? Math.Min(total, maximum.Value)
            : total;

        return Math.Max(0m, upper - minimum);
    }

    private static decimal GetRevenueForQuantityRange(
        IReadOnlyList<SaleLine> sales,
        decimal minimum,
        decimal? maximum)
    {
        decimal revenue = 0m;
        decimal position = 0m;

        foreach (SaleLine sale in sales)
        {
            decimal lineStart = position;
            decimal lineEnd = position + sale.Quantity;

            decimal overlapStart = Math.Max(lineStart, minimum);
            decimal overlapEnd = Math.Min(
                lineEnd,
                maximum ?? decimal.MaxValue);

            decimal overlappingQuantity =
                Math.Max(0m, overlapEnd - overlapStart);

            if (overlappingQuantity > 0m)
            {
                decimal revenuePerUnit = sale.Revenue / sale.Quantity;
                revenue += overlappingQuantity * revenuePerUnit;
            }

            position = lineEnd;
        }

        return revenue;
    }

    private void Validate()
    {
        if (Minimum < 0m)
            throw new InvalidOperationException(
                "Minimum cannot be negative.");

        if (Maximum.HasValue && Maximum.Value <= Minimum)
            throw new InvalidOperationException(
                "Maximum must be greater than Minimum.");

        if (RateOrAmount < 0m)
            throw new InvalidOperationException(
                "RateOrAmount cannot be negative.");

        if (PaymentBasis == PaymentBasis.RevenuePercentage &&
            RateOrAmount > 1m)
        {
            throw new InvalidOperationException(
                "A percentage must be stored as a fraction, for example 0.15.");
        }
    }
}

这里把 15% 存为 0.15m,而不是 15m。如果数据库或录入界面使用 15,应在系统边界统一转换,避免两种表示方式混用。

使用规则集合表达叠加和阶梯

一个 royalty plan 可以包含多条规则,最终金额为所有适用规则之和:

public sealed class RoyaltyPlan
{
    private readonly List<RoyaltyRule> _rules = new();

    public IReadOnlyCollection<RoyaltyRule> Rules => _rules;

    public void AddRule(RoyaltyRule rule)
    {
        ArgumentNullException.ThrowIfNull(rule);
        _rules.Add(rule);
    }

    public decimal Calculate(RoyaltyContext context)
    {
        return _rules.Sum(rule => rule.Calculate(context));
    }
}

Author B 的规则可以这样表示:

var plan = new RoyaltyPlan();

// 前 1000 件对应收入的 15%
plan.AddRule(new RoyaltyRule
{
    RangeBasis = RangeBasis.QuantitySold,
    PaymentBasis = PaymentBasis.RevenuePercentage,
    Minimum = 0m,
    Maximum = 1000m,
    RateOrAmount = 0.15m
});

// 超过 1000 件部分对应收入的 12%
plan.AddRule(new RoyaltyRule
{
    RangeBasis = RangeBasis.QuantitySold,
    PaymentBasis = PaymentBasis.RevenuePercentage,
    Minimum = 1000m,
    Maximum = null,
    RateOrAmount = 0.12m
});

// 前 1000 件每件额外支付 $1.50
plan.AddRule(new RoyaltyRule
{
    RangeBasis = RangeBasis.QuantitySold,
    PaymentBasis = PaymentBasis.AmountPerUnit,
    Minimum = 0m,
    Maximum = 1000m,
    RateOrAmount = 1.50m
});

Author C 的收入阶梯可以这样表示:

var plan = new RoyaltyPlan();

plan.AddRule(new RoyaltyRule
{
    RangeBasis = RangeBasis.Revenue,
    PaymentBasis = PaymentBasis.RevenuePercentage,
    Minimum = 0m,
    Maximum = 1000m,
    RateOrAmount = 0.15m
});

plan.AddRule(new RoyaltyRule
{
    RangeBasis = RangeBasis.Revenue,
    PaymentBasis = PaymentBasis.RevenuePercentage,
    Minimum = 1000m,
    Maximum = null,
    RateOrAmount = 0.12m
});

收入为 $1,500 时,计算结果如下:

$1,000 × 15% + $500 × 12% = $210

SQL Server 持久化方案

如果规则种类有限,字段也比较稳定,可以使用一张规则表:

CREATE TABLE RoyaltyPlan
(
    RoyaltyPlanId bigint IDENTITY PRIMARY KEY,
    Name nvarchar(200) NOT NULL
);

CREATE TABLE RoyaltyRule
(
    RoyaltyRuleId bigint IDENTITY PRIMARY KEY,
    RoyaltyPlanId bigint NOT NULL,

    RangeBasis tinyint NOT NULL,
    PaymentBasis tinyint NOT NULL,

    RangeMinimum decimal(19, 4) NOT NULL,
    RangeMaximum decimal(19, 4) NULL,
    RateOrAmount decimal(19, 8) NOT NULL,

    Priority int NOT NULL DEFAULT 0,

    CONSTRAINT FK_RoyaltyRule_RoyaltyPlan
        FOREIGN KEY (RoyaltyPlanId)
        REFERENCES RoyaltyPlan(RoyaltyPlanId),

    CONSTRAINT CK_RoyaltyRule_Range
        CHECK (
            RangeMinimum >= 0
            AND (
                RangeMaximum IS NULL
                OR RangeMaximum > RangeMinimum
            )
        ),

    CONSTRAINT CK_RoyaltyRule_Value
        CHECK (RateOrAmount >= 0)
);

数据库中的 RangeMinimum 使用 decimal,是为了同时保存销量边界和金额边界。领域层仍可要求销量规则的边界必须是整数:

if (RangeBasis == RangeBasis.QuantitySold &&
    decimal.Truncate(Minimum) != Minimum)
{
    throw new InvalidOperationException(
        "Quantity boundaries must be whole numbers.");
}

数据库中的 tinyint 应通过 ORM 映射为枚举。核心业务代码不应使用 "QuantitySold" 这类字符串进行分支判断。

数据库结构不等于领域模型

如果直接把数据库行当作最终的领域对象,短期内确实省事,但时间一长通常会出现两个问题:

  • 某些字段只对特定类型有效,表中会产生大量可空列,也容易形成无效组合;
  • 每增加一种规则,原有类的计算方法都会变得更复杂。

可以保留统一的持久化记录,在加载时再将其转换为具体策略。规则继续扩展后,可以拆成不同的计算器:

public interface IRoyaltyCalculator
{
    decimal Calculate(RoyaltyContext context);
}
public sealed class RevenuePercentageCalculator : IRoyaltyCalculator
{
    public decimal Rate { get; }

    public RevenuePercentageCalculator(decimal rate)
    {
        Rate = rate;
    }

    public decimal Calculate(RoyaltyContext context)
    {
        return context.Revenue * Rate;
    }
}
public sealed class AmountPerUnitCalculator : IRoyaltyCalculator
{
    public decimal Amount { get; }

    public AmountPerUnitCalculator(decimal amount)
    {
        Amount = amount;
    }

    public decimal Calculate(RoyaltyContext context)
    {
        return context.QuantitySold * Amount;
    }
}

持久化层根据 PaymentBasis 创建相应的实现。以后增加“固定保底”“封顶金额”或“一次性奖金”等算法时,只需增加新的计算器,不必继续把所有逻辑塞进同一个类。

不过,没有必要一开始就为每种字段组合建立大量子类。如果当前只有三四种稳定组合,枚举配合受控的 switch 会更直接。等算法明显增多,或不同规则需要完全不同的数据时,再改用策略模式。

需要特别注意的边界问题

  • 明确区间采用 [0, 1000) 还是 [1, 1000],并让所有规则保持一致。
  • 检查同一阶梯组中是否存在空档或意外重叠。
  • 明确区分“规则叠加”和“规则互斥”,不要仅凭 Priority 推断含义。
  • 金额使用 decimal,销量在领域层使用 int 或 long。
  • 在合同或规则中保存币种,不能默认所有金额都是美元。
  • 明确舍入发生在哪一步,以及采用哪种 MidpointRounding 规则。
  • 已结算的 royalty 应保存计算时使用的规则版本或计算快照。规则后来发生变化时,不应悄悄重算历史结果。
  • 如果需要处理退款、退货或负收入,应单独定义规则,不能直接假设所有输入都是非负数。
  • 如果规则由业务人员动态配置,只保存结构化参数和规则类型,避免把任意 C# 表达式或 SQL 条件存入数据库执行。

数据库保存规则定义,领域对象保证规则有效,计算策略执行算法,规则集合组合结果。这样的分层比单个“万能规则类”更容易验证,也方便以后增加新的计费场景。

备注:内容仅供参考。