PAYATHON 2026

NHibernate 如何将同一实体映射到不同表

支付小周

结论

不建议将同一套 MoneyTransferMean 继承层次分别映射为 payment 和 refund 两套实体。

entity-name 可以为映射指定不同的逻辑名称,但在当前继承映射中,SubclassMap<CashMoneyTransferMean> 对应的仍是同一个 CLR 类型。重复声明后,NHibernate 无法判断具体子类应归入哪一个根映射,因此会报错:

Duplicate class/entity mapping MoneyTransferMean+CashMoneyTransferMean

问题在于,Cash、EFT 等表示有限且固定的方式类型,payment 和 refund 则表示终端启用了哪些类型。这两者不是同一种实体。

改动较小且更稳妥的做法是:

  • 保留 MoneyTransferMean 及其行为,但不直接持久化它。
  • 数据库只保存稳定的类型代码,例如 Cash、EFT。
  • 为 payment 和 refund 新增配置实体,分别映射到不同的表。
  • 读取配置后,根据类型代码取得对应的 MoneyTransferMean 实例。

这样既不需要在 MoneyTransferMean 中增加公开的 IsRefund 属性,也不需要重复映射同一套继承层次。

当前模型为什么难以直接映射

当前代码混合了三个概念:

  1. MoneyTransferMean 的业务行为。
  2. Cash、EFT 等固定值。
  3. 某个 POS terminal 启用了哪些支付或退款方式。

真正需要持久化的是第三项,也就是终端的配置状态。

例如,下面两个集合都可以包含 Cash:

Payment means = { Cash, EFT, Voucher }
Refund means  = { Cash, Voucher }

这并不意味着存在两个不同的 Cash 实体。实际存在的是两条配置记录:

Payment -> Cash
Refund  -> Cash

如果将 CashMoneyTransferMean 本身映射为实体,NHibernate 就需要为它分配实体身份、跟踪生命周期,还要判断它属于 payment hierarchy 还是 refund hierarchy。这与静态单例式的领域模型并不相符。

直接把下面这样的静态对象交给 NHibernate 管理,也容易引发问题:

public static readonly MoneyTransferMean Cash =
    new CashMoneyTransferMean();

NHibernate 实体通常属于某个 ISession 的持久化上下文,而静态实例由整个进程共享。同一个静态实例一旦交给不同的 Session 管理,其实体标识、状态跟踪和缓存语义都会变得含糊。

推荐方案:持久化配置项,而不是行为对象

先为每种资金转移方式定义稳定的持久化代码,可以使用 enum,也可以使用字符串代码。

public enum MoneyTransferMeanCode
{
    Cash,
    EFT,
    LoyaltyCard,
    Voucher
}

MoneyTransferMean 继续承载业务行为,并提供根据代码取得实例的方法:

public abstract class MoneyTransferMean
{
    public static readonly MoneyTransferMean Cash =
        new CashMoneyTransferMean();

    public static readonly MoneyTransferMean EFT =
        new EFTMoneyTransferMean();

    public static readonly MoneyTransferMean LoyaltyCard =
        new LoyaltyCardMoneyTransferMean();

    public static readonly MoneyTransferMean Voucher =
        new VoucherMoneyTransferMean();

    public abstract MoneyTransferMeanCode Code { get; }

    public static MoneyTransferMean FromCode(MoneyTransferMeanCode code)
    {
        switch (code)
        {
            case MoneyTransferMeanCode.Cash:
                return Cash;

            case MoneyTransferMeanCode.EFT:
                return EFT;

            case MoneyTransferMeanCode.LoyaltyCard:
                return LoyaltyCard;

            case MoneyTransferMeanCode.Voucher:
                return Voucher;

            default:
                throw new ArgumentOutOfRangeException(nameof(code), code, null);
        }
    }

    public sealed class CashMoneyTransferMean : MoneyTransferMean
    {
        public override MoneyTransferMeanCode Code =>
            MoneyTransferMeanCode.Cash;
    }

    public sealed class EFTMoneyTransferMean : MoneyTransferMean
    {
        public override MoneyTransferMeanCode Code =>
            MoneyTransferMeanCode.EFT;
    }

    public sealed class LoyaltyCardMoneyTransferMean : MoneyTransferMean
    {
        public override MoneyTransferMeanCode Code =>
            MoneyTransferMeanCode.LoyaltyCard;
    }

    public sealed class VoucherMoneyTransferMean : MoneyTransferMean
    {
        public override MoneyTransferMeanCode Code =>
            MoneyTransferMeanCode.Voucher;
    }
}

这里的 enum 只用于持久化标识,业务行为仍保留在类中,因此不会变成只有 enum、没有行为的贫血设计。

分别建立 payment 和 refund 配置实体

如果确实需要两个独立的表,可以增加两个很小的持久化实体。

public class PaymentMeanConfiguration
{
    public virtual long Id { get; protected set; }

    private MoneyTransferMeanCode _code;

    protected PaymentMeanConfiguration()
    {
    }

    public PaymentMeanConfiguration(MoneyTransferMean mean)
    {
        if (mean == null)
            throw new ArgumentNullException(nameof(mean));

        _code = mean.Code;
    }

    public virtual MoneyTransferMean Mean =>
        MoneyTransferMean.FromCode(_code);
}
public class RefundMeanConfiguration
{
    public virtual long Id { get; protected set; }

    private MoneyTransferMeanCode _code;

    protected RefundMeanConfiguration()
    {
    }

    public RefundMeanConfiguration(MoneyTransferMean mean)
    {
        if (mean == null)
            throw new ArgumentNullException(nameof(mean));

        _code = mean.Code;
    }

    public virtual MoneyTransferMean Mean =>
        MoneyTransferMean.FromCode(_code);
}

对应的表结构可以很简单:

CREATE TABLE PaymentMeans
(
    Id   BIGINT NOT NULL PRIMARY KEY,
    Type VARCHAR(30) NOT NULL,
    CONSTRAINT UQ_PaymentMeans_Type UNIQUE (Type)
);

CREATE TABLE RefundMeans
(
    Id   BIGINT NOT NULL PRIMARY KEY,
    Type VARCHAR(30) NOT NULL,
    CONSTRAINT UQ_RefundMeans_Type UNIQUE (Type)
);

如果配置属于特定的 POS terminal,表中还应包含 TerminalId,唯一约束也应改为:

UNIQUE (TerminalId, Type)

否则,各终端无法分别保存自己的配置。

FluentNHibernate 映射示例

FluentNHibernate 可以通过成员访问机制映射私有字段,无需为持久化增加公开 setter。

public sealed class PaymentMeanConfigurationMap
    : ClassMap<PaymentMeanConfiguration>
{
    public PaymentMeanConfigurationMap()
    {
        Table("PaymentMeans");

        Id(x => x.Id)
            .GeneratedBy.Identity();

        Map(Reveal.Member<PaymentMeanConfiguration>("_code"))
            .Column("Type")
            .CustomType<MoneyTransferMeanCode>()
            .Not.Nullable()
            .Unique();
    }
}
public sealed class RefundMeanConfigurationMap
    : ClassMap<RefundMeanConfiguration>
{
    public RefundMeanConfigurationMap()
    {
        Table("RefundMeans");

        Id(x => x.Id)
            .GeneratedBy.Identity();

        Map(Reveal.Member<RefundMeanConfiguration>("_code"))
            .Column("Type")
            .CustomType<MoneyTransferMeanCode>()
            .Not.Nullable()
            .Unique();
    }
}

私有字段的具体映射方式可能因 FluentNHibernate 版本和访问策略而异。如果当前版本不接受 Reveal.Member<T>(),也可以改为映射受保护属性:

protected virtual MoneyTransferMeanCode Code
{
    get => _code;
    set => _code = value;
}

映射代码相应改为:

Map(Reveal.Member<PaymentMeanConfiguration>("Code"))
    .Column("Type")
    .CustomType<MoneyTransferMeanCode>();

这样依然不会污染实体的 public API。

保存和读取集合

替换支付方式集合时,应在同一个事务中完成删除和插入:

using (var transaction = session.BeginTransaction())
{
    session.CreateQuery(
        "delete from PaymentMeanConfiguration")
        .ExecuteUpdate();

    foreach (var mean in newPaymentMeans)
    {
        session.Save(new PaymentMeanConfiguration(mean));
    }

    transaction.Commit();
}

读取时再将配置转换为领域对象:

var paymentMeans = session
    .Query<PaymentMeanConfiguration>()
    .ToList()
    .Select(x => x.Mean)
    .ToList();

退款方式的处理相同,只需使用 RefundMeanConfiguration。

实际系统中,如果配置属于某个 terminal,删除和查询操作必须带上终端条件,不能直接处理整张表:

session.CreateQuery(@"
    delete from PaymentMeanConfiguration
    where Terminal.Id = :terminalId")
    .SetParameter("terminalId", terminalId)
    .ExecuteUpdate();

也可以使用一张配置表

如果没有必须拆成两个物理表的要求,也可以只建立一个配置实体,用私有的用途字段区分 payment 和 refund:

internal enum MoneyTransferUsage
{
    Payment,
    Refund
}

表结构可以写成:

CREATE TABLE EnabledMoneyTransferMeans
(
    Id         BIGINT NOT NULL PRIMARY KEY,
    TerminalId BIGINT NOT NULL,
    Usage      VARCHAR(20) NOT NULL,
    Type       VARCHAR(30) NOT NULL,
    CONSTRAINT UQ_EnabledMeans
        UNIQUE (TerminalId, Usage, Type)
);

Usage 可以一直作为实体的私有字段,不必在 MoneyTransferMean 上增加 IsRefund。这种设计使用的表更少,查询时按 Usage 过滤。使用两个独立表,则能在数据库结构上提供更明确的隔离。

注意事项

不要将 enum 的整数序号直接作为长期持久化值。以后调整 enum 成员的顺序,可能会改变已有数据的含义。更安全的做法是保存字符串:

Cash
EFT
LoyaltyCard
Voucher

也可以为每个值定义不可变的显式编号:

public enum MoneyTransferMeanCode
{
    Cash = 1,
    EFT = 2,
    LoyaltyCard = 3,
    Voucher = 4
}

数据库还应为同一 terminal、同一用途下的 Type 设置唯一约束,防止重复插入同一种方式。

这个问题不适合通过复制 NHibernate inheritance mapping 来解决。让 MoneyTransferMean 保持为带有行为的固定值对象,再单独持久化”某终端启用了哪种方式”这一配置关系,既可以保留现有的领域 API,也能得到清晰、稳定且便于查询的数据库结构。

备注:内容仅供参考。