PAYATHON 2026

支持多种账户类型登录的 Database 设计

支付小周

结论

不要为“Facebook 用户、密码用户、访客”分别建立三套用户表。更合适的设计是拆分下面几个概念:

  • customers:业务主体,表示实际购买服务的人。
  • auth_identities:登录凭证,记录客户使用哪种方式认证。
  • orders:订单或服务购买记录。
  • payments:支付记录。
  • 订单联系人信息:保存支付时的信息快照,不能只依赖用户资料。

密码登录和 Facebook 登录只是两种认证方式,可以关联到同一个 customer。访客付款时,可以创建一条没有登录凭证的 customer 记录,也可以只在订单中保存访客信息。选择哪种方式,取决于业务是否需要识别回头客。

不要因为 email 相同,就自动合并 Facebook 身份和密码账户。email 可能未经验证,也可能发生变化或被多个身份错误复用。只有验证结果可信,或者用户主动确认时,才能关联账户。

推荐的数据模型

核心关系如下:

customers
    1 ─── N auth_identities
    1 ─── N orders
               1 ─── N payments

一个客户可以没有登录方式,也可以同时拥有多种登录方式:

customer #123
├── password
└── facebook

只使用 Facebook 登录、从未设置密码的用户不需要特殊处理。系统创建一条 customers 记录,再创建一条 provider = 'facebook' 的认证记录即可,不必伪造密码。

SQL 表结构示例

下面使用 PostgreSQL 风格的 SQL。实际字段应根据业务和隐私合规要求调整。

CREATE TABLE customers (
    id BIGSERIAL PRIMARY KEY,
    display_name VARCHAR(200),
    primary_email VARCHAR(320),
    email_verified_at TIMESTAMPTZ,
    status VARCHAR(30) NOT NULL DEFAULT 'active',
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

customers 保存业务层面的客户资料,不存放具体的登录凭证。

CREATE TABLE auth_identities (
    id BIGSERIAL PRIMARY KEY,
    customer_id BIGINT NOT NULL
        REFERENCES customers(id),

    provider VARCHAR(50) NOT NULL,
    provider_subject VARCHAR(255) NOT NULL,

    password_hash TEXT,
    provider_email VARCHAR(320),
    provider_email_verified BOOLEAN NOT NULL DEFAULT FALSE,

    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    last_login_at TIMESTAMPTZ,

    UNIQUE (provider, provider_subject)
);

各字段的用途如下:

  • provider:认证方式,例如 password 或 facebook。
  • provider_subject:用户在该认证系统中的稳定标识。
  • Facebook 登录应保存提供方返回的用户 ID,也就是 OAuth/OIDC 中类似 sub 的稳定标识,不能把 email 当作外部账户主键。
  • password_hash:只有密码身份需要填写,Facebook 身份应为 NULL。
  • UNIQUE (provider, provider_subject):防止同一个外部身份绑定到多个客户。

如果密码登录只允许使用 email,可以在应用中把规范化后的 email 作为密码身份的登录名。密码哈希仍须使用 Argon2id、bcrypt 或 scrypt 等专用算法生成。不能保存明文密码,也不能直接用普通 SHA-256 代替密码哈希算法。

订单需要同时保存客户关联和购买时的联系人快照:

CREATE TABLE orders (
    id BIGSERIAL PRIMARY KEY,
    customer_id BIGINT
        REFERENCES customers(id),

    contact_name VARCHAR(200) NOT NULL,
    contact_email VARCHAR(320) NOT NULL,
    contact_phone VARCHAR(50),

    amount_minor BIGINT NOT NULL,
    currency CHAR(3) NOT NULL,
    status VARCHAR(30) NOT NULL,

    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

金额建议使用最小货币单位,例如分,不要使用浮点数:

1999 = 19.99 CNY

支付记录单独存放:

CREATE TABLE payments (
    id BIGSERIAL PRIMARY KEY,
    order_id BIGINT NOT NULL
        REFERENCES orders(id),

    payment_provider VARCHAR(50) NOT NULL,
    provider_payment_id VARCHAR(255),
    amount_minor BIGINT NOT NULL,
    currency CHAR(3) NOT NULL,
    status VARCHAR(30) NOT NULL,

    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,

    UNIQUE (payment_provider, provider_payment_id)
);

订单和支付应当分开,因为同一笔订单可能经历多次支付尝试,也可能出现支付失败、退款或重新付款。

三种使用场景如何处理

普通账户注册

注册成功后:

  1. 创建 customers 记录。
  2. 创建 auth_identities 记录。
  3. 将 provider 设为 password。
  4. 保存登录标识和安全生成的 password_hash。

示意数据:

customers
id: 101
primary_email: alice@example.com

auth_identities
customer_id: 101
provider: password
provider_subject: alice@example.com
password_hash: <password hash>

provider_subject 的具体格式由系统决定。如果允许用户修改登录 email,最好设置一个不可变的内部登录标识。另一种做法是在修改 email 时,同时完成重新验证和唯一性检查。

仅使用 Facebook 登录

用户第一次通过 Facebook 登录时:

  1. 验证 Facebook 返回的授权结果。
  2. 读取 Facebook 提供的稳定账户 ID。
  3. 查询 (provider, provider_subject) 是否已经存在。
  4. 如果不存在,创建 customers 记录。
  5. 创建对应的 auth_identities 记录,不设置密码。
customers
id: 102
primary_email: bob@example.com

auth_identities
customer_id: 102
provider: facebook
provider_subject: 987654321
password_hash: NULL

用户以后再次登录时,系统通过 facebook + 987654321 找到 customer_id = 102。

即使 Facebook 没有返回 email,这套结构也能正常工作。系统可以要求用户在付款前补充联系人 email,并将其保存到订单中。

不注册的访客付款

访客付款通常有两种处理方式。

第一种是创建没有认证身份的客户:

customers
id: 103
primary_email: guest@example.com

auth_identities
无记录

这样便于统一处理订单查询、客服请求和后续账户升级。访客以后注册时,可以在完成 email 验证后,为这个 customer 添加密码或 Facebook 身份。

第二种是不创建客户,只在订单中保存联系人:

orders
customer_id: NULL
contact_name: Guest User
contact_email: guest@example.com

如果业务只关心单次交易,这种方式更简单。如果以后需要订单历史、售后服务、重复购买分析或访客转注册用户,创建一条没有认证身份的 customer 记录通常更方便。

无论采用哪种方式,订单都应保留联系人快照。否则用户修改个人资料后,旧订单中的开票、通知或服务交付信息也会跟着变化。

如何关联 Facebook 与已有密码账户

不要因为两个账户的 email 相同就直接合并。更安全的流程是:

  1. 用户先登录已有的密码账户。
  2. 用户选择“绑定 Facebook”。
  3. 系统完成 Facebook 授权,取得稳定的 Facebook 用户 ID。
  4. 检查该 Facebook 身份是否已经绑定其他客户。
  5. 确认没有冲突后,为当前 customer_id 添加一条 Facebook 身份记录。

结果如下:

customers
id: 101

auth_identities
customer_id: 101, provider: password, provider_subject: alice@example.com
customer_id: 101, provider: facebook, provider_subject: 123456789

如果用户先用 Facebook 登录,之后想设置密码,也不需要创建新的客户。验证 email 后,直接为当前 customer 增加一条 password 身份即可。

如果系统打算在用户首次通过 Facebook 登录时,自动关联已有的密码账户,至少应满足这些条件:

  • Facebook 返回了 email;
  • 提供方明确确认该 email 已验证;
  • 本地账户的 email 也已验证;
  • 系统已经处理账户接管和身份冲突风险。

即使满足这些条件,让用户登录原账户后主动绑定,通常还是更稳妥。

登录与建单逻辑示例

function loginWithFacebook(facebookToken):
    profile = verifyFacebookToken(facebookToken)

    identity = findIdentity(
        provider = "facebook",
        providerSubject = profile.userId
    )

    if identity exists:
        return createSession(identity.customerId)

    customer = createCustomer(
        displayName = profile.name,
        primaryEmail = profile.verifiedEmail
    )

    createIdentity(
        customerId = customer.id,
        provider = "facebook",
        providerSubject = profile.userId,
        providerEmail = profile.email,
        providerEmailVerified = profile.emailVerified
    )

    return createSession(customer.id)

访客付款可以这样处理:

function createGuestOrder(input):
    customer = createCustomer(
        displayName = input.name,
        primaryEmail = input.email
    )

    return createOrder(
        customerId = customer.id,
        contactName = input.name,
        contactEmail = input.email,
        amountMinor = input.amountMinor,
        currency = input.currency
    )

如果不想为每个访客创建客户,可以把 customerId 设为 NULL,但其余联系人信息仍要写入订单。

需要特别注意的问题

不要用 email 代替 Facebook 用户 ID

email 是联系方式,不是可靠的外部身份主键。它可能缺失或发生变化,也可能因为配置和授权范围不同而无法取得。识别外部账户时,应使用“认证提供方 + 提供方用户 ID”。

区分当前资料和交易快照

customers.primary_email 是客户当前使用的 email,orders.contact_email 是客户下单时使用的 email。两者可能相同,但用途不同,不能只保留其中一个。

明确定义合并规则

确认两个 customer 属于同一个人后,需要通过专门的流程完成合并,其中包括:

  • 转移身份凭证;
  • 迁移订单归属;
  • 检查重复身份;
  • 记录操作审计;
  • 处理冲突信息;
  • 使会话失效或要求用户重新认证。

不能只因为 email 相同就自动触发合并。

限制认证记录的有效组合

为了便于理解,上面的示例把密码字段放在 auth_identities 中。实际项目还可以继续拆分:

auth_identities
password_credentials
oauth_identities

拆分后,可以分别约束密码凭证和 OAuth 凭证。如果认证方式不多,也可以使用单表,再通过数据库约束或应用层逻辑检查字段组合。

支付信息不要自行保存

银行卡号、安全码等敏感支付数据应交给合规的支付服务商处理。数据库通常只保存支付服务商生成的交易 ID、支付状态、金额和必要的审计信息。具体要求取决于支付渠道、所在地区和适用法规。

客户是谁、客户如何登录、客户购买了什么,这是三个不同的问题。把它们分别建模为 customers、auth_identities 和 orders/payments 后,Facebook 登录、密码登录和访客付款就能共用一套清晰的数据模型。

备注:内容仅供参考。