PAYATHON 2026

Customizable 用户地址与必填字段模型如何设计?

支付小周

结论

这类需求适合采用组合模型:

  • 稳定且常用的地址属性使用普通关系型列;
  • 管理员定义的扩展字段使用带元数据定义的 JSON/JSONB;如果确实需要跨记录检索,再使用受约束的 EAV;
  • 将地址与地址用途分开,同一个地址可以同时承担 Billing、Shipping、Main 等角色;
  • 将必填规则建模为可版本化的 schema,再根据产品、产品类别、国家等条件选择适用版本;
  • 提交订单时保存实际使用的 schema 版本或规则快照,避免管理员修改配置后影响历史订单。

如果数据库支持原生 JSON 类型,比如 PostgreSQL 的 jsonb,通常可以优先选择”普通列 + JSONB”。XML column 也能实现类似功能,但地址扩展字段本身并不是 XML 文档。除非系统已有 XML 交换、XSD 校验或遗留系统集成要求,否则使用 XML 的收益不大。

地址类型不应直接作为地址名称

Billing、Shipping 和 Main 表示地址的用途,而不是地址本身。一个客户的办公地址可能既是主地址,也是账单地址,因此建议拆成:

Customer
  └── Address
        └── AddressRole

核心关系可以写成:

CREATE TABLE customer (
    customer_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    first_name  varchar(100),
    last_name   varchar(100),
    email       varchar(320)
);

CREATE TABLE address (
    address_id    bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_id   bigint NOT NULL REFERENCES customer(customer_id),
    recipient     varchar(200),
    organization  varchar(200),
    line1         varchar(200),
    line2         varchar(200),
    city          varchar(120),
    region        varchar(120),
    postal_code   varchar(40),
    country_code  char(2) NOT NULL,
    custom_values jsonb NOT NULL DEFAULT '{}'::jsonb
);

CREATE TABLE address_role (
    address_id bigint NOT NULL REFERENCES address(address_id),
    role_code  varchar(40) NOT NULL,
    PRIMARY KEY (address_id, role_code),
    CHECK (role_code IN ('MAIN', 'BILLING', 'SHIPPING'))
);

如果管理员可以创建新的地址类型,就不应使用固定的 CHECK。可以建立 address_role_definition 表,再让 address_role.role_code 通过外键引用该表。

这样,账单地址和收货地址可以引用同一条 address,不必复制完全相同的数据。

分开管理字段定义和必填规则

“系统中有哪些字段”和”某个场景要求填写哪些字段”是两个不同的问题,需要分别建模。

字段定义表用于记录数据类型、标签和校验约束:

CREATE TABLE field_definition (
    field_id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    field_code     varchar(100) NOT NULL UNIQUE,
    target_type    varchar(30) NOT NULL,
    storage_kind   varchar(20) NOT NULL,
    data_type      varchar(20) NOT NULL,
    display_label  varchar(200) NOT NULL,
    validation     jsonb NOT NULL DEFAULT '{}'::jsonb,
    active         boolean NOT NULL DEFAULT true,
    CHECK (target_type IN ('CUSTOMER', 'ADDRESS')),
    CHECK (storage_kind IN ('COLUMN', 'CUSTOM')),
    CHECK (data_type IN ('STRING', 'INTEGER', 'DECIMAL', 'BOOLEAN', 'DATE'))
);

标准字段可以按下面的方式登记:

INSERT INTO field_definition
    (field_code, target_type, storage_kind, data_type, display_label)
VALUES
    ('first_name',   'CUSTOMER', 'COLUMN', 'STRING', '名字'),
    ('last_name',    'CUSTOMER', 'COLUMN', 'STRING', '姓氏'),
    ('email',        'CUSTOMER', 'COLUMN', 'STRING', '电子邮件'),
    ('line1',        'ADDRESS',  'COLUMN', 'STRING', '地址第一行'),
    ('city',         'ADDRESS',  'COLUMN', 'STRING', '城市'),
    ('postal_code',  'ADDRESS',  'COLUMN', 'STRING', '邮政编码'),
    ('delivery_note','ADDRESS',  'CUSTOM', 'STRING', '配送说明');

storage_kind = 'COLUMN' 表示值来自普通列,CUSTOM 表示值来自 custom_values。应用程序需要维护字段代码与实际存储位置之间的映射,不能把客户端提交的 field_code 直接拼接进 SQL。

用可版本化的 schema 表达场景要求

可以将一组要求定义为 preference_schema。每个版本记录所需的地址角色和字段规则:

CREATE TABLE preference_schema (
    schema_id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    schema_code   varchar(100) NOT NULL,
    version       integer NOT NULL,
    status        varchar(20) NOT NULL,
    created_at    timestamptz NOT NULL DEFAULT now(),
    UNIQUE (schema_code, version),
    CHECK (status IN ('DRAFT', 'ACTIVE', 'RETIRED'))
);

CREATE TABLE schema_address_role (
    schema_id     bigint NOT NULL REFERENCES preference_schema(schema_id),
    role_code     varchar(40) NOT NULL,
    required      boolean NOT NULL DEFAULT true,
    min_count     integer NOT NULL DEFAULT 1,
    max_count     integer,
    PRIMARY KEY (schema_id, role_code)
);

CREATE TABLE schema_field_rule (
    schema_id     bigint NOT NULL REFERENCES preference_schema(schema_id),
    field_id      bigint NOT NULL REFERENCES field_definition(field_id),
    required      boolean NOT NULL DEFAULT false,
    validation    jsonb NOT NULL DEFAULT '{}'::jsonb,
    PRIMARY KEY (schema_id, field_id)
);

题目给出的约束是:同一产品要求多个地址时,这些地址使用相同的必填字段列表。因此,字段规则只需关联 schema_id,不必为每个 role_code 重复保存。

例如,一个 schema 可以规定:

  • 必须提供 BILLING 和 SHIPPING 两种地址;
  • 两种地址都必须包含 line1、city、postal_code 和 country_code;
  • 两种地址都可以填写 delivery_note。

如果以后出现”账单地址必须填写税号,但收货地址不需要”这样的要求,可以给 schema_field_rule 增加可空的 role_code,或者新建角色级覆盖表。在当前没有这类需求时,不必提前增加复杂度。

将产品、类别和国家建模为适用规则

不要直接在产品表上增加大量 required_xxx 列。可以使用单独的规则表,把业务条件映射到 schema:

CREATE TABLE schema_assignment (
    assignment_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    schema_id     bigint NOT NULL REFERENCES preference_schema(schema_id),
    product_id    bigint,
    category_id   bigint,
    country_code  char(2),
    priority      integer NOT NULL DEFAULT 0,
    valid_from    timestamptz,
    valid_to      timestamptz,
    CHECK (product_id IS NOT NULL OR category_id IS NOT NULL)
);

查询时需要定义明确且可以测试的优先级,例如:

产品 + 国家
产品
产品类别 + 国家
产品类别
系统默认

priority 可以处理同一层级内的业务顺序,但系统仍需禁止两个有效规则在相同条件和相同优先级下同时命中。否则,数据库返回顺序不同就可能导致系统选择不同的 schema。

如果订单包含多个产品,不能随意选择其中一个 schema。常见做法是合并各项要求:

  • 地址角色取并集;
  • 必填字段取并集;
  • 校验约束选择更严格的一方;
  • 如果两条规则相互矛盾,则拒绝自动合并并提示配置错误。

最好在后台发布配置时就检查冲突,不要等到客户结账时才发现问题。

自定义字段可以使用 JSONB,但字段定义必须关系化

只在地址表中存放一个任意 JSON 对象还不够。管理员创建的字段必须记录在 field_definition 中,JSONB 只负责保存字段值:

{
  "delivery_note": "请在工作日送达",
  "access_code": "A-1024"
}

保存地址时,应用程序应执行以下校验:

schema = resolveSchema(productIds, categoryIds, countryCode)

assertRequiredRolesExist(schema.requiredRoles, request.addresses)

for address in request.addresses:
    assert address.countryCode == expectedCountryIfRestricted

    for rule in schema.fieldRules:
        value = readStandardColumnOrCustomValue(address, rule.field)

        if rule.required and isBlank(value):
            error(rule.field.fieldCode, "required")

        validateType(value, rule.field.dataType)
        validateConstraints(value, rule.field.validation)
        validateConstraints(value, rule.validation)

    rejectUnknownCustomFields(address.customValues, schema)

数据库层可以通过 CHECK 约束保证 custom_values 是对象:

ALTER TABLE address
ADD CONSTRAINT address_custom_values_is_object
CHECK (jsonb_typeof(custom_values) = 'object');

经常用于查询、排序、唯一性约束或关联的数据,应该改为普通列。JSONB 更适合低频、可变,并且通常随整条地址一起读取的附加字段。

适合使用 EAV 的情况

EAV 并非完全不可用,但不适合存储姓名、城市、邮编、国家等全部核心字段。全量采用 EAV 会直接带来这些问题:

  • 查询一条完整地址需要多次连接或聚合;
  • 数据类型往往只能统一为字符串,或者需要设置多套值列;
  • NOT NULL、唯一性、外键和范围约束很难直接表达;
  • 排序、统计和批量筛选会变得更复杂;
  • 字段重命名、停用和类型变更需要额外的迁移规则。

如果扩展字段需要频繁单独检索,可以只对这些动态字段使用 EAV:

CREATE TABLE address_custom_value (
    address_id   bigint NOT NULL REFERENCES address(address_id),
    field_id     bigint NOT NULL REFERENCES field_definition(field_id),
    string_value text,
    number_value numeric,
    boolean_value boolean,
    date_value   date,
    PRIMARY KEY (address_id, field_id),
    CHECK (
        ((string_value IS NOT NULL)::integer +
         (number_value IS NOT NULL)::integer +
         (boolean_value IS NOT NULL)::integer +
         (date_value IS NOT NULL)::integer) <= 1
    )
);

应用程序还需要检查实际填写的值列是否与 field_definition.data_type 一致。这种跨表约束通常要通过触发器或服务层完成。

可以按照数据的访问方式选择存储方案:

情况更适合的存储
核心地址字段普通列
扩展字段通常随整条地址读取JSON/JSONB
扩展字段需要大量筛选、统计和索引受约束的 EAV
字段稳定且参与外键或唯一约束普通列
已有 XML 标准、XSD 或 XML 交换接口XML column

XML column 通常不是首选

XML column 可以保留 XML 结构、支持 XPath/XQuery,也便于对接基于 XML 的外部规范。不过,对于简单的键值型扩展字段,XML 往往比 JSON 更冗长。

性能不能只根据数据类型判断,实际表现取决于数据库产品、索引能力、字段数量和查询模式。在没有明确数据库和查询负载的情况下,不能断言 XML 一定比 EAV 或 JSON 慢。但如果系统不需要 XML 语义,使用 XML 通常会增加序列化、查询和维护成本。

历史订单必须固定规则版本

计费系统不能只保存”当前启用的 schema”。管理员可能在订单创建后修改必填字段或国家规则。如果历史订单重新使用新配置校验,原本有效的数据可能会突然失效。

建议订单记录至少保存:

CREATE TABLE order_schema_snapshot (
    order_id       bigint PRIMARY KEY,
    schema_id      bigint NOT NULL REFERENCES preference_schema(schema_id),
    schema_version integer NOT NULL,
    resolved_rules jsonb NOT NULL,
    resolved_at    timestamptz NOT NULL DEFAULT now()
);

schema_id + schema_version 用于追踪配置来源,resolved_rules 保存当时合并后的最终要求。对于审计要求较高的计费系统,还应保存订单实际使用的地址快照,而不只是引用客户当前的地址,因为客户之后可能修改地址。

实施时的其他注意事项

  • 客户注册字段和地址字段属于不同的目标对象,不应强行放入同一张地址规则表。
  • 国家除了影响必填字段,还可能影响字段标签、格式和行政区层级,例如是否要求填写邮编或州、省。
  • 自定义字段发布后应保持 field_code 稳定。修改显示名称时,不应同时修改字段代码。
  • 删除字段时应优先停用,不要立即删除定义和历史值。
  • schema 应遵循草稿、发布、退役流程。已发布的版本应尽量保持不可变。
  • 前端校验用于改善用户体验,服务端仍须根据同一份已解析规则重新校验。
  • 不要只用正则表达式判断地址是否有效,不同国家的地址格式差异很大。
  • 如果需要按 JSONB 自定义字段查询,只为实际会查询的键建立表达式索引,不要为所有动态字段批量建立索引。

综合来看,较稳妥的方案是”规范化的规则元数据 + 普通地址列 + JSONB 扩展值 + 版本化快照”。这种设计保留了关系模型对核心数据的约束能力,允许管理员增加字段,也减少了全量 EAV 带来的查询和维护成本。

备注:内容仅供参考。