Registered Customers 与 Guest Checkout 的数据库设计
结论
不要把“Guest”简单地建模为一种 customer_group,也不要只用 password IS NULL 来区分访客和注册用户。
建议这样设计:
customers保存能够持续识别的客户身份。- 登录凭据单独存放在
customer_credentials表中。 orders保存下单时的联系人、账单地址和收货地址快照。- 注册用户的订单通过可空的
customer_id关联customers。 - Guest checkout 不必创建
customers记录,此时orders.customer_id为NULL。 - 只有当业务需要识别回访访客或统计跨订单行为时,才为访客创建客户记录,同时制定明确的去重和合并规则。
customer_group 适合表示会员等级、批发客户、企业客户、VIP 等业务分类,不适合表示“是否注册”。注册状态属于身份和认证问题,不能与客户分组混为一谈。
为什么订单必须保存独立快照
订单是历史交易记录,不能完全依赖客户的当前资料。
注册用户下单后,可能会修改姓名、手机号或默认地址。如果订单只关联 customers 和客户地址表,再查看旧订单时,显示的可能是修改后的资料,无法还原下单时的真实信息。
所以,无论是 Guest checkout 还是注册用户 checkout,订单都应保存下单时使用的信息,例如:
- 联系人姓名
- 手机号
- Billing address
- Shipping address
- 公司名称和税务信息
- 下单时的币种、价格和税额
这些数据看起来与客户资料重复,但用途不同:
customers保存当前客户身份和可以更新的资料。orders保存不会随客户资料变化的交易快照。
推荐的数据模型
以下示例使用 PostgreSQL。实际字段应根据支付、税务和隐私合规要求调整。
CREATE TABLE customers (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email VARCHAR(320),
display_name VARCHAR(200),
status VARCHAR(30) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT customers_status_check
CHECK (status IN ('active', 'disabled', 'anonymized'))
);
登录凭据单独保存:
CREATE TABLE customer_credentials (
customer_id BIGINT PRIMARY KEY
REFERENCES customers(id) ON DELETE CASCADE,
password_hash TEXT NOT NULL,
password_updated_at TIMESTAMPTZ,
email_verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
订单可以不关联注册客户:
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id BIGINT NULL
REFERENCES customers(id) ON DELETE SET NULL,
checkout_type VARCHAR(20) NOT NULL,
contact_name VARCHAR(200) NOT NULL,
contact_email VARCHAR(320) NOT NULL,
contact_phone VARCHAR(50),
billing_address JSONB NOT NULL,
shipping_address JSONB NOT NULL,
currency CHAR(3) NOT NULL,
total_amount NUMERIC(18, 2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT orders_checkout_type_check
CHECK (checkout_type IN ('guest', 'registered')),
CONSTRAINT registered_order_customer_check
CHECK (
checkout_type <> 'registered'
OR customer_id IS NOT NULL
)
);
Guest checkout 创建订单时,无须同时创建客户记录:
INSERT INTO orders (
customer_id,
checkout_type,
contact_name,
contact_email,
contact_phone,
billing_address,
shipping_address,
currency,
total_amount
) VALUES (
NULL,
'guest',
'Zhang San',
'zhangsan@example.com',
'+86-13800000000',
'{"country":"CN","city":"Shanghai","line1":"Example Road 1"}',
'{"country":"CN","city":"Shanghai","line1":"Example Road 1"}',
'CNY',
299.00
);
注册用户下单时写入 customer_id,但订单快照仍需保留:
INSERT INTO orders (
customer_id,
checkout_type,
contact_name,
contact_email,
contact_phone,
billing_address,
shipping_address,
currency,
total_amount
) VALUES (
10001,
'registered',
'Zhang San',
'zhangsan@example.com',
'+86-13800000000',
'{"country":"CN","city":"Shanghai","line1":"Example Road 1"}',
'{"country":"CN","city":"Shanghai","line1":"Example Road 1"}',
'CNY',
299.00
);
是否会产生大量重复的 Guest 数据
如果每次 Guest checkout 都创建一条 customers 记录,确实容易产生重复数据。例如,同一个人用相同的 Email 下单三次,可能会得到三个客户 ID。数据正确性未必会因此受到影响,但会带来一些额外成本:
- 客户数量统计失真。
- 营销名单出现重复。
- 跨订单分析更加困难。
- 用户后续注册时需要合并历史记录。
- 数据删除和匿名化流程更复杂。
最直接的做法是让 Guest checkout 只创建订单,不创建客户身份。即使不同订单使用了相同的 Email,也不要自动判断它们属于同一个人。
Email、手机号甚至收货地址都不是绝对可靠的身份标识。家庭成员可能共用邮箱或电话,同一位用户也可能在不同订单中填写不同信息。在未经验证的情况下自动合并数据,通常比保留重复数据风险更高。
什么时候需要为 Guest 创建客户记录
以下场景可能需要持久化访客身份:
- 支持 Guest order history。
- 需要识别回访客户。
- 需要恢复跨设备购物车。
- 需要进行忠诚度、风控或客户生命周期分析。
- 允许访客下单后直接激活账户并继承历史订单。
这时仍可使用统一的 customers 表,但应显式记录账户状态,不要根据密码是否为空来推断。
ALTER TABLE customers
ADD COLUMN account_state VARCHAR(30) NOT NULL DEFAULT 'registered',
ADD CONSTRAINT customers_account_state_check
CHECK (
account_state IN (
'guest',
'invited',
'registered',
'disabled',
'anonymized'
)
);
是否存在登录凭据,则由 customer_credentials 表表示。这样可以准确描述更多账户状态:
- Guest 客户:有客户身份,但没有登录凭据。
- Invited 客户:身份已经创建,但尚未完成注册。
- Registered 客户:拥有一种或多种认证方式。
- OAuth 用户:可能没有本地密码。
- Passwordless 用户:通过 magic link 或 WebAuthn 登录。
- Disabled 客户:身份仍然保留,但不能登录。
为什么不能只用“没有 password”区分 Guest
password IS NULL 不能准确表示账户状态。
现代系统中的注册用户可能通过以下方式登录:
- OAuth 2.0 / OpenID Connect
- SAML
- Magic link
- Passkey / WebAuthn
- 企业统一身份认证
这些用户也可能没有本地密码,但他们显然不是 Guest。
同时,customers 表不应保存明文密码。即使系统使用传统密码登录,也只能保存经过适当密码哈希算法处理的 password_hash。认证信息最好独立存储,这样既便于限制访问权限,也能支持一个客户绑定多种登录方式。
例如:
CREATE TABLE customer_auth_identities (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL
REFERENCES customers(id) ON DELETE CASCADE,
provider VARCHAR(50) NOT NULL,
provider_subject VARCHAR(255) NOT NULL,
password_hash TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT auth_identity_unique
UNIQUE (provider, provider_subject),
CONSTRAINT auth_identity_password_check
CHECK (
provider = 'password'
OR password_hash IS NULL
)
);
这里的 provider 可以是 password、google、apple、saml 或其他认证来源。具体取值应由应用自行定义,不能把示例直接当作通用标准。
customer_groups 应该如何使用
customer_groups 应用于业务分群,例如:
CREATE TABLE customer_groups (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL
);
CREATE TABLE customer_group_memberships (
customer_id BIGINT NOT NULL
REFERENCES customers(id) ON DELETE CASCADE,
group_id BIGINT NOT NULL
REFERENCES customer_groups(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (customer_id, group_id)
);
使用关联表,而不是在 customers 中设置单个 customer_group_id,可以让一个客户同时属于多个分组。例如,同一位客户可以既是 VIP,也是 wholesale。
如果业务明确规定每个客户只能属于一个互斥等级,也可以使用单个外键。不过,“Guest”和“Registered”仍不适合作为此类分组,因为它们描述的是账户生命周期状态。
Guest 转为注册用户时如何处理
访客下单后注册账户,通常按以下流程处理:
- 创建或确认注册客户身份。
- 验证用户对 Email 或手机号的控制权。
- 查找符合条件的历史 Guest 订单。
- 将确认属于该用户的订单关联到新客户。
- 保留订单中的联系人和地址快照,不用当前客户资料覆盖。
UPDATE orders
SET customer_id = :customer_id
WHERE customer_id IS NULL
AND checkout_type = 'guest'
AND LOWER(contact_email) = LOWER(:verified_email);
这段 SQL 只用于说明基本思路,不能在 Email 未经验证时直接执行。实际系统还需要考虑:
- Email 是否已经验证。
- 是否允许多人使用同一个邮箱。
- 查找历史订单的时间范围。
- 是否需要订单号、付款信息等额外证明。
- 关联操作是否需要审计记录。
订单关联到注册客户后,也未必需要把 checkout_type 改为 registered。checkout_type 记录下单时采用的结账方式,customer_id 表示订单当前归属,两者含义不同。
两类 checkout 应采集哪些不同信息
完成交易所需的信息通常相同,包括收货地址、联系人、支付信息和税务信息。具体采集哪些字段,取决于商品类型、配送方式和当地法规。
Guest checkout 通常只要求:
- 完成订单所需的联系人信息。
- 配送和账单信息。
- 接收订单通知所需的 Email 或手机号。
- 法律要求的同意记录。
注册用户还可能拥有:
- 已验证的 Email 或手机号。
- 登录身份和认证方式。
- 保存的地址簿。
- 默认支付方式的引用标识。
- 会员等级、积分和偏好设置。
- 营销订阅状态。
- 登录安全与账户恢复信息。
即使注册用户已经保存了地址,订单仍要保留地址快照。结账所需的信息也不能默认用于营销。交易通知和营销授权通常属于不同目的,应分别记录,并根据适用地区的法规设计同意和删除流程。
实施时的注意事项
- 不要用 Email 唯一约束限制所有 Guest 订单。Guest Email 保存在订单快照中,同一个 Email 多次出现很正常。
- 如果
customers.email用于登录,应先明确大小写归一化、验证状态和唯一性策略。 - 不要将支付卡敏感数据直接存入业务数据库。通常只保存支付服务商返回的 token、payment method ID 和必要的展示信息。
- 删除客户账户时,订单可能因财务、税务或审计要求需要保留。因此,外键适合使用
ON DELETE SET NULL,同时对个人信息进行适当的删除或匿名化处理。 checkout_type、账户状态和客户分组是三个不同的维度,不要用一个字段同时表示它们。- Guest 转为注册用户、客户合并和订单认领都应写入审计日志,避免在没有记录的情况下修改订单归属。
对大多数电商系统来说,稳妥的起点是让注册用户进入 customers,Guest checkout 只进入 orders。等业务确实需要跨订单识别访客时,再增加访客身份和合并机制。
备注:内容仅供参考。
