可集成 Kannel 的开源计费解决方案
结论
Kannel 目前没有公认的、开箱即用并能直接承担大规模 SMS 计费的通用开源方案。常见的 billing 平台可以管理账户、费率、账单和支付,但接入 Kannel 时仍要开发适配层。面对数千条 SMS/sec 的吞吐量,围绕 Kannel 建立异步计费流水,通常比直接套用传统计费系统更可靠。
建议采用以下组合:
- Kannel 处理 SMS 接入、路由和状态回执。
- 独立服务生成不可变的 usage event。
- 使用 Kafka、Pulsar 或 RabbitMQ 解耦消息发送和计费。
- rating worker 根据用户、运营商、国家、消息类型及时间段计算费用。
- 使用 PostgreSQL、ClickHouse 等数据库分别存储账务数据和报表数据。
- 如果需要完整的客户、账单和支付功能,再将汇总后的计费结果同步到 Apache Fineract、Kill Bill 等开源平台。
这套方案并非完全”零开发”,但可以避免从头实现账务、支付和客户管理,也不会让外部 billing 平台拖慢 Kannel。
为什么不建议直接同步计费
Kannel 是消息网关,不是 billing engine。发送请求、运营商接收和最终送达属于三个不同事件,计费依据也可能不同:
- 按提交成功计费;
- 按运营商接受计费;
- 按最终送达计费;
- 失败消息不收费;
- 长短信按拆分后的 SMS segment 数量收费;
- 不同目的地、供应商和路由采用不同费率。
如果在 Kannel 的 HTTP 请求链路中同步查询费率、扣费并写入多个数据库,计费服务一旦变慢,消息发送的吞吐量也会下降。采用异步计费后,Kannel 可以先完成消息处理,再由后台批量执行 rating 和账务入账。
预付费业务需要单独考虑。如果系统必须严格禁止超额发送,就要在发送前预留余额。这需要一个低延迟的在线余额服务,不能完全依赖离线计费。
推荐架构
Client
|
v
Submission API
|---- validate user / quota
|---- generate message_id
|---- write submission event
|
v
Kannel
|
+---- upstream SMSC
|
+---- DLR callback
|
v
Event collector
|
v
Kafka / Pulsar
| |
v v
Rating worker Raw event archive
|
+---- Billing ledger
|
+---- Reporting database
|
+---- External billing platform
Submission API 可以放在 Kannel /cgi-bin/sendsms 前面作为代理,也可以由现有业务系统直接生成 usage event。每次提交都要有稳定的业务 message_id,并在提交事件、Kannel 请求和 DLR 回调之间传递这一标识。
正式计费不宜只依靠普通文本日志。日志可以用于审计和故障恢复,但格式变更、日志轮转、重复采集和部分写入都会增加账务风险。
实施步骤
1. 先定义计费事件
不要直接把”消息表”当成账本。更合适的做法是保存不可变事件,例如:
{
"event_id": "01J8QYH76S9M0J2A4B8K3P5R7T",
"message_id": "msg-20260901-00001234",
"event_type": "sms.submitted",
"occurred_at": "2026-09-01T08:15:30.123Z",
"account_id": "customer-1024",
"route_id": "cmcc-primary",
"destination": "86138******00",
"encoding": "GSM-7",
"segments": 1
}
后续可能产生以下事件:
sms.acceptedsms.rejectedsms.deliveredsms.expiredsms.failed
具体状态取决于上游 SMSC 能否提供可靠的 DLR。如果供应商不返回最终状态,就不能把”最终送达”设为唯一的计费依据。
2. 选择计费点
每个产品都要明确自己的 chargeable event。例如:
POSTPAID_STANDARD -> sms.accepted
POSTPAID_DELIVERED -> sms.delivered
PREPAID -> reserve on submit, capture on accepted
同一条消息可能收到重复的 DLR,状态也可能发生反转,因此不能在每次收到 delivered 时直接扣费。所有计费操作都必须具备幂等性。
3. 建立费率模型
费率模型通常至少包含以下维度:
account_id或客户等级;- 目标国家及号段;
- SMSC 或路由;
- 消息方向;
- 生效时间区间;
- 单价和币种;
- 是否按 segment 计费。
每条费率都要保留有效期,不能直接覆盖旧记录,否则历史账单将无法重算。
CREATE TABLE sms_rate (
rate_id BIGSERIAL PRIMARY KEY,
account_id TEXT,
destination_prefix TEXT NOT NULL,
route_id TEXT,
price_per_segment NUMERIC(18, 6) NOT NULL,
currency CHAR(3) NOT NULL,
valid_from TIMESTAMPTZ NOT NULL,
valid_to TIMESTAMPTZ
);
号码匹配一般采用最长前缀规则,例如 86138 的优先级应高于 86。实际实现还要处理同一前缀下客户专属费率与默认费率的优先级。
4. 使用幂等账本
账务表应通过唯一约束避免重复扣费:
CREATE TABLE billing_entry (
entry_id UUID PRIMARY KEY,
account_id TEXT NOT NULL,
message_id TEXT NOT NULL,
charge_type TEXT NOT NULL,
segments INTEGER NOT NULL CHECK (segments > 0),
unit_price NUMERIC(18, 6) NOT NULL,
amount NUMERIC(18, 6) NOT NULL,
currency CHAR(3) NOT NULL,
source_event_id TEXT NOT NULL,
rated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (message_id, charge_type)
);
amount 应使用数据库的定点数类型或以最小货币单位表示的整数,不能使用浮点数。基本计算公式是:
amount = segments × price_per_segment
如果涉及折扣、税费和汇率,应分别生成明细项,不要只保存一个无法追溯计算过程的最终数字。
5. 异步完成 rating
以下是简化后的处理逻辑:
from decimal import Decimal
def rate_event(event, rate_repository, ledger):
if event["event_type"] != "sms.accepted":
return
idempotency_key = (
event["message_id"],
"SMS_ACCEPTED"
)
if ledger.exists(idempotency_key):
return
rate = rate_repository.find_effective_rate(
account_id=event["account_id"],
destination=event["destination"],
route_id=event["route_id"],
occurred_at=event["occurred_at"],
)
if rate is None:
raise RuntimeError("No applicable SMS rate")
segments = int(event["segments"])
unit_price = Decimal(rate["price_per_segment"])
amount = unit_price * segments
ledger.insert(
message_id=event["message_id"],
account_id=event["account_id"],
charge_type="SMS_ACCEPTED",
segments=segments,
unit_price=unit_price,
amount=amount,
currency=rate["currency"],
source_event_id=event["event_id"],
)
这里的 exists() 只能说明处理思路。在生产环境中,应依靠数据库唯一约束,并在事务内执行插入,防止多个 worker 同时处理同一事件。
Kannel 集成方式
常见的集成方式有三种。
在 Kannel 前增加发送代理
客户端不再直接调用 Kannel,而是请求自有的 Submission API。该服务负责认证、生成 message_id、记录提交事件,再向 Kannel 发起请求。
这种方式让用户、产品和业务标识始终处于自己的控制之下,也方便实现预付费余额预留。相应地,代理服务本身必须具备高可用能力,并能承受足够的吞吐量。
使用 DLR 回调采集状态
发送消息时配置 Kannel 的 DLR 请求和回调地址,由 Kannel 将状态通知发送给 Event collector。不同 Kannel 版本和 SMSC 协议支持的 DLR 级别可能不同,具体参数及占位符应以实际部署版本的 Kannel User Guide 为准。
回调接口要尽快返回,不要在 HTTP 请求中执行费率匹配和账务写入:
POST /callbacks/kannel/dlr
Content-Type: application/x-www-form-urlencoded
message_id=msg-20260901-00001234&status=delivered
接口只需验证来源、标准化状态,并将事件写入消息队列或可靠的本地事件表。
从持久化记录或日志补采
Kannel 的访问日志、SMSC 日志或 SQL 记录可以作为对账来源,但不适合成为唯一的实时计费入口。补采程序需要保存读取位置,并正确处理日志轮转和重复记录。
是否接入现有开源 billing 平台
可以接入,但更适合放在 rating 或账单层,不应直接进入 Kannel 的发送链路。
Apache Fineract 侧重账户和金融账务能力,Kill Bill 侧重订阅、发票及支付流程。两者都不是为 Kannel 或 SMS segment rating 专门设计的,因此仍要开发适配器,将已经计算完成的 usage 或账务汇总写入平台。
选择平台前,至少需要确认:
- 是否支持按用量计费;
- 是否允许自定义费率及生效时间;
- 是否具有稳定的 API;
- 是否支持账务冲正;
- 是否满足所需的币种、税务和发票规则;
- 能否在不修改核心代码的情况下扩展报表;
- 项目的维护状态和许可证是否符合部署要求。
如果需求只是按月统计每个客户发送的消息数量并生成账单,独立事件流水、rating worker 和关系数据库通常就已足够。只有确实需要支付、催收、发票生命周期或复杂的客户账户管理时,接入完整的 billing 平台才更合算。
扩展性和性能注意事项
即使吞吐量达到数千条 SMS/sec,也不必逐条同步入账。可以按照 account_id 或 message_id 对队列分区,再由多个无状态 worker 并行消费。数据库写入可采用小批量提交,同时将原始事件、账务账本和分析报表分开存储。
还要处理以下问题:
- 长短信拆分:计费通常以 segment 为单位,而不是按业务消息数量计算。
- 字符编码:GSM-7、UCS-2 和用户数据头都会影响 segment 数量。
- 重复回执:设计 DLR 处理逻辑时,应假定回执可能重复投递。
- 状态乱序:
delivered可能早于此前的事件进入计费系统。 - 费率缺失:不能在没有匹配费率时静默采用零价格,应将事件送入异常队列等待修复。
- 冲正机制:已计费消息的状态发生修正时,应追加 reversal entry,不能修改原始账目。
- 数据留存:手机号属于敏感信息,报表应做脱敏处理,并配置访问控制和保留周期。
- 对账:定期核对 Kannel、上游 SMSC、usage event 和 billing ledger 的数量及金额。
- 背压:队列积压时,发送服务仍可继续工作,但必须监控最老未处理事件的延迟。
- 可观测性:至少监控提交量、DLR 延迟、重复事件、费率匹配失败和计费积压。
更稳妥的做法不是寻找一个可以”直接安装到 Kannel 上”的计费插件,而是把 Kannel 作为事件来源,建立可重放、幂等的异步计费管道,再根据实际需求接入开源账单或支付平台。
备注:内容仅供参考。
