PAYATHON 2026

如何使用 SQL 计算通话费用并写入新表

支付阿杰

可以使用 INSERT ... SELECT 把计费结果写入新表。匹配跨午夜的 tariff 时,不能只用 BETWEEN,需要分别处理普通时间段和 ratestart > rateend 的跨午夜时间段。

计费一般使用 billsec,而不是 duration,因为 billsec 通常表示通话接通后的实际计费秒数。同时需要确定 ratecharged 表示每秒价格、每分钟价格,还是每个开始分钟的价格。计价方式不明确,SQL 就无法正确计算费用。

完善 tariff 表

tariff 表可以保存名称、适用日期类型、计费单位和舍入方式:

CREATE TABLE tariffs (
    tariff_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    tariff_name VARCHAR(50) NOT NULL,
    day_type ENUM('WEEKDAY', 'WEEKEND') NOT NULL,
    ratestart TIME NOT NULL,
    rateend TIME NOT NULL,
    billing_unit_seconds SMALLINT UNSIGNED NOT NULL,
    charge_mode ENUM('PRORATED', 'STARTED_UNIT') NOT NULL,
    ratecharged DECIMAL(18,8) NOT NULL
);

假设题目中的价格是每分钟价格,可以这样写:

INSERT INTO tariffs
    (tariff_name, day_type, ratestart, rateend,
     billing_unit_seconds, charge_mode, ratecharged)
VALUES
    ('weekday_peak', 'WEEKDAY', '08:00:00', '19:00:00',
     60, 'PRORATED', 3.3333),

    ('weekday_off_peak', 'WEEKDAY', '19:00:00', '08:00:00',
     60, 'PRORATED', 1.5555),

    ('weekend_off_peak', 'WEEKEND', '00:00:00', '00:00:00',
     60, 'PRORATED', 1.5555);

以上示例采用以下约定:

  • ratestart = rateend 表示全天有效。
  • ratestart < rateend 表示当天的普通时间段。
  • ratestart > rateend 表示跨午夜时间段。
  • PRORATED 表示按实际秒数折算。
  • STARTED_UNIT 表示不足一个计费单位也按一个完整单位收费。

如果费率按秒计算,把 billing_unit_seconds 设置为 1 即可。

创建通话费用结果表

结果表应尽量保留原通话记录的完整唯一标识。只保存 clid,不足以唯一识别一条通话:

CREATE TABLE call_charges (
    charge_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    callerid VARCHAR(32) NOT NULL,
    source_number VARCHAR(64),
    destination_number VARCHAR(64),
    source_channel VARCHAR(32) NOT NULL,
    calldate TIMESTAMP NOT NULL,
    call_duration INT UNSIGNED NOT NULL,
    tariff_id BIGINT UNSIGNED NOT NULL,
    tariff_name VARCHAR(50) NOT NULL,
    ratecharged DECIMAL(18,8) NOT NULL,
    billing_unit_seconds SMALLINT UNSIGNED NOT NULL,
    cost DECIMAL(18,8) NOT NULL,

    UNIQUE KEY uq_cdr_charge (callerid, source_channel, calldate),
    FOREIGN KEY (tariff_id) REFERENCES tariffs(tariff_id)
);

在结果表中保存 ratecharged、计费单位和 tariff 名称,相当于保留一份费率快照。即使以后 tariff 表发生变化,已经出账的历史费用仍有原始计费依据。

按通话开始时间选择 tariff

如果业务规定一通电话全部按接通时的费率计费,可以直接执行:

INSERT INTO call_charges (
    callerid,
    source_number,
    destination_number,
    source_channel,
    calldate,
    call_duration,
    tariff_id,
    tariff_name,
    ratecharged,
    billing_unit_seconds,
    cost
)
SELECT
    c.clid,
    c.src,
    c.dst,
    c.channel,
    c.calldate,
    c.billsec,
    t.tariff_id,
    t.tariff_name,
    t.ratecharged,
    t.billing_unit_seconds,
    ROUND(
        CASE t.charge_mode
            WHEN 'PRORATED' THEN
                c.billsec * t.ratecharged / t.billing_unit_seconds
            WHEN 'STARTED_UNIT' THEN
                CEIL(c.billsec / t.billing_unit_seconds) * t.ratecharged
        END,
        8
    ) AS cost
FROM cdr AS c
JOIN tariffs AS t
  ON t.day_type =
     CASE
         WHEN WEEKDAY(c.answerdate) IN (5, 6) THEN 'WEEKEND'
         ELSE 'WEEKDAY'
     END
 AND (
        t.ratestart = t.rateend

        OR (
            t.ratestart < t.rateend
            AND TIME(c.answerdate) >= t.ratestart
            AND TIME(c.answerdate) <  t.rateend
        )

        OR (
            t.ratestart > t.rateend
            AND (
                TIME(c.answerdate) >= t.ratestart
                OR TIME(c.answerdate) < t.rateend
            )
        )
     )
WHERE c.billsec > 0
  AND c.disposition = 'ANSWERED';

MySQL 的 WEEKDAY() 返回值如下:

  • 0:星期一
  • 4:星期五
  • 5:星期六
  • 6:星期日

时间区间采用左闭右开形式,例如 08:00:00 <= time < 19:00:00。这样可以避免时间恰好为 19:00:00 时同时匹配两条 tariff。

跨午夜的非高峰时段不能写成:

TIME(c.answerdate) BETWEEN '19:00:00' AND '08:00:00'

因为它的开始时间晚于结束时间。正确条件是:

TIME(c.answerdate) >= '19:00:00'
OR TIME(c.answerdate) < '08:00:00'

每秒和每分钟计费公式

假设价格为 ratecharged,计费单位秒数为 billing_unit_seconds。

按实际通话时长折算:

billsec * ratecharged / billing_unit_seconds

例如每分钟价格为 3.3333,通话 30 秒:

30 × 3.3333 ÷ 60 = 1.66665

按每个开始分钟收费:

CEIL(billsec / billing_unit_seconds) * ratecharged

同样是 30 秒通话:

CEIL(30 ÷ 60) × 3.3333 = 3.3333

如果 ratecharged 本身就是每秒价格,公式为:

billsec * ratecharged

具体采用哪一种公式,要以实际资费合同为准。

通话跨越费率边界时的处理

前面的查询只根据接通时间选择 tariff。比如一通电话从 18:59 持续到 19:05,整通电话都会按高峰费率计费。

如果计费规则要求:

  • 18:59 到 19:00 按高峰收费;
  • 19:00 到 19:05 按非高峰收费;

就需要拆分通话。此时一条通话可能对应多条计费明细,因此不适合只在 call_charges 中保存一个 tariff。

可以建立具体的费率窗口表:

CREATE TABLE tariff_windows (
    tariff_window_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    tariff_id BIGINT UNSIGNED NOT NULL,
    window_start DATETIME NOT NULL,
    window_end DATETIME NOT NULL,
    FOREIGN KEY (tariff_id) REFERENCES tariffs(tariff_id),
    INDEX ix_tariff_window (window_start, window_end)
);

系统可以提前生成每天的费率窗口,例如:

2026-09-01 00:00:00 至 2026-09-01 08:00:00  非高峰
2026-09-01 08:00:00 至 2026-09-01 19:00:00  高峰
2026-09-01 19:00:00 至 2026-09-02 00:00:00  非高峰

星期六和星期日只生成一个全天非高峰窗口。

计费明细表可以这样设计:

CREATE TABLE call_charge_items (
    item_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    callerid VARCHAR(32) NOT NULL,
    source_channel VARCHAR(32) NOT NULL,
    calldate TIMESTAMP NOT NULL,
    segment_start DATETIME NOT NULL,
    segment_end DATETIME NOT NULL,
    segment_seconds INT UNSIGNED NOT NULL,
    tariff_id BIGINT UNSIGNED NOT NULL,
    ratecharged DECIMAL(18,8) NOT NULL,
    cost DECIMAL(18,8) NOT NULL,
    FOREIGN KEY (tariff_id) REFERENCES tariffs(tariff_id)
);

然后按照通话与费率窗口的交集进行拆分:

INSERT INTO call_charge_items (
    callerid,
    source_channel,
    calldate,
    segment_start,
    segment_end,
    segment_seconds,
    tariff_id,
    ratecharged,
    cost
)
SELECT
    x.clid,
    x.channel,
    x.calldate,
    x.segment_start,
    x.segment_end,
    TIMESTAMPDIFF(
        SECOND,
        x.segment_start,
        x.segment_end
    ) AS segment_seconds,
    x.tariff_id,
    x.ratecharged,
    ROUND(
        CASE x.charge_mode
            WHEN 'PRORATED' THEN
                TIMESTAMPDIFF(
                    SECOND,
                    x.segment_start,
                    x.segment_end
                ) * x.ratecharged / x.billing_unit_seconds
            WHEN 'STARTED_UNIT' THEN
                CEIL(
                    TIMESTAMPDIFF(
                        SECOND,
                        x.segment_start,
                        x.segment_end
                    ) / x.billing_unit_seconds
                ) * x.ratecharged
        END,
        8
    ) AS cost
FROM (
    SELECT
        c.clid,
        c.channel,
        c.calldate,
        GREATEST(c.answerdate, w.window_start) AS segment_start,
        LEAST(
            DATE_ADD(c.answerdate, INTERVAL c.billsec SECOND),
            w.window_end
        ) AS segment_end,
        t.tariff_id,
        t.ratecharged,
        t.billing_unit_seconds,
        t.charge_mode
    FROM cdr AS c
    JOIN tariff_windows AS w
      ON w.window_start <
         DATE_ADD(c.answerdate, INTERVAL c.billsec SECOND)
     AND w.window_end > c.answerdate
    JOIN tariffs AS t
      ON t.tariff_id = w.tariff_id
    WHERE c.billsec > 0
      AND c.disposition = 'ANSWERED'
) AS x
WHERE x.segment_end > x.segment_start;

每通电话的总费用可以进一步聚合:

SELECT
    callerid,
    source_channel,
    calldate,
    SUM(segment_seconds) AS billed_seconds,
    SUM(cost) AS total_cost
FROM call_charge_items
GROUP BY callerid, source_channel, calldate;

如果采用“每个开始分钟收费”,还需要确定跨 tariff 拆分后,是每段分别向上取整,还是整通电话只取整一次。这两种规则算出的费用可能不同,必须按照业务合同确定。

是否需要用户表

计费计算本身不要求必须有用户表。如果还要维护客户姓名、套餐、账户状态或账单归属,可以单独建表:

CREATE TABLE users (
    accountcode VARCHAR(30) PRIMARY KEY,
    display_name VARCHAR(100) NOT NULL,
    status ENUM('ACTIVE', 'SUSPENDED', 'CLOSED') NOT NULL
);

再通过 cdr.accountcode 关联用户:

SELECT
    u.display_name,
    c.src,
    c.dst,
    c.billsec
FROM cdr AS c
JOIN users AS u
  ON u.accountcode = c.accountcode;

不要把 clid 作为稳定的用户主键,因为它可能包含显示名称、号码或不同格式。accountcode 通常更适合作为计费账户标识,但仍需结合实际的 CDR 生成规则确认。

注意事项

  • 确保同一时刻最多匹配一条 tariff,否则 JOIN 会重复生成费用。
  • 对已接通的电话,优先使用 answerdate 和 billsec。
  • 费用字段应使用 DECIMAL,不要使用 FLOAT 或 DOUBLE。
  • 明确费率是否含税,以及最终在哪一级进行金额舍入。
  • 周末、节假日和特殊促销费率可以作为独立规则维护。
  • 批量任务应通过唯一键保证幂等,避免重复运行后生成重复账单。
  • 如果资费还会随号码前缀、国家、运营商或用户套餐变化,需要增加 destination pattern、tariff plan 和生效日期等字段。

备注:内容仅供参考。