项目名: 绿能充电
技术栈: 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 端站长、后台管理员。
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INTEGER | 主键,自增 |
| phone | VARCHAR(11) | 手机号,唯一索引(登录用) |
| password_hash | VARCHAR(255) | bcrypt 加密后的密码 |
| name | VARCHAR(50) | 昵称 |
| role | VARCHAR(20) | user / operator / admin(角色权限) |
| balance | DECIMAL(10,2) | 账户余额(单位:元),默认 0.00 |
| status | VARCHAR(10) | active / frozen(冻结用户不能充电) |
| created_at | DATETIME | 注册时间 |
设计思路:
-
手机号 + 密码登录,没有做第三方登录(老板说成本高)。
-
role字段控制权限:普通用户只能看到自己的订单;运营商能看到自己站点的数据;admin 能看到所有。 -
balance放用户表里,方便实时扣费。但注意,账户余额和支付记录必须保持一致,通过事务来保证。
槽点: 一开始我没建 phone 索引,结果用户量到 1000 的时候,登录查询明显变慢。后来加上 CREATE INDEX idx_users_phone ON users(phone); 瞬间好了。——索引是爹,别忘了建。
2. stations —— 充电站表(桩在哪儿)
每个物理位置的充电站,比如“望京 SOHO 充电站”。
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INTEGER | 主键,自增 |
| name | VARCHAR(100) | 充电站名称 |
| address | VARCHAR(200) | 详细地址 |
| latitude | DECIMAL(10,8) | 纬度(地图打点用) |
| longitude | DECIMAL(11,8) | 经度 |
| total_ports | INTEGER | 总桩口数(如 10 个桩) |
| available_ports | INTEGER | 当前可用桩口数(实时更新) |
| status | VARCHAR(10) | online / offline / maintenance |
| power | DECIMAL(5,2) | 充电桩功率(kW),如 60.00 |
| pricing | DECIMAL(5,2) | 服务费单价(元/度),如 0.80 |
| created_at | DATETIME | 建站时间 |
设计思路:
-
latitude/longitude用于前端地图展示(React-Leaflet 读这两个字段打点)。 -
available_ports是一个冗余字段,可以通过charging_sessions实时计算,但为了性能,选择在每次开始/结束充电时更新它。 -
status用于运维人员手动下线某个场站。
表关系: stations 是一,charging_sessions 是多。一个充电站有多个充电订单。
3. charging_sessions —— 充电会话表(最核心的业务表)
这是整个系统最繁忙的表,每一次充电行为就是一条记录。
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INTEGER | 主键,自增 |
| session_no | VARCHAR(32) | 会话编号(唯一,展示给用户) |
| user_id | INTEGER | 外键,关联 users.id |
| station_id | INTEGER | 外键,关联 stations.id |
| pile_no | VARCHAR(20) | 具体哪个桩(如 "A-01") |
| start_time | DATETIME | 开始充电时间 |
| end_time | DATETIME | 结束充电时间(未结束时为 NULL) |
| duration_min | INTEGER | 充电时长(分钟) |
| electricity | DECIMAL(8,2) | 充电度数(kWh) |
| total_amount | DECIMAL(10,2) | 总金额(电费 + 服务费) |
| status | VARCHAR(20) | charging / completed / cancelled / failed |
| created_at | DATETIME | 记录创建时间 |
设计思路:
-
session_no展示给用户,不用自增 id(显得专业)。 -
station_id+pile_no可以定位到具体某一个充电口。 -
duration_min和electricity从设备上报的数据中获取(模拟阶段可以随机生成)。 -
status字段配合 WebSocket 实时更新前端状态。
表关系:
-
charging_sessions->users(多对一) -
charging_sessions->stations(多对一) -
charging_sessions->payments(一对一,一次充电对应一次支付)
4. payments —— 支付记录表(钱去哪儿了)
充电结束后,用户要付钱,这张表记录每一笔资金流水。
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INTEGER | 主键,自增 |
| session_id | INTEGER | 外键,关联 charging_sessions.id(唯一) |
| user_id | INTEGER | 外键,关联 users.id |
| amount | DECIMAL(10,2) | 支付金额 |
| payment_method | VARCHAR(20) | balance / wechat / alipay(演示用余额) |
| status | VARCHAR(20) | pending / success / failed |
| paid_at | DATETIME | 支付完成时间 |
| created_at | DATETIME | 记录创建时间 |
设计思路:
-
session_id加上唯一约束,保证一次充电只有一次支付,防止重复扣费。 -
支付成功后,通过事务同时更新
users.balance(扣减)和payments状态。 -
演示环境我们只做“余额支付”,
wechat/alipay是留给未来的接口。
坑位提醒: 千万别用 float 存金额,精度会炸。必须用 DECIMAL(10,2)。
5. reservations —— 预约表(占个坑)
用户怕到站没桩,可以先预约。
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INTEGER | 主键,自增 |
| user_id | INTEGER | 外键,关联 users.id |
| station_id | INTEGER | 外键,关联 stations.id |
| pile_no | VARCHAR(20) | 预约的具体桩号 |
| reserve_time | DATETIME | 预约时间 |
| expire_time | DATETIME | 预约过期时间(默认 15 分钟后无效) |
| status | VARCHAR(20) | active / used / expired / cancelled |
| created_at | DATETIME | 记录创建时间 |
设计思路:
-
expire_time:用户预约后 15 分钟内没扫码开始充电,预约自动释放(通过定时任务或查询时判断)。 -
预约成功后,前端需倒计时提醒。
-
这张表可以不用太复杂,MVP 阶段甚至可以先不做预约功能。
6. fault_alerts —— 故障告警表(运维的灵魂
充电桩坏了,系统得知道。
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INTEGER | 主键,自增 |
| station_id | INTEGER | 外键,关联 stations.id |
| pile_no | VARCHAR(20) | 故障桩号 |
| alert_type | VARCHAR(30) | 通信故障 / 过温保护 / 绝缘故障 等 |
| alert_content | TEXT | 详细描述 |
| is_resolved | BOOLEAN | 是否已处理,默认 0 |
| resolved_at | DATETIME | 处理时间 |
| created_at | DATETIME | 告警发生时间 |
设计思路:
-
告警数据可以从设备模拟脚本生成,也可以由运维人员手动录入。
-
后台管理界面需要有一个“告警列表”,按
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);
五、给前端的几句“数据库忠告”
-
外键约束在 SQLite 默认不开启,记得执行 PRAGMA foreign_keys = ON; 不然删了 user 订单还在。
-
日期时间统一存 CURRENT_TIMESTAMP,前端展示时再格式化。
-
金额相关字段禁止用 float,用 DECIMAL。
-
索引不是越多越好,但登录字段(phone)、外键字段一定要建。
-
不要害怕冗余:stations.available_ports 就是冗余,但少了它每次都要 COUNT 订单表,性能会崩。
如果你也是一个人全栈、项目初期、预算有限,抄我这 6 张表完全够用。等哪天日活过万、数据量破千万,我们再一起研究分库分表也不迟。
下一篇我会讲 “JWT 认证 + 角色权限:充电桩平台的安全体系搭建” ,教你怎么用 Express 给这些表穿上盔甲,防止被小学生拖库。
项目地址: github.com/Rhi637/ev-c…
在线演示: rhi637.github.io/ev-charging…
评论区欢迎开喷:你这设计不符合第三范式!SQLite 上生产就是找死!为什么不用 PostgreSQL?—— 你说得都对,但我先上线赚钱了。
点赞过 500,我把 better-sqlite3 的事务封装代码也发出来。