PAYATHON 2026

数据库如何设计 system default 与用户自定义项

支付小周

结论

通常应选择 Option 1 的单表设计。系统默认项和用户自定义项本质上都是 PaymentTerm,适合存放在同一张表中,并通过可空的 OwnerUserId 区分作用域:

  • OwnerUserId IS NULL:系统默认项;
  • OwnerUserId = 当前用户 ID:该用户创建的自定义项。

INVOICE 只需保留一个 PaymentTermId 外键。Option 2 将同一种业务实体拆成两张表,会引入双外键、状态标志和分支查询,也更难保证数据完整性,因此不建议采用。

如果数据库必须严格保证“发票只能引用系统项或发票所属用户的自定义项”,可以在 Option 1 的基础上增加触发器。如果希望完全依靠声明式外键约束,可以再增加一张用户可用项关联表。

推荐的基础表结构

以下示例使用 PostgreSQL,其他数据库也可以沿用相同的建模思路。

CREATE TABLE app_user (
    id BIGINT PRIMARY KEY
);

CREATE TABLE payment_term (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    owner_user_id BIGINT NULL,
    is_active BOOLEAN NOT NULL DEFAULT TRUE,

    CONSTRAINT fk_payment_term_owner
        FOREIGN KEY (owner_user_id)
        REFERENCES app_user(id)
);

CREATE TABLE invoice (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL,
    payment_term_id BIGINT NOT NULL,

    CONSTRAINT fk_invoice_user
        FOREIGN KEY (user_id)
        REFERENCES app_user(id),

    CONSTRAINT fk_invoice_payment_term
        FOREIGN KEY (payment_term_id)
        REFERENCES payment_term(id)
);

系统默认数据的 owner_user_id 为 NULL:

INSERT INTO payment_term (name, owner_user_id)
VALUES
    ('DueOnReceipt', NULL),
    ('NET10', NULL),
    ('NET15', NULL),
    ('NET30', NULL);

用户自定义项则记录所属用户:

INSERT INTO payment_term (name, owner_user_id)
VALUES ('NET45', 1001);

在这种结构中,系统项和自定义项共用同一套主键,因此发票只需要一个外键:

INVOICE.PaymentTermId
    └── PAYMENT_TERM.Id

不需要额外保存 IsPaymentTermCustom 标志。是否属于自定义项可以直接根据 owner_user_id 判断,重复保存标志还可能产生相互矛盾的数据。

查询用户可选择的 Payment Terms

用户可以看到系统默认项和自己创建的自定义项:

SELECT
    id,
    name,
    owner_user_id,
    CASE
        WHEN owner_user_id IS NULL THEN 'SYSTEM'
        ELSE 'CUSTOM'
    END AS source
FROM payment_term
WHERE is_active = TRUE
  AND (
      owner_user_id IS NULL
      OR owner_user_id = :current_user_id
  )
ORDER BY
    CASE WHEN owner_user_id IS NULL THEN 0 ELSE 1 END,
    name;

查询某张发票使用的 Payment Term 时,也不用根据标志选择不同的表:

SELECT
    i.id AS invoice_id,
    pt.id AS payment_term_id,
    pt.name AS payment_term_name,
    CASE
        WHEN pt.owner_user_id IS NULL THEN FALSE
        ELSE TRUE
    END AS is_custom
FROM invoice AS i
JOIN payment_term AS pt
  ON pt.id = i.payment_term_id
WHERE i.id = :invoice_id;

普通外键无法完成所有校验

上述外键只能保证 PaymentTermId 对应的记录确实存在,无法确认它是否属于正确的用户。例如,用户 1001 的发票在技术上仍有可能引用用户 1002 创建的 Payment Term。

因此,写入发票时必须检查:

payment_term.owner_user_id IS NULL
OR payment_term.owner_user_id = invoice.user_id

这条规则可以由应用服务检查。如果系统存在多个写入入口,最好同时在数据库中添加约束。

PostgreSQL 可以使用触发器:

CREATE OR REPLACE FUNCTION validate_invoice_payment_term()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
    IF NOT EXISTS (
        SELECT 1
        FROM payment_term AS pt
        WHERE pt.id = NEW.payment_term_id
          AND pt.is_active = TRUE
          AND (
              pt.owner_user_id IS NULL
              OR pt.owner_user_id = NEW.user_id
          )
    ) THEN
        RAISE EXCEPTION
            'Payment term % is not available to user %',
            NEW.payment_term_id,
            NEW.user_id;
    END IF;

    RETURN NEW;
END;
$$;

CREATE TRIGGER trg_validate_invoice_payment_term
BEFORE INSERT OR UPDATE OF user_id, payment_term_id
ON invoice
FOR EACH ROW
EXECUTE FUNCTION validate_invoice_payment_term();

具体语法取决于所用的数据库。MySQL、SQL Server 和 Oracle 需要改写成各自支持的触发器语法。

需要纯外键约束时的 Option 3

如果不想依赖触发器,可以增加一张“用户可用 Payment Term”关联表,让发票引用用户已经获准使用的选项:

CREATE TABLE user_payment_term (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL,
    payment_term_id BIGINT NOT NULL,

    CONSTRAINT fk_user_payment_term_user
        FOREIGN KEY (user_id)
        REFERENCES app_user(id),

    CONSTRAINT fk_user_payment_term_term
        FOREIGN KEY (payment_term_id)
        REFERENCES payment_term(id),

    CONSTRAINT uq_user_payment_term
        UNIQUE (user_id, payment_term_id),

    CONSTRAINT uq_user_payment_term_owner
        UNIQUE (user_id, id)
);

发票同时保存用户和关联记录,再用复合外键确保两者匹配:

CREATE TABLE invoice (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id BIGINT NOT NULL,
    user_payment_term_id BIGINT NOT NULL,

    CONSTRAINT fk_invoice_user_payment_term
        FOREIGN KEY (user_id, user_payment_term_id)
        REFERENCES user_payment_term(user_id, id)
);

创建用户时,需要为其写入系统默认项的关联记录:

INSERT INTO user_payment_term (user_id, payment_term_id)
SELECT :user_id, pt.id
FROM payment_term AS pt
WHERE pt.owner_user_id IS NULL
  AND pt.is_active = TRUE;

创建自定义项后,也要为创建者增加一条关联:

WITH new_term AS (
    INSERT INTO payment_term (name, owner_user_id)
    VALUES (:name, :user_id)
    RETURNING id
)
INSERT INTO user_payment_term (user_id, payment_term_id)
SELECT :user_id, id
FROM new_term;

这种设计可以通过外键严格保证访问关系,也便于以后支持以下需求:

  • 管理员只向部分用户开放某个系统项;
  • 团队共享自定义项;
  • 用户隐藏某些系统默认项;
  • 为不同租户配置不同的可用集合。

相应的代价是,系统默认项不再天然对所有用户可见,必须维护关联记录。用户数量和默认项数量较大时,这张表可能积累大量数据。如果需求只有“系统项全局可见,自定义项仅创建者可见”,使用单表并增加校验通常更简单。

不推荐 Option 2 的原因

PaymentTerm 和 PaymentTermCustom 表示的是同一种实体,区别只有所有者,没有必要拆成两张表。

拆表后,发票中会出现三个相互依赖的字段:

PaymentTermId
PaymentTermCustomId
IsPaymentTermCustom

数据库还要额外保证以下组合规则:

IsPaymentTermCustom = FALSE
    => PaymentTermId 非空且 PaymentTermCustomId 为空

IsPaymentTermCustom = TRUE
    => PaymentTermCustomId 非空且 PaymentTermId 为空

如果标志和两个外键不一致,就会产生无法判断真实 Payment Term 的脏数据。

查询也会变得复杂。统计、排序、搜索和关联查询通常需要使用 UNION、两个 LEFT JOIN 或大量 CASE 表达式。以后为 Payment Term 增加描述、到期天数或状态等字段时,两张表也必须同步修改。

这相当于手工实现多态关联,却失去了普通外键模型原有的简洁性。

唯一性约束与索引

如果系统项名称不能重复,可以建立部分唯一索引:

CREATE UNIQUE INDEX uq_payment_term_system_name
ON payment_term (name)
WHERE owner_user_id IS NULL;

同一用户下的自定义名称也可以限制为唯一:

CREATE UNIQUE INDEX uq_payment_term_user_name
ON payment_term (owner_user_id, name)
WHERE owner_user_id IS NOT NULL;

还可以为常用的作用域查询增加索引:

CREATE INDEX ix_payment_term_owner_active
ON payment_term (owner_user_id, is_active);

是否允许用户创建与系统项同名的自定义项,应由业务规则决定。例如,用户能否再创建一个 NET30,需要在产品层面明确,不能单靠表结构判断。

历史发票的处理

Payment Term 通常属于历史业务数据。直接修改或删除 NET30,可能改变已开具发票的语义。因此,已经被使用的 Payment Term 通常不应物理删除,而应通过状态将其停用:

UPDATE payment_term
SET is_active = FALSE
WHERE id = :payment_term_id
  AND owner_user_id = :current_user_id;

停用后,该选项不会再出现在新发票中,但旧发票仍可通过外键读取它。

如果发票必须完整保留开具时的显示内容,还可以在发票中保存快照字段:

ALTER TABLE invoice
ADD COLUMN payment_term_name_snapshot VARCHAR(100);

真正参与计算的条款更适合保存为结构化数据,例如:

ALTER TABLE payment_term
ADD COLUMN due_days INTEGER;

不要通过解析 NET30 这样的名称计算日期。名称用于展示,due_days 才是业务数据。DueOnReceipt 可以对应 due_days = 0。

最终选择

如果需求简单,而且系统默认项始终向所有用户开放,使用以下结构即可:

PAYMENT_TERM
- Id
- Name
- OwnerUserId NULL 表示系统项
- IsActive

INVOICE
- Id
- UserId
- PaymentTermId

应用校验或数据库触发器负责保证发票只能引用系统项,或者当前用户自己的自定义项。

只有在需要共享、授权、隐藏、租户级配置,或者必须用声明式外键严格表达可用范围时,才需要增加 USER_PAYMENT_TERM 关联表。无论采用哪种方案,都不建议将系统项和自定义项拆成两张实体表,也不应在发票中保存可由关联数据推导出的 IsPaymentTermCustom 标志。

备注:内容仅供参考。