-- Petstore Phase 0: persist immutable business events for funnels and timelines. -- Target: MySQL. Run after 20260801_create_store_customer.sql. -- One-time migration; back up the database before execution. CREATE TABLE t_business_event ( id BIGINT NOT NULL AUTO_INCREMENT, event_id VARCHAR(36) NOT NULL COMMENT '随机事件 ID', event_type VARCHAR(64) NOT NULL COMMENT '过去时业务事件类型', event_version INT NOT NULL DEFAULT 1, store_id BIGINT NOT NULL, store_customer_id BIGINT NULL COMMENT '门店客户稳定关系,可空', aggregate_type VARCHAR(32) NOT NULL, aggregate_id BIGINT NOT NULL, actor_user_id BIGINT NULL, actor_role VARCHAR(16) NULL, source VARCHAR(32) NOT NULL, occurred_at DATETIME(3) NOT NULL, metadata_json TEXT NOT NULL COMMENT '仅低敏白名单维度 JSON', idempotency_key VARCHAR(128) NULL, create_time DATETIME(3) NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_business_event_event_id (event_id), UNIQUE KEY uk_business_event_idempotency (idempotency_key), KEY idx_be_store_type_time (store_id, event_type, occurred_at), KEY idx_be_aggregate_time (aggregate_type, aggregate_id, occurred_at), KEY idx_be_store_customer_time (store_customer_id, occurred_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='不可变业务事件'; -- 历史预约只回填可确定的“预约已创建”,不猜测无法还原的开始服务时间。 INSERT INTO t_business_event ( event_id, event_type, event_version, store_id, store_customer_id, aggregate_type, aggregate_id, actor_user_id, actor_role, source, occurred_at, metadata_json, idempotency_key, create_time ) SELECT UUID(), 'appointment_created', 1, a.store_id, sc.id, 'appointment', a.id, a.created_by_user_id, actor.role, CASE WHEN a.created_by_user_id IS NULL THEN 'migration' WHEN a.created_by_user_id = a.customer_user_id THEN 'customer' ELSE 'admin' END, COALESCE(a.create_time, a.appointment_time, NOW()), CASE WHEN a.created_by_user_id IS NULL THEN '{"bookingOrigin":"migration"}' WHEN a.created_by_user_id = a.customer_user_id THEN '{"bookingOrigin":"customer"}' ELSE '{"bookingOrigin":"admin"}' END, CONCAT('appointment_created:', a.id), NOW() FROM t_appointment a LEFT JOIN t_store_customer sc ON sc.store_id = a.store_id AND sc.customer_user_id = a.customer_user_id AND sc.deleted = 0 LEFT JOIN t_user actor ON actor.id = a.created_by_user_id AND actor.deleted = 0 WHERE a.deleted = 0 AND a.store_id IS NOT NULL; -- 历史报告提交是可靠事实;不保存 report_token 或其 hash。 INSERT INTO t_business_event ( event_id, event_type, event_version, store_id, store_customer_id, aggregate_type, aggregate_id, actor_user_id, actor_role, source, occurred_at, metadata_json, idempotency_key, create_time ) SELECT UUID(), 'report_submitted', 1, r.store_id, sc.id, 'report', r.id, r.author_staff_id, actor.role, CASE WHEN r.author_staff_id IS NULL THEN 'migration' ELSE 'admin' END, COALESCE(r.create_time, NOW()), '{}', CONCAT('report_submitted:', r.id), NOW() FROM t_report r LEFT JOIN t_store_customer sc ON sc.store_id = r.store_id AND sc.customer_user_id = r.customer_user_id AND sc.deleted = 0 LEFT JOIN t_user actor ON actor.id = r.author_staff_id AND actor.deleted = 0 WHERE r.deleted = 0 AND r.store_id IS NOT NULL; -- 有报告的完成时间可由报告创建时间可靠近似;无报告的历史 done 不做猜测。 INSERT INTO t_business_event ( event_id, event_type, event_version, store_id, store_customer_id, aggregate_type, aggregate_id, actor_user_id, actor_role, source, occurred_at, metadata_json, idempotency_key, create_time ) SELECT UUID(), 'service_completed', 1, r.store_id, sc.id, 'appointment', r.appointment_id, r.author_staff_id, actor.role, CASE WHEN r.author_staff_id IS NULL THEN 'migration' ELSE 'admin' END, COALESCE(r.create_time, NOW()), '{}', CONCAT('service_completed:', r.appointment_id), NOW() FROM t_report r LEFT JOIN t_store_customer sc ON sc.store_id = r.store_id AND sc.customer_user_id = r.customer_user_id AND sc.deleted = 0 LEFT JOIN t_user actor ON actor.id = r.author_staff_id AND actor.deleted = 0 WHERE r.deleted = 0 AND r.store_id IS NOT NULL AND r.appointment_id IS NOT NULL; -- 历史留资每条只形成一个 lead_submitted 事实;不复制手机号、IP、openid 或退订 token。 INSERT INTO t_business_event ( event_id, event_type, event_version, store_id, store_customer_id, aggregate_type, aggregate_id, actor_user_id, actor_role, source, occurred_at, metadata_json, idempotency_key, create_time ) SELECT UUID(), 'lead_submitted', 1, l.store_id, sc.id, 'report_lead', l.id, NULL, NULL, 'public_report', COALESCE(l.consent_at, l.create_time, l.update_time, NOW()), '{"repeatSubmit":false}', CONCAT('lead_submitted:', l.id), NOW() FROM t_report_lead l LEFT JOIN t_store_customer sc ON sc.store_id = l.store_id AND sc.phone = l.phone AND sc.deleted = 0 WHERE l.store_id IS NOT NULL; -- Verification queries. All four counts should be 0 before application release. SELECT COUNT(*) AS appointments_without_created_event FROM t_appointment a LEFT JOIN t_business_event e ON e.idempotency_key = CONCAT('appointment_created:', a.id) WHERE a.deleted = 0 AND a.store_id IS NOT NULL AND e.id IS NULL; SELECT COUNT(*) AS reports_without_submitted_event FROM t_report r LEFT JOIN t_business_event e ON e.idempotency_key = CONCAT('report_submitted:', r.id) WHERE r.deleted = 0 AND r.store_id IS NOT NULL AND e.id IS NULL; SELECT COUNT(*) AS leads_without_submitted_event FROM t_report_lead l LEFT JOIN t_business_event e ON e.idempotency_key = CONCAT('lead_submitted:', l.id) WHERE l.store_id IS NOT NULL AND e.id IS NULL; SELECT COUNT(*) AS events_without_store FROM t_business_event WHERE store_id IS NULL;