重构前
- 任务编排分散在业务服务。
- 并发、锁、重试重复实现。
- 新增推送需要复制发送流程。
Architecture brief · 09
周四估价 Banner 与周五 BuyerViewing 已完成分支内重构。准备端留在 house-resource,任务和发送能力集中到 line;独立 line-push 包不属于最终发布依赖。
01 / Architecture
house-resource 计算名单并冻结 payload;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:dispatch | buyer-viewing:dispatch |
| 新发送器 | line-push:dispatch-components | |
| 默认并发 | 5 → 15 | 10 → 15 |
| BFF 卡片 | /v1/liff/notify | /v1/line/host-entrust-sub |
| 成功合同 | HTTP 2xx 且 status === 1 | HTTP 2xx;包含 status 时必须为 1 |
| 追踪字段 | 请求头有 retry key;请求体当前没有 rid | 请求头有 retry key;请求体有 rid=delivery_id |
| 单人试发 | owner-valuation-banner:test | buyer-viewing:send-one |
周四 DAG 按台北日期生成 YYYYMMDD001(增量)和 YYYYMMDD003(存量),分别精确读取当周批次;不会扫描历史,也不会回退上一周。数据组须在执行前写入两个批次,当周无数据时不建立发送任务。
03 / State
自动任务等待计划时间;人工任务按 task_id 直接取锁发送,因此不会被自动扫描同时取走。
到达 plan_time 后由公共发送器处理。
指定 task_id,获取任务锁后立即发送。
两种模式共用任务级分布式锁。失败明细保留待重试;任务到达 plan_time 后,公共扫描器继续补发。
周四、周五在任务及候选明细写完后,向工号 10467 提醒一次。内容包含业务、批次、task_id、模式、计划时间及待推送人数;dry-run、零候选或幂等重跑未新增明细时不提醒。
04 / Onboarding
接入重点是稳定业务标识、冻结数据和明确 BFF 合同。公共发送流程无需复制。
business_type、轮次规则、模式、时间、截止时间、频控、排除条件。
验收:同轮标识稳定;迟到任务有处理规则。
分页候选、稳定 business_id、line_id、user_id、冻结 payload。
验收:可重跑、可对数;失败查询返回 retry。
卡片模板、字段、URL 或 postback、UTM、rid、成功返回合同。
验收:链接在白名单;曝光与点击可归因。
在仓库内注册业务类型、payload 校验、预检、BFF 映射和并发配置;不安装独立 line-push 包。
验收:任务锁、重试与统计通过聚焦测试。
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 日仍在线的屋主;同一房源每周最多一次;退订、封锁及已下架排除。
定义模板、字段类型、空值、按钮、跳转白名单、UTM、rid、retry key、成功响应和曝光/点击事件。
本例:POST /v1/line/owner-listing-health;成功必须返回 {"status":1}。
实现分页候选查询;产生稳定 business_id;冻结 line_id、user_id 和 payload;调用 line 的 prepare、append、publish。
禁止发送阶段重新计算卡片;重跑不能生成第二份候选。
注册 business_type、payload 校验器、BFF 路由、官方帐号、默认并发、预检/频控和成功合同。
BFF 投递沿用既有服务认证;任务 API 单独使用 LINE_PUSH_TASK_AUTH。
业务 DAG 只负责准备任务;line 公共 DAG 每 5 分钟扫描。配置服务地址、鉴权、监控、失败告警和暂停开关。
先以人工模式验收一个测试收件人,再启用自动排程。
以下为接口示例,不是当前已存在的命令。
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/publishline 发送器携带服务鉴权和幂等键;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 卡片和生产任务仍须按下方步骤验收。
存量卡片可将批次改为 20260917003。
php bin/hyperf.php owner-valuation-banner:test \ --line-id=Uxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ --push-batch=20260917001
需已关注、已绑定、有符合条件物件和潜在买家。
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
https://miniapp.line.me/2008479301-N96xEdy5/my-househttps://miniapp.line.me/2008479303-zpEB3WDQ/my-house/v1/liff/notify /v1/line/host-entrust-sub直接打开 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 一致