Electronic Bill Payment System 数据库设计与查询
结论
建议把“客户订阅配置”和“每月实际账单”分开存储:
- 订阅表记录客户订阅的公用事业公司,以及每月的计划生成日、截止日和付款到期日。
- 账单表记录某个客户在具体账期内生成的账单,包括金额、状态和实际生成时间。
界面加载时,根据当前日期查询“已到生成日,但本账期还没有生成账单”的订阅即可。对于 100 多名客户,这类查询的负担很小,一般不需要定时任务。如果业务要求提前创建任务、发送提醒,或者在无人登录时继续处理,才需要数据库 Job 或后台调度服务。
推荐的数据模型
不要再让 Bill_Master 同时承担订阅规则和实际账单两种职责。可以把数据拆成以下几张表。
客户表
CREATE TABLE Customer (
Customer_ID BIGINT PRIMARY KEY,
Customer_Name VARCHAR(200) NOT NULL,
Address VARCHAR(500),
Phone VARCHAR(50)
);
公用事业公司表
CREATE TABLE UtilityCompany (
UtilityCompany_ID BIGINT PRIMARY KEY,
Company_Name VARCHAR(200) NOT NULL
);
客户账单订阅表
这张表保存长期有效的配置,不代表某个月的实际账单。
CREATE TABLE BillSubscription (
Subscription_ID BIGINT PRIMARY KEY,
Customer_ID BIGINT NOT NULL,
UtilityCompany_ID BIGINT NOT NULL,
Enrollment_Date DATE NOT NULL,
Generation_Day SMALLINT NOT NULL,
Due_Day SMALLINT NOT NULL,
CutOff_Day SMALLINT NOT NULL,
Is_Active BOOLEAN NOT NULL DEFAULT TRUE,
End_Date DATE NULL,
CONSTRAINT FK_Subscription_Customer
FOREIGN KEY (Customer_ID)
REFERENCES Customer (Customer_ID),
CONSTRAINT FK_Subscription_Utility
FOREIGN KEY (UtilityCompany_ID)
REFERENCES UtilityCompany (UtilityCompany_ID),
CONSTRAINT CK_Subscription_GenerationDay
CHECK (Generation_Day BETWEEN 1 AND 31),
CONSTRAINT CK_Subscription_DueDay
CHECK (Due_Day BETWEEN 1 AND 31),
CONSTRAINT CK_Subscription_CutOffDay
CHECK (CutOff_Day BETWEEN 1 AND 31)
);
这里使用 Generation_Day,而不是容易让人误解的 Bill_Generation_Date。如果该字段表示“每月 18 日”,它保存的是重复规则中的日号,而不是完整日期。
如果同一客户对同一家公用事业公司只能有一个有效订阅,还要添加对应的唯一约束。如果需要保留配置变更历史,可以增加 Effective_From 和 Effective_To,不要直接覆盖旧配置。
实际账单表
每个账期生成一条独立的账单记录。
CREATE TABLE Bill (
Bill_ID BIGINT PRIMARY KEY,
Subscription_ID BIGINT NOT NULL,
Billing_Period_Start DATE NOT NULL,
Billing_Period_End DATE NOT NULL,
Scheduled_Date DATE NOT NULL,
Due_Date DATE NOT NULL,
CutOff_Date DATE NOT NULL,
Amount DECIMAL(18, 2) NOT NULL,
Bill_Reference VARCHAR(100),
Status VARCHAR(30) NOT NULL DEFAULT 'GENERATED',
Generated_At TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
Paid_At TIMESTAMP NULL,
CONSTRAINT FK_Bill_Subscription
FOREIGN KEY (Subscription_ID)
REFERENCES BillSubscription (Subscription_ID),
CONSTRAINT UQ_Bill_Subscription_Period
UNIQUE (Subscription_ID, Billing_Period_Start)
);
UNIQUE (Subscription_ID, Billing_Period_Start) 可以防止用户重复点击,或者两个操作员同时处理时,为同一订阅和同一账期生成两张账单。
生产环境中还可以增加:
Created_ByUpdated_AtCurrency_CodePayment_StatusFailure_Reason- 外部公用事业公司的账单编号
查询当天需要生成的账单
以下示例基于三个假设:
Generation_Day表示每月可以开始录入账单的日期。- 每个自然月对应一个账期。
- 到达生成日后,只要当月账单尚未创建,该记录就一直显示在待处理列表中。
PostgreSQL 示例:
SELECT
s.Subscription_ID,
c.Customer_ID,
c.Customer_Name,
u.UtilityCompany_ID,
u.Company_Name,
s.Generation_Day,
s.Due_Day,
s.CutOff_Day
FROM BillSubscription s
JOIN Customer c
ON c.Customer_ID = s.Customer_ID
JOIN UtilityCompany u
ON u.UtilityCompany_ID = s.UtilityCompany_ID
WHERE s.Is_Active = TRUE
AND s.Enrollment_Date <= CURRENT_DATE
AND (s.End_Date IS NULL OR s.End_Date >= CURRENT_DATE)
AND EXTRACT(DAY FROM CURRENT_DATE) >= s.Generation_Day
AND NOT EXISTS (
SELECT 1
FROM Bill b
WHERE b.Subscription_ID = s.Subscription_ID
AND b.Billing_Period_Start = DATE_TRUNC('month', CURRENT_DATE)::date
)
ORDER BY s.Generation_Day, c.Customer_Name;
这里使用:
EXTRACT(DAY FROM CURRENT_DATE) >= s.Generation_Day
没有使用等号,是因为如果 18 日无人处理,19 日打开界面时,这条记录仍应出现在待处理列表中。
SQL Server 可以写成:
DECLARE @Today date = CAST(GETDATE() AS date);
DECLARE @PeriodStart date = DATEFROMPARTS(
YEAR(@Today),
MONTH(@Today),
1
);
SELECT
s.Subscription_ID,
c.Customer_ID,
c.Customer_Name,
u.UtilityCompany_ID,
u.Company_Name,
s.Generation_Day,
s.Due_Day,
s.CutOff_Day
FROM BillSubscription AS s
JOIN Customer AS c
ON c.Customer_ID = s.Customer_ID
JOIN UtilityCompany AS u
ON u.UtilityCompany_ID = s.UtilityCompany_ID
WHERE s.Is_Active = 1
AND s.Enrollment_Date <= @Today
AND (s.End_Date IS NULL OR s.End_Date >= @Today)
AND DAY(@Today) >= s.Generation_Day
AND NOT EXISTS (
SELECT 1
FROM Bill AS b
WHERE b.Subscription_ID = s.Subscription_ID
AND b.Billing_Period_Start = @PeriodStart
)
ORDER BY s.Generation_Day, c.Customer_Name;
按日期范围查找计划生成的客户
如果界面支持选择日期范围,例如查看 8 月 18 日至 8 月 25 日的计划,可以先算出每个订阅在目标月份对应的实际计划日期。
SQL Server 示例:
DECLARE @FromDate date = '2026-08-18';
DECLARE @ToDate date = '2026-08-25';
WITH Candidate AS (
SELECT
s.Subscription_ID,
s.Customer_ID,
s.UtilityCompany_ID,
DATEFROMPARTS(
YEAR(@FromDate),
MONTH(@FromDate),
CASE
WHEN s.Generation_Day >
DAY(EOMONTH(@FromDate))
THEN DAY(EOMONTH(@FromDate))
ELSE s.Generation_Day
END
) AS Scheduled_Date
FROM BillSubscription AS s
WHERE s.Is_Active = 1
)
SELECT
x.Subscription_ID,
c.Customer_ID,
c.Customer_Name,
u.UtilityCompany_ID,
u.Company_Name,
x.Scheduled_Date
FROM Candidate AS x
JOIN Customer AS c
ON c.Customer_ID = x.Customer_ID
JOIN UtilityCompany AS u
ON u.UtilityCompany_ID = x.UtilityCompany_ID
WHERE x.Scheduled_Date BETWEEN @FromDate AND @ToDate
AND NOT EXISTS (
SELECT 1
FROM Bill AS b
WHERE b.Subscription_ID = x.Subscription_ID
AND b.Billing_Period_Start =
DATEFROMPARTS(YEAR(x.Scheduled_Date), MONTH(x.Scheduled_Date), 1)
)
ORDER BY x.Scheduled_Date, c.Customer_Name;
这个示例会把配置中的“31 日”调整为当月最后一天,用于处理没有 31 日的月份。是否采用这种规则,应由业务决定。其他做法包括禁止配置 29、30、31 日,或者让订阅明确指定“月末”。
生成账单时应重新校验
不能只依赖界面首次查询的结果。用户提交金额时,后台要再次检查账单是否已经存在,并在事务中完成插入。
BEGIN TRANSACTION;
INSERT INTO Bill (
Bill_ID,
Subscription_ID,
Billing_Period_Start,
Billing_Period_End,
Scheduled_Date,
Due_Date,
CutOff_Date,
Amount,
Bill_Reference,
Status
)
VALUES (
@BillId,
@SubscriptionId,
@PeriodStart,
EOMONTH(@PeriodStart),
@ScheduledDate,
@DueDate,
@CutOffDate,
@Amount,
@BillReference,
'GENERATED'
);
COMMIT TRANSACTION;
即使应用层已经检查过,数据库中的唯一约束也必须保留。发生并发请求时,唯一约束是防止重复数据的最后一道防线。应用需要捕获唯一键冲突,并提示“该账期账单已经生成”。
查询还是定时任务
如果流程是“操作员打开页面,查看待处理项,再手工录入金额”,页面打开或刷新时直接查询就够了。100 多名客户的数据量很小,只要索引合理,查询成本通常可以忽略。
建议增加以下索引:
CREATE INDEX IX_BillSubscription_Active_GenerationDay
ON BillSubscription (Is_Active, Generation_Day);
CREATE INDEX IX_Bill_Subscription_Period
ON Bill (Subscription_ID, Billing_Period_Start);
以下情况更适合使用定时任务:
- 没有操作员登录时,也必须在指定时间创建待办记录。
- 系统需要发送邮件、短信或其他提醒。
- 需要冻结当月配置,避免后续订阅变更影响历史结果。
- 待处理任务需要经过分派、审核、重试或超时升级。
- 数据量较大,实时计算已经影响页面响应。
遇到这些情况,可以增加 BillGenerationTask 表,由每日 Job 创建任务。任务生成过程必须具备幂等性,例如为 (Subscription_ID, Billing_Period_Start) 添加唯一约束。Job 再次运行时应跳过已有任务,避免重复创建。
定时任务不应直接生成带有虚构金额的正式账单。如果金额必须由操作员填写,Job 最多只能创建“待录入”任务,或者创建状态为 PENDING_INPUT 的账单草稿。
日期设计中的注意事项
先明确三个日期的含义
必须先定义 Bill_Generation_Date、Our_CutOff_Date 和 Bill_Due_Date 的先后关系。例如:
账单产生日 <= 银行处理截止日 <= 公用事业公司付款到期日
如果 Our_CutOff_Date 才是操作员开始处理账单的日期,待处理查询就应该使用 CutOff_Day,而不是 Generation_Day。字段名称不能代替明确的业务定义。
处理跨月日期
如果账单在 27 日到期,通常仍属于当月。但业务中也可能出现“每月 28 日生成,下月 5 日到期”。这时只保存 Due_Day = 5,无法判断到期日属于哪个月,还需要增加月份偏移,例如:
Due_Month_Offset = 1
也可以直接把规则定义为“生成日后多少天到期”。
处理月末和非工作日
以下规则都需要提前确定:
- 配置为 31 日,但当月只有 30 天时如何处理。
- 2 月遇到 29 日、30 日或 31 日时如何处理。
- 日期落在周末或法定节假日时,是提前还是顺延。
- 采用银行所在时区,还是数据库服务器时区。
涉及银行业务时,建议使用银行营业日历表,不要只根据星期几判断工作日。
保存历史快照
账单生成后,要把该账期的 Scheduled_Date、Due_Date 和 CutOff_Date 写入 Bill 表。以后即使订阅配置发生变化,历史账单也不应跟着改变。
订阅表回答“今后应该何时生成”,账单表回答“某个账期实际生成了什么”。把两类数据分开后,待处理查询、历史追溯、并发控制和自动调度都会更清楚。
备注:内容仅供参考。
