Template Variable Data Pipeline - 从原始数据到邮件渲染的完整链路追踪与修复记录 | 2026-02-17
Celoria 的模板变量数据流经 5 个阶段:
管道断裂可以发生在任何阶段之间。本次审计发现的问题集中在 阶段1→2(写入未触发)和 阶段3→4(字段名对接失败)。
buildVariableContext()buildPromotionContext()buildServiceContext()buildTemplateData()已修复 guests 表有 total_visits 和 total_spent 列,但 appointmentHandler 在预约完成时只更新 last_visit_date,不更新统计字段。
修复:新增 updateGuestStats() 方法,使用子查询 COUNT(*) / SUM(total_amount) 从 appointments 表重算,避免并发自增漂移。
文件:backend/services/tagging/eventHandlers/appointmentHandler.js
已修复 promotion-context.js 声明了 favorite_service 变量,但没有任何服务计算和写入它。
修复:在 inferredPreferenceService.js 新增 calculateAndUpdateFavoriteService(),使用时间衰减加权算法(与 inferred_preferred_time 相同权重配置),将结果写入 guests.preferences JSONB。在 appointmentHandler 中于预约完成时触发(非阻塞)。
文件:backend/services/guest/inferredPreferenceService.js、appointmentHandler.js
已修复 outreachService 的 3 个 SQL 查询(getPendingOutreach、getPendingFallbacks、getParticipantWithGuest)没有 SELECT guests 的行为字段,buildTemplateData 也不输出行为变量。
修复:在 3 个 SQL 中添加 g.total_visits, g.total_spent, g.last_visit_date, g.vip_status, g.preferences;在 buildTemplateData 中添加 JSONB 解析和 5 个行为变量。
文件:backend/services/research/outreachService.js
已修复 buildVariableContext() 直接读 recipient.favorite_service 和 recipient.preferred_time_slot,但 guests 表没有这两个列。数据存在 guests.preferences JSONB 中。
修复:在函数头部添加 preferences JSONB 解析,将 favorite_service 和 preferred_time_slot 的读取改为 fallback 链。
文件:backend/services/templating/variable-engine.js
47 个变量,用于通用营销邮件。格式:{{variable_name}}
| 变量 | 数据来源 | 状态 |
|---|---|---|
customer_name | guests.name / guests.first_name | OK |
first_name | guests.first_name 或从 name 拆分 | OK |
last_name | guests.last_name 或从 name 拆分 | OK |
email | guests.email | OK |
phone | guests.phone | OK |
preferred_language | guests.preferred_language | OK |
| 变量 | 数据来源 | 状态 |
|---|---|---|
appointment_id | 由调用方传入 campaign 对象 | OK |
appointment_time | appointments.scheduled_time | OK |
appointment_date | appointments.scheduled_date | OK |
service_name | services.name | OK |
services | 数组,由调用方传入 | OK |
technician_name | employees.name | OK |
total_amount | appointments.total_amount | OK |
| 变量 | 数据来源 | 状态 |
|---|---|---|
store_name | centers.name | OK |
store_address | centers.address | OK |
store_phone | centers.phone | OK |
store_hours | centers.business_hours | OK |
store_logo | centers.logo_url | OK |
store_website | centers.website | OK |
| 变量 | 数据来源 | 状态 |
|---|---|---|
discount_code | campaign 配置传入 | OK |
discount_amount | campaign 配置传入 | OK |
discount_percentage | campaign 配置传入 | OK |
discount_description | campaign 配置传入 | OK |
expiry_date | campaign 配置传入 | OK |
promotion_title | campaign 配置传入 | OK |
promotion_description | campaign 配置传入 | OK |
| 变量 | 数据来源 | 状态 | 备注 |
|---|---|---|---|
loyalty_points | guests.loyalty_points | OK | |
loyalty_tier | guests.vip_status | OK | |
is_vip | 派生: vip_status === 'VIP' | OK | |
membership_level | guests.membership_level | 空壳 | guests 表无此列,永远返回 'basic' |
| 变量 | 数据来源 | 状态 | 写入方 |
|---|---|---|---|
last_visit_date | guests.last_visit_date | OK | appointmentHandler.updateGuestLastVisitDate() |
days_since_visit | 运行时计算 | OK | Math.floor((now - last_visit_date) / day_ms) |
visit_count | guests.total_visits | 已修复 | appointmentHandler.updateGuestStats() — COUNT(*) |
total_spend | guests.total_spent | 已修复 | appointmentHandler.updateGuestStats() — SUM(total_amount) |
favorite_service | guests.preferences JSONB | 已修复 | inferredPreferenceService.calculateAndUpdateFavoriteService() — 时间衰减加权 |
preferred_time_slot | guests.preferences JSONB | 已修复 | inferredPreferenceService.calculateAndUpdateInferredPreferredTime() |
tags | guest_tags 关联表 | 依赖调用方 | 需调用方 JOIN guest_tags 后传入数组 |
| 变量 | 来源 | 状态 |
|---|---|---|
unsubscribe_link | 运行时: ${API_BASE_URL}/api/promotion/unsubscribe/${id} | OK |
view_in_browser_link | 运行时: ${API_BASE_URL}/api/promotion/view/${id} | OK |
booking_link | 运行时: ${WEB_BASE_URL}/booking | OK |
booking_link_with_service | 运行时: booking?service=${id} | OK |
feedback_link | 运行时拼接 | OK |
rewards_link | 静态: ${WEB_BASE_URL}/rewards | OK |
account_link | 静态: ${WEB_BASE_URL}/account | OK |
current_year | 运行时 | OK |
current_date | 运行时 | OK |
current_time | 运行时 | OK |
favorite_service 和 preferred_time_slot 的计算都使用同一套时间衰减权重:
| 时间范围 | 权重 | 说明 |
|---|---|---|
| 近 1 个月 | 1.0 | 最近行为权重最高 |
| 1 - 3 个月 | 0.7 | |
| 3 - 6 个月 | 0.5 | |
| 6 个月以上 | 0.3 | 远期行为仍有参考价值 |
favorite_service 算法:查询该客户所有已完成预约 JOIN services,按服务名分组,对每笔预约按时间衰减加权累加分数,取最高分服务写入 guests.preferences.favorite_service。
preferred_time_slot 算法:查询已完成预约的时间,按时段(morning 9-12 / afternoon 12-17 / evening 17-21)分组,同样加权累加,取最高分时段写入 guests.preferences.inferred_preferred_time。
SELECT id, name, email, phone 扩充为包含 first_name, last_name, total_visits, total_spent, last_visit_date, vip_status, loyalty_points, preferencessendEmail() 从只传 {email, name, id} 改为传递完整 recipient 对象,使 buildVariableContext() 能读取全部行为字段total_visits, total_spent, last_visit_date, vip_status, loyalty_points, preferences,guest 对象透传全部字段membership_level 改为读取 vip_status || 'basic',不再是空壳硬编码guest_tags 子查询聚合。promotion-context + variable-engine 支持 {{tags}} 和 {{#each tags}}days_since_visit, loyalty_points, membership_level, is_vip, preferred_time_slot, tags 6 个缺失变量,与系统 1/2 对齐| 问题 | 严重度 | 说明 |
|---|---|---|
| service-context 未加行为字段 | 已评估 | 预约通知模板使用 {{#each guests}} 数组结构,行为字段对其场景无意义,不修改 |
| tenant_perftest 已清除 | 已完成 | Schema 已 DROP(12 GB / 995K guests / 105 tables)。功能定型后需重新生成性能测试数据 |