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 带来的查询和维护成本。
备注:内容仅供参考。
