PAYATHON 2026

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,订单都应保存下单时使用的信息,例如:

  • 联系人姓名
  • Email
  • 手机号
  • 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 转为注册用户时如何处理

访客下单后注册账户,通常按以下流程处理:

  1. 创建或确认注册客户身份。
  2. 验证用户对 Email 或手机号的控制权。
  3. 查找符合条件的历史 Guest 订单。
  4. 将确认属于该用户的订单关联到新客户。
  5. 保留订单中的联系人和地址快照,不用当前客户资料覆盖。
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。等业务确实需要跨订单识别访客时,再增加访客身份和合并机制。

备注:内容仅供参考。