当一个“订阅 Brand A 产品新闻”的动作需要同时落到一个 HubSpot Brand、一个通信订阅类型、六个细分、三个联系人属性和若干分析目标时,这份映射已经不再是普通配置,而是产品业务逻辑。
如果把这些数字 ID 分散在网站代码和条件分支里,每增加一个站点、活动或品牌重组都要重新部署。更麻烦的是,几个月后很难回答一个审计问题:为什么这个联系人进入了这些目标?
更稳妥的做法是让网站只表达业务意图,由一个经过验证、带版本号的路由模型,把意图解析为具体的 HubSpot 操作。
网站提交意图,不提交 HubSpot 实现细节
网站应该发送这样的请求:
{
"contactId": "usr_7T4M9Q6K",
"email": "person@example.com",
"brand": "brand_a",
"product": "product_newsletter",
"action": "SUBSCRIBE",
"source": "site-a-footer"
}
网站不应该知道当前实现使用了 businessUnitId=41857、订阅类型 39644612 或细分 611。这些值属于集成层,未来可能因为 HubSpot 重组、迁移或运营策略调整而变化。
品牌身份也不应直接使用域名。域名会变更,一个品牌可能有多个域名,一个网站也可能提供多种订阅产品,预览域名还必须拒绝真实订阅。因此需要稳定的内部键,例如 brand_a,再单独维护域名到品牌的映射。
可以这样实践数据库边界:
CREATE TABLE brands (
id text PRIMARY KEY,
name text NOT NULL,
hubspot_business_unit_id bigint,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE brand_hosts (
hostname text PRIMARY KEY,
brand_id text NOT NULL REFERENCES brands(id),
environment text NOT NULL
CHECK (environment IN ('production', 'preview')),
accepts_subscriptions boolean NOT NULL DEFAULT false
);
CREATE TABLE subscription_products (
id text PRIMARY KEY,
name text NOT NULL,
channel text NOT NULL DEFAULT 'EMAIL',
active boolean NOT NULL DEFAULT true
);
CREATE TABLE brand_subscription_routes (
brand_id text NOT NULL REFERENCES brands(id),
product_id text NOT NULL REFERENCES subscription_products(id),
mapping_version integer NOT NULL,
hubspot_subscription_type_id bigint NOT NULL,
legal_basis text,
active_from timestamptz NOT NULL,
active_until timestamptz,
PRIMARY KEY (brand_id, product_id, mapping_version)
);
HubSpot 已把 business units 更名为 Brands,但现有接口仍可能使用 businessUnitId。在内部模型中保留明确的外部字段名称,可以把术语变化限制在集成边界,而不会迫使整个应用迁移品牌主键。
按目标用途建模,而不是堆一组 ID
“把 Brand A 映射到若干列表”只是起点。Newsletter 投递、分析分群、入门流程、抑制组和历史快照具有不同语义,重试方式也不同。
可以为每个 HubSpot 目标声明类型和用途:
CREATE TYPE hubspot_target_kind AS ENUM (
'MANUAL_SEGMENT',
'DYNAMIC_SEGMENT_INPUT',
'CONTACT_PROPERTY',
'BRAND_ASSIGNMENT'
);
CREATE TABLE hubspot_targets (
id uuid PRIMARY KEY,
kind hubspot_target_kind NOT NULL,
hubspot_id text,
property_name text,
purpose text NOT NULL,
active boolean NOT NULL DEFAULT true,
CHECK (
(hubspot_id IS NOT NULL AND property_name IS NULL)
OR
(hubspot_id IS NULL AND property_name IS NOT NULL)
)
);
CREATE TABLE brand_target_mappings (
brand_id text NOT NULL REFERENCES brands(id),
product_id text NOT NULL REFERENCES subscription_products(id),
target_id uuid NOT NULL REFERENCES hubspot_targets(id),
desired_value jsonb,
mapping_version integer NOT NULL,
required boolean NOT NULL DEFAULT true,
active_from timestamptz NOT NULL,
active_until timestamptz,
PRIMARY KEY (brand_id, product_id, target_id, mapping_version)
);
这样,一个品牌可以解析为六个目标,而应用代码不需要六个分支。同一个目标也可以服务多个品牌。上线版本 13 时,版本 12 仍能为此前接收的事件提供确定的重试结果。
目标类型还决定由哪一侧拥有规则:
- 当成员资格完全由持久联系人属性推导时,使用动态细分。例如设置
newsletter_brand_a=true,再由 HubSpot 计算成员资格。 - 当外部事件无法可靠表示为 CRM 属性时,使用手工细分。
- 当分群应在首次计算后保持不变时,使用快照细分。
- 不要把细分成员资格当成唯一的同意记录或同意证据。
属性驱动可以减少 API 调用,但会增加 HubSpot 端配置的重要性;手工细分让集成承担更多工作,却可能更容易从应用侧追踪。关键是明确记录每条规则归应用还是 HubSpot 所有。
同意状态必须独立于细分成员资格
通信订阅类型表达“是否允许联系”,细分表达“联系人属于哪个运营或分析集合”。两者不能互相替代。
一个清晰的期望状态可以写成:
{
"contact": {
"external_contact_id": "usr_7T4M9Q6K",
"email": "person@example.com",
"origin_brand": "brand_a"
},
"communicationPreferences": [
{
"businessUnitId": 41857,
"subscriptionId": 39644612,
"state": "SUBSCRIBED",
"consentEvidenceId": "consent_01JXYZ"
}
],
"manualSegmentIds": ["611", "614", "702"],
"properties": {
"newsletter_brand_a": true,
"first_subscription_source": "site-a-footer"
}
}
优先级应写进业务模型,而不是依赖调用顺序:
- 已验证的退订应覆盖较旧的订阅事件。
- 品牌级退订不一定取消联系人对另一个品牌的合法订阅。
- 全局退订必须在应用任何品牌级期望状态之前检查。
- 每次决定都要保存时间、来源、适用政策和同意证据。
如果两条映射对同一订阅类型分别要求订阅和退订,worker 应在发送第一个请求前报告配置冲突。不能让“最后一次 API 调用”偶然决定合规政策。
联系人身份也应使用稳定的内部 ID。通过自定义唯一属性 external_contact_id 执行 upsert,可以让身份跨邮箱变更保持稳定,并供所有网站共享。邮箱仍需保留,但不宜把邮箱的 Base64 编码当作公开身份,也不能因为两个地址看起来相似就自动合并。
先生成纯计划,再执行网络调用
下面是一个可以直接运行的 TypeScript 示例。它假设路由查询和远程调用位于函数外部;这个纯函数只负责把快照解析为 HubSpot 执行计划并检测冲突。
将内容保存为 routing.ts,使用 Node.js 20+ 执行 npx tsx routing.ts:
type PreferenceState = 'SUBSCRIBED' | 'UNSUBSCRIBED' | 'NOT_SPECIFIED';
type Target =
| { kind: 'CONTACT_PROPERTY'; name: string; value: string | boolean | number }
| { kind: 'MANUAL_SEGMENT'; id: string; desired: 'member' | 'not_member' }
| {
kind: 'COMMUNICATION_PREFERENCE';
businessUnitId: number;
subscriptionId: number;
state: PreferenceState;
};
type MappingSnapshot = {
mappingVersion: number;
resolvedTargets: Target[];
};
type HubSpotPlan = {
mappingVersion: number;
contactProperties: Record<string, string | boolean | number>;
communicationPreferences: Array<{
businessUnitId: number;
subscriptionId: number;
state: PreferenceState;
}>;
segmentIdsToAdd: string[];
segmentIdsToRemove: string[];
};
function buildHubSpotPlan(snapshot: MappingSnapshot): HubSpotPlan {
const plan: HubSpotPlan = {
mappingVersion: snapshot.mappingVersion,
contactProperties: {},
communicationPreferences: [],
segmentIdsToAdd: [],
segmentIdsToRemove: []
};
const preferenceStates = new Map<string, PreferenceState>();
for (const target of snapshot.resolvedTargets) {
if (target.kind === 'CONTACT_PROPERTY') {
const current = plan.contactProperties[target.name];
if (current !== undefined && current !== target.value) {
throw new Error(`Conflicting property value: ${target.name}`);
}
plan.contactProperties[target.name] = target.value;
}
if (target.kind === 'MANUAL_SEGMENT') {
const add = target.desired === 'member';
const opposite = add ? plan.segmentIdsToRemove : plan.segmentIdsToAdd;
if (opposite.includes(target.id)) {
throw new Error(`Conflicting segment state: ${target.id}`);
}
(add ? plan.segmentIdsToAdd : plan.segmentIdsToRemove).push(target.id);
}
if (target.kind === 'COMMUNICATION_PREFERENCE') {
const key = `${target.businessUnitId}:${target.subscriptionId}`;
const current = preferenceStates.get(key);
if (current !== undefined && current !== target.state) {
throw new Error(`Conflicting communication preference: ${key}`);
}
preferenceStates.set(key, target.state);
plan.communicationPreferences.push(target);
}
}
return plan;
}
const snapshot: MappingSnapshot = {
mappingVersion: 12,
resolvedTargets: [
{ kind: 'CONTACT_PROPERTY', name: 'newsletter_brand_a', value: true },
{ kind: 'MANUAL_SEGMENT', id: '611', desired: 'member' },
{
kind: 'COMMUNICATION_PREFERENCE',
businessUnitId: 41857,
subscriptionId: 39644612,
state: 'SUBSCRIBED'
}
]
};
console.log(JSON.stringify(buildHubSpotPlan(snapshot), null, 2));
纯路由函数适合使用表驱动测试覆盖所有品牌、产品、订阅状态转换、映射版本和冲突组合,而且不需要发出网络请求。真正执行 API 调用的适配器只接收已经验证过的计划。
事件必须保存解析快照
队列事件可能几分钟后处理,也可能几天后重放。如果 worker 每次都读取最新映射,同一事件会在普通重试中产生不同结果。
接收事件时应同时保存:
{
"eventId": "evt_01JXYZ",
"mappingVersion": 12,
"resolvedTargets": [
{
"kind": "CONTACT_PROPERTY",
"name": "newsletter_brand_a",
"value": true
},
{
"kind": "MANUAL_SEGMENT",
"id": "611",
"desired": "member"
},
{
"kind": "BRAND_ASSIGNMENT",
"id": "41857"
}
],
"communicationPreference": {
"businessUnitId": 41857,
"subscriptionId": 39644612,
"state": "SUBSCRIBED"
}
}
普通重试使用原快照。需要采用新映射时,迁移或对账流程显式创建一个新事件。这样才能保证幂等重试可解释,并准确回答某次操作使用了哪一版规则。
激活配置前,把远程对象也纳入验证
数据库外键只能证明本地引用存在,不能证明 HubSpot 中的对象有效。激活新版本前,验证任务应读取 HubSpot 的订阅定义、Brands、联系人属性和细分元数据,再与待发布配置比较。
发布门禁至少应检查:
- 每个生产域名只映射到一个启用的内部品牌。
- 每个对外提供的产品都有生效中的通信订阅路由。
- 手工成员资格目标实际属于
MANUAL或SNAPSHOT,不能指向DYNAMIC。 - 自定义联系人属性存在,类型和唯一性设置符合预期。
businessUnitId属于当前集成可访问的 HubSpot Brand。- 每条路由有明确生效时间,版本号单调递增。
- 同一计划内不存在属性、细分或通信偏好的矛盾状态。
用“第 41 个网站”检验架构
这套模型是否真正扩展,取决于增加下一个品牌时要做什么。理想流程是:录入品牌与批准域名,配置 HubSpot Brand、订阅类型、目标细分和属性,运行远程验证,审核并激活新版本,然后用合成联系人走完整 worker。
如果仍需修改 switch 语句,配置边界还不完整;如果完全不需要审核,变更流程又过于宽松。路由配置应享受与代码相同的保护:类型约束、测试、版本历史、审批、预览、可审计的激活记录,以及崩溃重试、限流、死信和对账机制。
落地时可以用这份清单收尾:
- 使用稳定内部 ID 表示品牌,域名只负责入口映射。
- 把 HubSpot 数字 ID 当作外部集成配置。
- 将通信同意与细分成员资格分开建模。
- 为每个目标声明用途、所有者和更新语义。
- 在持久属性足以推导成员资格时优先使用动态细分。
- 尽可能通过自定义唯一属性 upsert 联系人。
- 对映射做版本控制,并在接收事件时保存解析快照。
- 激活前验证 HubSpot 远程对象和类型。
- 在发送请求前构建完整计划并检测冲突。
- 用合成事件完成新增品牌的端到端验收。
配置不是散落在数据库里的常量。只要它决定联系人能否被联系、进入哪些流程以及如何归因,它就是可执行的业务逻辑,也应该按生产代码的标准管理。