Architecture brief · 09

LINE 推送组件化重构

周四估价 Banner 与周五 BuyerViewing 已完成分支内重构。准备端留在 house-resource,任务和发送能力集中到 line;独立 line-push 包不属于最终发布依赖。

01 / Architecture

计算与发送拆开

house-resource 计算名单并冻结 payload;line 统一管理任务并投递。现有 BFF 卡片接口继续复用。

  1. 01业务计算名单、规则、频控
  2. 02冻结 payload发送时不再重算
  3. 03line 任务 API建立总任务与明细
  4. 04公共发送器扫描、加锁、并发、重试
  5. 05BFF 模板既有卡片接口
  6. 06LINEMessaging API

重构前

  • 任务编排分散在业务服务。
  • 并发、锁、重试重复实现。
  • 新增推送需要复制发送流程。

重构后

  • 业务只提交冻结候选。
  • line 统一任务生命周期和投递。
  • 新增业务只补注册、校验和 BFF 合同。
最终落地选择

house-resource@785d5b216 负责候选、冻结 payload、任务提交、BuyerViewing 预检和有度提醒;line@7593abe 负责任务 API、SchemaGate、公共发送、锁、限速和结果回写。tw591pk/line-push@73829dd 是早期设计验证,本轮业务仓库没有引用。

02 / Migration

周四、周五怎么变

名单产生方式不变;任务写入和发送能力统一。旧单人试发命令继续保留。

项目周四估价 Banner周五 BuyerViewing
名单来源数据组写入 bi_data.line_user_push后端运行 buyer-viewing:generate-batch
业务计算复用 Campaign、URL Builder、批次规则复用筛选、房源、匹配 RPC、CardBuilder
任务写入house-resource 调用 line 任务 API
旧发送器owner-valuation-banner:dispatchbuyer-viewing:dispatch
新发送器line-push:dispatch-components
默认并发5 → 1510 → 15
BFF 卡片/v1/liff/notify/v1/line/host-entrust-sub
成功合同HTTP 2xx 且 status === 1HTTP 2xx;包含 status 时必须为 1
追踪字段请求头有 retry key;请求体当前没有 rid请求头有 retry key;请求体有 rid=delivery_id
单人试发owner-valuation-banner:testbuyer-viewing:send-one
周四固定规则

周四 DAG 按台北日期生成 YYYYMMDD001(增量)和 YYYYMMDD003(存量),分别精确读取当周批次;不会扫描历史,也不会回退上一周。数据组须在执行前写入两个批次,当周无数据时不建立发送任务。

03 / State

自动与人工两条轨道

自动任务等待计划时间;人工任务按 task_id 直接取锁发送,因此不会被自动扫描同时取走。

SCHEDULED
4PREPARING5PENDING6SENDING2SUCCESS

到达 plan_time 后由公共发送器处理。

MANUAL
4PREPARING6SENDING2SUCCESS

指定 task_id,获取任务锁后立即发送。

两种模式共用任务级分布式锁。失败明细保留待重试;任务到达 plan_time 后,公共扫描器继续补发。

周四、周五在任务及候选明细写完后,向工号 10467 提醒一次。内容包含业务、批次、task_id、模式、计划时间及待推送人数;dry-run、零候选或幂等重跑未新增明细时不提醒。

04 / Onboarding

第三个需求要提供什么

接入重点是稳定业务标识、冻结数据和明确 BFF 合同。公共发送流程无需复制。

产品

business_type、轮次规则、模式、时间、截止时间、频控、排除条件。

验收:同轮标识稳定;迟到任务有处理规则。

数据/业务后端

分页候选、稳定 business_idline_iduser_id、冻结 payload

验收:可重跑、可对数;失败查询返回 retry。

BFF/前端

卡片模板、字段、URL 或 postback、UTM、rid、成功返回合同。

验收:链接在白名单;曝光与点击可归因。

line

在仓库内注册业务类型、payload 校验、预检、BFF 映射和并发配置;不安装独立 line-push 包。

验收:任务锁、重试与统计通过聚焦测试。

16 KiB单条 payload 上限
500单批候选上限
1 MiB整批 JSON 上限
代码注册当前仍非纯配置上线

05 / Example

第三个推送如何完整接入

以下以「屋主房源成效周报」为示例。名称、字段和时间可替换,但业务标识、冻结 payload、BFF 合同和验收边界必须明确。

设定项示例值规则
业务类型owner_listing_health_component上线后不可随意改名
任务标识business_key=2026-W40同一业务轮次保持唯一
候选标识hash(batch:user_id:listing_id)重跑仍产生相同 business_id
模式/时间scheduled/周一 10:00到达 plan_time 后由公共发送器处理
默认并发15最大护栏 32;压测后才能提高

业务/产品

提供推送目的、目标人群、排除条件、频控、计划时间、截止时间、自动/人工模式、重复执行规则和指标口径。

本例:近 7 日仍在线的屋主;同一房源每周最多一次;退订、封锁及已下架排除。

前端/BFF

定义模板、字段类型、空值、按钮、跳转白名单、UTM、rid、retry key、成功响应和曝光/点击事件。

本例:POST /v1/line/owner-listing-health;成功必须返回 {"status":1}

业务后端

实现分页候选查询;产生稳定 business_id;冻结 line_iduser_id 和 payload;调用 line 的 prepare、append、publish。

禁止发送阶段重新计算卡片;重跑不能生成第二份候选。

line 后端

注册 business_type、payload 校验器、BFF 路由、官方帐号、默认并发、预检/频控和成功合同。

BFF 投递沿用既有服务认证;任务 API 单独使用 LINE_PUSH_TASK_AUTH

排程/运维

业务 DAG 只负责准备任务;line 公共 DAG 每 5 分钟扫描。配置服务地址、鉴权、监控、失败告警和暂停开关。

先以人工模式验收一个测试收件人,再启用自动排程。

业务后端提交给 line 的任务合同

以下为接口示例,不是当前已存在的命令。

POST /line-push/tasks
{
  "business_type": "owner_listing_health_component",
  "business_key": "2026-W40",
  "plan_time": "2026-09-28 10:00:00"
}

POST /line-push/tasks/301/candidates
{
  "candidates": [{
    "business_id": 781245001,
    "line_id": "Uxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "user_id": 591001,
    "payload": {
      "listing_id": "S12345678",
      "impression_count": 320,
      "contact_count": 8,
      "jump_url": "https://example.591.com.tw/report?utm_source=line&rid=..."
    }
  }]
}

POST /line-push/tasks/301/publish

BFF 卡片合同

line 发送器携带服务鉴权和幂等键;BFF 负责模板与 LINE 消息组装。

POST /v1/line/owner-listing-health
X-Service-Auth: <既有 BFF 服务认证值>
X-Line-Retry-Key: <delivery_id>

{
  "line_id": "Uxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "official": "t591",
  "user_id": "591001",
  "rid": "<delivery_id>",
  "payload": { "listing_id": "S12345678", "impression_count": 320 }
}

建议成功合同:HTTP 2xx 且 {"status":1}
实际合同须按业务注册并测试;失败明细保留待重试
接入完成定义

同一 fixture 下,旧业务计算结果与新 Adapter 冻结 payload 一致;单人卡片、人工任务、自动任务、重复执行、失败重试和 SQL 统计全部通过后,才允许解除 DAG 暂停。

06 / Verification

先自动化,再看卡片

结果对应 2026-09-22 最终分支:house-resource 785d5b216、line 7593abe。单人试发用于验证真实卡片,不写入组件任务表。

验证层覆盖范围结果
house-resource本分支新增/修改的两条业务 Adapter、任务提交、属性读取及有度提醒36 项测试 · 159 个断言通过
line任务 API、状态机、锁、重试、并发、人工发送、服务鉴权48 项测试 · 172 个断言通过
静态检查最终提交中的 Adapter、发送器与鉴权相关代码分支已有 PHPStan 通过记录
验证边界

house-resource 不表述为全量测试通过。line 的结果使用标准 PHPUnit;co-phpunit 会进入协程环境,两条专门验证「非协程退回串行」的断言会得到并发 15。真实 BFF、LINE 卡片和生产任务仍须按下方步骤验收。


周四 · Banner 单人试发

存量卡片可将批次改为 20260917003。

php bin/hyperf.php owner-valuation-banner:test \
  --line-id=Uxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
  --push-batch=20260917001

周五 · BuyerViewing 单人试发

需已关注、已绑定、有符合条件物件和潜在买家。

php bin/hyperf.php buyer-viewing:send-one \
  --line-id=Uxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

组件人工任务

house-resource 只准备;line 按 task_id 立即发送。

# house-resource
php bin/hyperf.php buyer-viewing:line-push-prepare \
  --push-batch=<YYYYMMDD591> \
  --run-at="YYYY-MM-DD HH:mm:ss" \
  --mode=manual

# line
php bin/hyperf.php line-push:dispatch-components \
  --task-id=<task_id> \
  --batch-size=100
测试边界

直接打开 Mini App 只能验证落地页。真实卡片须在 LINE 客户端验收;试发会消耗额度,也可能产生曝光与 GA 数据。

07 / Monitoring

实时进展查询

查询前先确认目标环境已有组件字段与唯一索引。以下 SQL 默认数据库为 t591_new

最近任务
SELECT id AS task_id, business_type, business_key, status,
       CASE status
         WHEN 2 THEN 'SUCCESS' WHEN 3 THEN 'DELETED'
         WHEN 4 THEN 'PREPARING' WHEN 5 THEN 'PENDING'
         WHEN 6 THEN 'SENDING' ELSE CONCAT('UNKNOWN_', status)
       END AS status_name,
       plan_time, send_total, suc_total, created_at
FROM t591_new.push_line_task
WHERE business_type IN (
  'owner_valuation_banner_component',
  'buyer_viewing_component'
)
ORDER BY id DESC
LIMIT 30;
指定任务实时进度
SET @task_id := 12345;

SELECT t.id AS task_id, t.business_type, t.business_key,
       t.status AS task_status, t.plan_time, t.send_total, t.suc_total,
       COUNT(r.id) AS record_total,
       SUM(r.status = 0) AS pending_count,
       SUM(r.status = 1) AS success_count,
       SUM(r.status = 2) AS failure_count,
       SUM(r.status = 3) AS arrive_count,
       SUM(r.status = 4) AS skipped_count,
       SUM(r.status = 5) AS sending_count,
       ROUND(100 * SUM(r.status IN (1,2,3,4)) /
             NULLIF(COUNT(r.id), 0), 2) AS settled_percent,
       MIN(r.updated_at) AS first_updated_at,
       MAX(r.updated_at) AS last_updated_at
FROM t591_new.push_line_task AS t
LEFT JOIN t591_new.push_line_records AS r ON r.pid = t.id
WHERE t.id = @task_id
GROUP BY t.id, t.business_type, t.business_key, t.status,
         t.plan_time, t.send_total, t.suc_total;
待重试与异常明细
SET @task_id := 12345;

SELECT id AS record_id, business_id, line_id, user_id,
       delivery_id, status, updated_at
FROM t591_new.push_line_records
WHERE pid = @task_id
  AND status IN (0, 2, 4, 5)
ORDER BY status, id
LIMIT 1000;
每分钟处理量
SET @task_id := 12345;

SELECT DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:00') AS minute_bucket,
       SUM(status = 1) AS success_count,
       SUM(status = 2) AS failure_count,
       SUM(status = 4) AS skipped_count,
       COUNT(*) AS settled_count
FROM t591_new.push_line_records
WHERE pid = @task_id AND status IN (1, 2, 4)
GROUP BY DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:00')
ORDER BY minute_bucket;

updated_at 是状态更新时间,仅用于观察趋势。

字段与唯一约束检查
SHOW FULL COLUMNS FROM t591_new.push_line_task;
SHOW INDEX FROM t591_new.push_line_task;
SHOW FULL COLUMNS FROM t591_new.push_line_records;
SHOW INDEX FROM t591_new.push_line_records;

SELECT business_type, business_key, COUNT(*) AS duplicate_count
FROM t591_new.push_line_task
WHERE business_type IS NOT NULL AND business_key IS NOT NULL
GROUP BY business_type, business_key
HAVING COUNT(*) > 1;

SELECT business_type, business_id, COUNT(*) AS duplicate_count
FROM t591_new.push_line_records
WHERE business_type IS NOT NULL AND business_id IS NOT NULL
GROUP BY business_type, business_id
HAVING COUNT(*) > 1;

08 / Release

上线前检查

正式批次只允许一个发送器处理。公共 Airflow DAG 部署完成并验证后再解除暂停。

  • 目标库字段与唯一索引通过检查
  • LINE_PUSH_TASK_AUTH 在任务提交方与 line 一致
  • line 到既有 BFF 的服务认证值保持可用
  • market BFF、house BFF、house-resource 地址已配置
  • 模板、链接、rid 覆盖范围和 retry key 已试发
  • 独立测试批次完成组件人工任务验证
  • 公共 Airflow DAG 部署后再解除暂停
  • 新旧发送器不会处理同一正式批次
  • 发送端与曝光回调完成联合压测