第 3 篇:充电站运营后台的数据库怎么设计?6张表搞定核心业务 —— 一个刚学会 SQL 连表查询的前端,硬着头皮设计了一套数据库

0 阅读9分钟

项目名: 绿能充电  

技术栈: SQLite + better-sqlite3  

作者: 一个被迫“全栈”的前端

前言

大家好,还是我。

  上回说到,我用 React + Vite + Tailwind 搭好了“绿能充电”的前端架子,老板看了一眼说:“不错,页面挺绿。” 然后补了一句:“那数据存哪儿?”  

我回头看了一眼我们那位“熟悉 SQLite”的后端小哥,他正在工位上认真地刷 LeetCode。  

行吧,我来。  

作为一个前端,让我写 React 组件我能在 GitHub 上抄一天,但让我设计数据库?我连第三范式都背不全。不过项目不等人,老板的 deadline 像达摩克利斯之剑悬在头顶。于是我用了一个周末,翻遍了开源充电桩项目的数据库设计,再结合“绿能充电”的真实业务,硬着头皮搓出了 6 张表。  

今天就把这 6 张表的设计思路、字段含义、表之间的关系,以及我踩过的坑,毫无保留地分享出来。  

免责声明: 这不是 《MySQL 高性能》 级别的设计,但足够让你的中小型充电桩平台平稳运行到被大厂收购。

一、数据库选型:为什么是 SQLite + better-sqlite3?

再次简单说两句,因为总有人在评论区问。

我们的后端小哥只会 SQLite,我们的数据量一年不到 22 万条,我们的并发约等于“老板和三个站长同时点刷新”。在这种 “人少、数据少、要求快” 的场景下,SQLite 就是天选之子。  

至于 better-sqlite3 而不是 sqlite3,原因更简单:它同步。对,你没看错,我选同步。因为我们的 API 一次请求就查一两张表,同步代码写起来不用 async/await 满天飞,心智负担直接减半。性能?20ms 和 15ms 的区别,用户感知不到。

好了,废话不多说,上表。  

二、核心表设计:6 张表,撑起一个充电桩平台

  我们的核心业务就四件事:用户充电站充电订单。围绕这四点,我设计了以下 6 张表。

1. users —— 用户表(谁在用电)

这是最基础的表,管的是所有“人”:C 端车主、B 端站长、后台管理员。

字段名类型说明
idINTEGER主键,自增
phoneVARCHAR(11)手机号,唯一索引(登录用)
password_hashVARCHAR(255)bcrypt 加密后的密码
nameVARCHAR(50)昵称
roleVARCHAR(20)user / operator / admin(角色权限)
balanceDECIMAL(10,2)账户余额(单位:元),默认 0.00
statusVARCHAR(10)active / frozen(冻结用户不能充电)
created_atDATETIME注册时间

设计思路:

  • 手机号 + 密码登录,没有做第三方登录(老板说成本高)。

  • role 字段控制权限:普通用户只能看到自己的订单;运营商能看到自己站点的数据;admin 能看到所有。

  • balance 放用户表里,方便实时扣费。但注意,账户余额和支付记录必须保持一致,通过事务来保证。

槽点: 一开始我没建 phone 索引,结果用户量到 1000 的时候,登录查询明显变慢。后来加上 CREATE INDEX idx_users_phone ON users(phone); 瞬间好了。——索引是爹,别忘了建。  

2. stations —— 充电站表(桩在哪儿)

每个物理位置的充电站,比如“望京 SOHO 充电站”。

字段名类型说明
idINTEGER主键,自增
nameVARCHAR(100)充电站名称
addressVARCHAR(200)详细地址
latitudeDECIMAL(10,8)纬度(地图打点用)
longitudeDECIMAL(11,8)经度
total_portsINTEGER总桩口数(如 10 个桩)
available_portsINTEGER当前可用桩口数(实时更新)
statusVARCHAR(10)online / offline / maintenance
powerDECIMAL(5,2)充电桩功率(kW),如 60.00
pricingDECIMAL(5,2)服务费单价(元/度),如 0.80
created_atDATETIME建站时间

 

设计思路:

  • latitude / longitude 用于前端地图展示(React-Leaflet 读这两个字段打点)。

  • available_ports 是一个冗余字段,可以通过 charging_sessions 实时计算,但为了性能,选择在每次开始/结束充电时更新它。

  • status 用于运维人员手动下线某个场站。

 

表关系: stations 是一,charging_sessions 是多。一个充电站有多个充电订单。

 

3. charging_sessions —— 充电会话表(最核心的业务表)

这是整个系统最繁忙的表,每一次充电行为就是一条记录。

字段名类型说明
idINTEGER主键,自增
session_noVARCHAR(32)会话编号(唯一,展示给用户)
user_idINTEGER外键,关联 users.id
station_idINTEGER外键,关联 stations.id
pile_noVARCHAR(20)具体哪个桩(如 "A-01")
start_timeDATETIME开始充电时间
end_timeDATETIME结束充电时间(未结束时为 NULL)
duration_minINTEGER充电时长(分钟)
electricityDECIMAL(8,2)充电度数(kWh)
total_amountDECIMAL(10,2)总金额(电费 + 服务费)
statusVARCHAR(20)charging / completed / cancelled / failed
created_atDATETIME记录创建时间

设计思路:

  • session_no 展示给用户,不用自增 id(显得专业)。

  • station_id + pile_no 可以定位到具体某一个充电口。

  • duration_minelectricity 从设备上报的数据中获取(模拟阶段可以随机生成)。

  • status 字段配合 WebSocket 实时更新前端状态。

表关系:

  • charging_sessions -> users(多对一)

  • charging_sessions -> stations(多对一)

  • charging_sessions -> payments(一对一,一次充电对应一次支付)

4. payments —— 支付记录表(钱去哪儿了)

充电结束后,用户要付钱,这张表记录每一笔资金流水。  

字段名类型说明
idINTEGER主键,自增
session_idINTEGER外键,关联 charging_sessions.id(唯一)
user_idINTEGER外键,关联 users.id
amountDECIMAL(10,2)支付金额
payment_methodVARCHAR(20)balance / wechat / alipay(演示用余额)
statusVARCHAR(20)pending / success / failed
paid_atDATETIME支付完成时间
created_atDATETIME记录创建时间

设计思路:

  • session_id 加上唯一约束,保证一次充电只有一次支付,防止重复扣费。

  • 支付成功后,通过事务同时更新 users.balance(扣减)和 payments 状态。

  • 演示环境我们只做“余额支付”,wechat/alipay 是留给未来的接口。

坑位提醒: 千万别用 float 存金额,精度会炸。必须用 DECIMAL(10,2)

5. reservations —— 预约表(占个坑)

用户怕到站没桩,可以先预约。

字段名类型说明
idINTEGER主键,自增
user_idINTEGER外键,关联 users.id
station_idINTEGER外键,关联 stations.id
pile_noVARCHAR(20)预约的具体桩号
reserve_timeDATETIME预约时间
expire_timeDATETIME预约过期时间(默认 15 分钟后无效)
statusVARCHAR(20)active / used / expired / cancelled
created_atDATETIME记录创建时间

设计思路:

  • expire_time:用户预约后 15 分钟内没扫码开始充电,预约自动释放(通过定时任务或查询时判断)。

  • 预约成功后,前端需倒计时提醒。

  • 这张表可以不用太复杂,MVP 阶段甚至可以先不做预约功能。

6. fault_alerts —— 故障告警表(运维的灵魂

充电桩坏了,系统得知道。

字段名类型说明
idINTEGER主键,自增
station_idINTEGER外键,关联 stations.id
pile_noVARCHAR(20)故障桩号
alert_typeVARCHAR(30)通信故障 / 过温保护 / 绝缘故障
alert_contentTEXT详细描述
is_resolvedBOOLEAN是否已处理,默认 0
resolved_atDATETIME处理时间
created_atDATETIME告警发生时间

  设计思路:

  • 告警数据可以从设备模拟脚本生成,也可以由运维人员手动录入。

  • 后台管理界面需要有一个“告警列表”,按 is_resolved=0 筛选,运维人员处理后点击“解决”。

三、表关系总览(ER 图脑补版)


users (1) ----< (N) charging_sessions (1) ---- (1) payments

|                         |

|                         | (N)

|                         |

+----< (N) reservations   +----- (N:1) stations

|

+----< (N) payments

简单说就是:

· 一个用户有多个充电订单、多个预约、多笔支付。

· 一个充电订单属于一个用户和一个充电站,并且(可选)对应一笔支付。

· 一个充电站有多条订单、多条告警。

四、SQLite 实战:初始化表的 SQL 脚本

  下面是完整的建表 SQL,复制到 better-sqlite3 里就能跑。


-- 用户表

CREATE TABLE IF NOT EXISTS users (

id INTEGER PRIMARY KEY AUTOINCREMENT,

phone VARCHAR(11) NOT NULL UNIQUE,

password_hash VARCHAR(255) NOT NULL,

name VARCHAR(50),

role VARCHAR(20) DEFAULT 'user',

balance DECIMAL(10,2) DEFAULT 0.00,

status VARCHAR(10) DEFAULT 'active',

created_at DATETIME DEFAULT CURRENT_TIMESTAMP

);

 

-- 充电站表

CREATE TABLE IF NOT EXISTS stations (

id INTEGER PRIMARY KEY AUTOINCREMENT,

name VARCHAR(100) NOT NULL,

address VARCHAR(200),

latitude DECIMAL(10,8),

longitude DECIMAL(11,8),

total_ports INTEGER DEFAULT 0,

available_ports INTEGER DEFAULT 0,

status VARCHAR(10) DEFAULT 'offline',

power DECIMAL(5,2),

pricing DECIMAL(5,2),

created_at DATETIME DEFAULT CURRENT_TIMESTAMP

);

 

-- 充电会话表

CREATE TABLE IF NOT EXISTS charging_sessions (

id INTEGER PRIMARY KEY AUTOINCREMENT,

session_no VARCHAR(32) NOT NULL UNIQUE,

user_id INTEGER NOT NULL,

station_id INTEGER NOT NULL,

pile_no VARCHAR(20),

start_time DATETIME,

end_time DATETIME,

duration_min INTEGER DEFAULT 0,

electricity DECIMAL(8,2) DEFAULT 0,

total_amount DECIMAL(10,2) DEFAULT 0,

status VARCHAR(20) DEFAULT 'charging',

created_at DATETIME DEFAULT CURRENT_TIMESTAMP,

FOREIGN KEY (user_id) REFERENCES users(id),

FOREIGN KEY (station_id) REFERENCES stations(id)

);

 

-- 支付记录表

CREATE TABLE IF NOT EXISTS payments (

id INTEGER PRIMARY KEY AUTOINCREMENT,

session_id INTEGER NOT NULL UNIQUE,

user_id INTEGER NOT NULL,

amount DECIMAL(10,2) NOT NULL,

payment_method VARCHAR(20) DEFAULT 'balance',

status VARCHAR(20) DEFAULT 'pending',

paid_at DATETIME,

created_at DATETIME DEFAULT CURRENT_TIMESTAMP,

FOREIGN KEY (session_id) REFERENCES charging_sessions(id),

FOREIGN KEY (user_id) REFERENCES users(id)

);

 

-- 预约表

CREATE TABLE IF NOT EXISTS reservations (

id INTEGER PRIMARY KEY AUTOINCREMENT,

user_id INTEGER NOT NULL,

station_id INTEGER NOT NULL,

pile_no VARCHAR(20),

reserve_time DATETIME DEFAULT CURRENT_TIMESTAMP,

expire_time DATETIME,

status VARCHAR(20) DEFAULT 'active',

created_at DATETIME DEFAULT CURRENT_TIMESTAMP,

FOREIGN KEY (user_id) REFERENCES users(id),

FOREIGN KEY (station_id) REFERENCES stations(id)

);

 

-- 故障告警表

CREATE TABLE IF NOT EXISTS fault_alerts (

id INTEGER PRIMARY KEY AUTOINCREMENT,

station_id INTEGER NOT NULL,

pile_no VARCHAR(20),

alert_type VARCHAR(30),

alert_content TEXT,

is_resolved BOOLEAN DEFAULT 0,

resolved_at DATETIME,

created_at DATETIME DEFAULT CURRENT_TIMESTAMP,

FOREIGN KEY (station_id) REFERENCES stations(id)

);

 

-- 索引(别忘了建!)

CREATE INDEX idx_users_phone ON users(phone);

CREATE INDEX idx_sessions_user_id ON charging_sessions(user_id);

CREATE INDEX idx_sessions_station_id ON charging_sessions(station_id);

CREATE INDEX idx_payments_session_id ON payments(session_id);

CREATE INDEX idx_reservations_user_id ON reservations(user_id);

CREATE INDEX idx_faults_station_id ON fault_alerts(station_id);

五、给前端的几句“数据库忠告”

  1. 外键约束在 SQLite 默认不开启,记得执行 PRAGMA foreign_keys = ON; 不然删了 user 订单还在。

  2. 日期时间统一存 CURRENT_TIMESTAMP,前端展示时再格式化。

  3. 金额相关字段禁止用 float,用 DECIMAL。

  4. 索引不是越多越好,但登录字段(phone)、外键字段一定要建。

  5. 不要害怕冗余:stations.available_ports 就是冗余,但少了它每次都要 COUNT 订单表,性能会崩。

如果你也是一个人全栈、项目初期、预算有限,抄我这 6 张表完全够用。等哪天日活过万、数据量破千万,我们再一起研究分库分表也不迟。

下一篇我会讲 “JWT 认证 + 角色权限:充电桩平台的安全体系搭建” ,教你怎么用 Express 给这些表穿上盔甲,防止被小学生拖库。

项目地址: github.com/Rhi637/ev-c…

在线演示: rhi637.github.io/ev-charging…

评论区欢迎开喷:你这设计不符合第三范式!SQLite 上生产就是找死!为什么不用 PostgreSQL?—— 你说得都对,但我先上线赚钱了。

点赞过 500,我把 better-sqlite3 的事务封装代码也发出来。