单机商城用户系统实战:SQLite存储与权限控制详解

发布时间:2026/10/21 5:54:14
单机商城用户系统实战:SQLite存储与权限控制详解
最近翻到一个购物商城项目的源码越看越觉得有意思。这个项目完全单机运行、不联网所有数据存储都靠SQLite一个文件搞定却能实现完整的用户注册登录、商品浏览、购物车、下单结算这些功能。很多人一听到“单机”“SQLite”就觉得是玩具项目实际扒开代码才发现里面把用户系统做得相当规整该有的边界校验、状态管理、权限控制一样不少。这篇文章就专门聊这个项目里最核心的部分——用户系统的实现。我会从整体设计思路讲起然后拆数据表结构、注册登录流程、会话保持方式、权限控制再附上我实际跑代码时踩过的坑和排查思路。不管你是刚入门想看看真实项目长什么样还是打算自己做个不依赖网络的本地商城或工具类软件这篇内容都能直接给你参考。1. 单机商城项目的整体设计思路1.1 为什么选择SQLite而不是MySQL或文件存储先聊一个很多人都会问的问题都做购物商城了为什么不直接用MySQL非要SQLite单机、不联网这个前提决定了数据存储方案的选择逻辑。MySQL这类数据库需要独立服务进程安装配置一套下来很重而且对普通用户来说“启动服务”本身就有门槛。SQLite是一个嵌入式数据库它没有独立进程就是直接在你的程序里以库函数的形式运行数据全部存在一个.db文件里。对单机应用来说这几乎是零成本的选择——你不需要装任何额外的数据库软件写代码时连上文件就能用分发的时候把那个数据库文件一起拷贝就行。关于文件存储有人会说数据量不大我直接写JSON或者CSV文件不就好了项目源码之所以没有走这条路核心原因在于查询和事务。文件存储要自己处理并发读写、数据一致性、按条件筛选数据这些问题两三行代码写完的逻辑很难保证不出错。SQLite基于SQL天然支持结构化查询而且支持事务用户下单这种东西一旦写了一半断电了文件存储可能就残了SQLite还能回滚到之前的一致状态。所以项目中选SQLite是非常务实的方案单机环境下它比MySQL轻比文件存储可靠性能在几万条到几十万条数据这个量级下完全够用。用一句通俗的话形容——SQLite就像一个“口袋里的工具箱”平时不占地方需要的时候拿出来就能干活。1.2 用户系统在整个项目中的位置这个商城项目能跑通整个购物流程用户系统是地基。商品可以浏览但真正要下单结算必须得有用户身份。整个用户系统承担的任务很明确用户注册把账号信息写入数据库用户登录校验凭证并建立会话状态密码加密存储防止数据库文件泄露后密码直接暴露区分普通用户和管理员控制不同角色的操作范围维护用户与购物车、订单、收藏、地址这些业务数据的关联如果从数据库表关系来看用户表是所有业务表的“锚点”。购物车表里有一个用户ID字段订单表里有用户ID地址表里有用户ID。没有用户系统这些表就都成了无主数据。所以看这个项目的代码入口我建议先看用户相关的表和接口把这个地基摸清楚后面的商品、购物车、下单逻辑看起来都会顺畅很多。1.3 单机场景下用户系统的特殊性单机用户系统跟联网应用的用户系统关注点不太一样。联网应用要考虑分布式会话、多端同步、验证码服务、第三方登录这些事但单机项目只需要解决一个用户在本机上的完整操作链路。不过单机不代表可以偷懒。源码里让我比较惊讶的是它并没有因为“只是一个人用”就砍掉密码加密和角色校验这些环节反而把这些当成安全底线来处理。这说明作者很清楚一个道理即使没有网络攻击数据库文件本身也有可能被拷贝走密码绝不能明文存。另外单机应用里同一个用户可能同时存在多个窗口实例这就涉及SQLite并发读写和会话状态隔离的问题后面我会具体展开。2. 用户系统的数据表设计与核心字段2.1 用户基础信息表的结构先看最核心的用户表。项目里的表结构大致是CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, nickname TEXT DEFAULT , avatar TEXT DEFAULT , role INTEGER NOT NULL DEFAULT 1, status INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL );逐字段分析一下为什么这么设计。id用整型自增主键这算是SQLite的常规操作。很多项目会纠结用UUID还是自增整数单机场景下自增整数就足够而且做关联查询时整数索引效率更高。username必须唯一这是登录凭证。注意它加了UNIQUE约束目的是在数据库层面就拦住重复用户名而不是只靠代码里查一遍再插入。password_hash存的是加密后的密码字符串不是明文也不是简单的哈希。这一点的意义后面单独说。role字段用整数表示角色我看到的项目里role1是普通用户role2是管理员。这种设计比直接存字符串“admin”或“user”灵活节省存储空间判断也快。status字段表示账号状态1正常0禁用。商城项目需要禁用某些违规账号没有这个字段就只能删数据不好。created_at和updated_at是时间审计字段虽然单机项目看着不显眼但你想排查“这个账号什么时候注册的”“密码什么时候改的”没这些字段就得抓瞎。2.2 地址、购物车、收藏等关联表怎么设计光有一个用户表不够购物商城里的业务数据都是围绕用户展开的。项目里与此相关的表还有这几张用户地址表CREATE TABLE IF NOT EXISTS user_address ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, receiver TEXT NOT NULL, phone TEXT NOT NULL, province TEXT NOT NULL, city TEXT NOT NULL, detail TEXT NOT NULL, is_default INTEGER DEFAULT 0, created_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE );购物车表CREATE TABLE IF NOT EXISTS cart_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, product_id INTEGER NOT NULL, quantity INTEGER NOT NULL DEFAULT 1, selected INTEGER NOT NULL DEFAULT 1, added_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE );这些关联表都通过user_id和用户表建立外键关系并且设置了ON DELETE CASCADE意思是用户一旦删除他名下的地址、购物车数据会跟着自动清理。这个细节好评不然删用户后数据库里会留下一堆没人认领的孤儿数据。2.3 SQLite外键约束的坑这里必须注意一个SQLite特有的坑SQLite默认是不开启外键约束的也就是说你在建表语句里写了FOREIGN KEY但如果连接数据库时没有执行下面这条命令外键根本不会生效PRAGMA foreign_keys ON;这个PRAGMA是每个连接独立的不是设置一次全局生效。项目代码在初始化数据库连接后第一时间执行了这条命令这点非常关键。我看到很多半成品项目建表时写了外键跑起来却没有任何约束效果就是这个原因。你在自己实现时务必确认连接池里的每个连接都执行了这条PRAGMA。3. 注册与登录的实现细节3.1 注册流程校验、加密、落库用户注册是用户系统的入口。代码里注册的处理流程可以概括为四种参数校验、用户名查重、密码加密、插入数据。先后端参数校验。项目里接收前端传过来的用户名、密码、确认密码后端会做三件事用户名不能为空长度限制在3到20个字符密码不为空长度至少6位两次输入的密码一致这些校验逻辑通常在一个独立的校验函数中完成不合法就直接抛错返回不会进入数据库操作。为什么要后端再校验一遍因为前端校验只是体验优化别人完全可以绕过前端直接构造请求。单机项目也一样万一有人直接调用内部接口或脚本操作数据库后端校验就是最后一道防线。然后是用户名查重。这里用了一条带UNIQUE约束的插入语句来做项目里大体是这样try: cursor.execute( INSERT INTO users (username, password_hash, nickname, role, status, created_at, updated_at) VALUES (?, ?, ?, 1, 1, ?, ?), (username, password_hash, nickname, now, now) ) conn.commit() except sqlite3.IntegrityError: raise BizException(用户名已存在)这里有个很好的设计思路不先SELECT查一次再INSERT而是直接利用数据库的唯一索引去捕获IntegrityError。这样做省掉了一次查询而且避免了并发窗口下“查到没重复但插入时重复”的竞态问题。3.2 密码加密方式绝对不要用明文这是我看到这个源码项目时最想表扬的设计之一——密码加密。项目中没有用简单的MD5或者SHA1而是用了加盐哈希。什么叫加盐就是你给原始密码拼接一段随机字符串再对拼接后的内容做哈希运算这段随机字符串就是“盐”。每个用户生成一个独立的随机盐存储时把盐和哈希结果一起保存校验时再用同样的盐对输入密码做哈希比对结果是否一致。我当时看到的一段核心逻辑简化后是这样import os import hashlib SALT_LENGTH 16 def hash_password(password: str) - str: salt os.urandom(SALT_LENGTH) # 使用 PBKDF2 做多次迭代哈希增加暴力破解成本 digest hashlib.pbkdf2_hmac( sha256, password.encode(utf-8), salt, iterations100000 ) return f{salt.hex()}${digest.hex()} def verify_password(password: str, stored: str) - bool: salt_hex, digest_hex stored.split($) salt bytes.fromhex(salt_hex) digest hashlib.pbkdf2_hmac( sha256, password.encode(utf-8), salt, iterations100000 ) return digest.hex() digest_hex为什么不直接用MD5因为MD5太快了一台普通电脑一秒钟可以算几百万次攻击者拿到数据库文件后可以无限尝试常用密码口令。PBKDF2通过iterations参数把单次计算成本拉高比如10万次迭代攻击者想暴力破解的成本就大了很多。如果项目是用Python的hashlib库实现pbkdf2_hmac是很顺手的一个方案。提示加密方案宁可选择慢一点、计算复杂度可控的算法也不要追求“快”。哈希算法在密码这个场景下越慢越安全因为攻击者和你在同一台机器上拼算力。3.3 登录流程与会话保持登录逻辑相比注册更复杂涉及状态管理。项目里登录的处理步骤是根据用户名从数据库查出用户记录如果用户不存在直接返回“用户名或密码错误”存在就比对密码哈希检查账号状态禁用账号不允许登录登录成功后为当前用户创建一个会话标识进入“已登录”状态登录过程中的返回信息有个讲究不要把“用户不存在”和“密码错误”区分得太明显。统一返回“用户名或密码错误”可以有效避免别人猜出哪些账号存在。源码里果然也做了这个处理这是成熟项目该有的小心思。会话保持这里项目用的是基于内存的会话管理。具体来说登录成功后生成一个随机会话ID存到一个本地会话表或者内存字典里同时返回给前端一个会话令牌。后续每次操作都携带这个令牌后端根据令牌找到对应的用户ID。import uuid session_store {} def create_session(user_id: int) - str: token uuid.uuid4().hex session_store[token] { user_id: user_id, created_at: time.time(), last_active: time.time() } return token def get_session_user(token: str): if token not in session_store: return None session session_store[token] # 每次访问都刷新最后活跃时间用于超时判断 session[last_active] time.time() return session[user_id]可能有人会问会话数据为什么不直接存到SQLite里其实两种都可以但单机项目里用内存存储更简单。应用一重启会话就会失效用户需要重新登录——这在单机场景下可以接受而且更安全防止旧会话一直有效。3.4 记住登录状态与记住密码功能的实现很多商城项目会有“记住我”选项。这个项目里也实现了类似效果登录时如果勾选了“记住我”会额外生成一个持久化的令牌写进本地文件或数据库的token表里。下次启动程序时检测到有效令牌就自动恢复登录状态。这里有一个安全提醒单机项目的“记住我”令牌本质上就是一把门钥匙谁拿到这个令牌谁就能冒充用户。代码里需要做到两点令牌用随机数生成不要用用户名密码的某种可预测组合令牌至少要有过期时间就算被泄露也不能永久有效。我看到源码里给令牌设了30天有效期这个策略是合理的。4. 权限控制与用户状态管理4.1 普通用户与管理员角色的权限模型用户表里的role字段在权限控制中起到决定性作用。项目中不同的角色能做的事情完全不同角色可以做的事情不能做的事情普通用户role1浏览商品、管理购物车、下单、查看自己的订单、管理收货地址修改商品信息、查看所有用户数据、操作订单状态管理员role2普通用户的所有能力之外还能管理商品上下架、调整价格、查看全部订单、处罚用户账号无理论上最高权限这种用整数表示角色并逐层开放权限的做法比单独维护一个权限表要轻量得多对于小型单机系统完全够用。在每个需要权限的接口里代码都会先取当前会话用户的角色再判断是否允许操作。比如删除商品的操作开头大概是这样def admin_required(user_id: int): user user_repo.get_by_id(user_id) if user is None or user[role] ! 2: raise ForbiddenException(仅管理员可操作)这套逻辑的门道在于权限判断必须放在每个敏感操作的最前面而且必须在后端做不能只靠前端隐藏菜单来限制。前端隐藏菜单只算体验优化不是安全控制。4.2 会话过期与账号状态的实时校验拿到会话不一定等于能一直畅通无阻。项目里有两个细节值得关注。第一会话有过期机制。为了不让用户一直挂着项目在每次请求时都会检查created_at或last_active如果超过设定时间比如30分钟就判定会话失效要求重新登录。这避免了程序一直开着但人已经离开很久的风险。第二每次关键操作会重新校验账号状态。理论上用户登录成功后账号状态就锁定了但管理员可能在你登录期间把你的账号禁用。如果系统只在登录时检查一次那么这个被禁用的用户还能继续用旧会话操作这就有问题了。源码里在操作购物车、订单结算这些关键节点都会重新查一次用户表里当前用户的status字段发现是0就立即登出并拒绝操作。4.3 找回密码与修改密码的本地实现商城项目顺带会有修改密码的功能。单机应用做不了手机验证码、邮件验证码但至少要做旧密码验证。项目里的修改密码流程是用户输入原密码、新密码、确认新密码先校验原密码是否正确再校验新密码与确认密码一致且符合复杂度要求用新密码生成新的加盐哈希更新用户表让当前会话失效强制重新登录最后一步很多人会忽略但你想想密码都换了旧会话还继续有效就说不通了。项目让所有现有会话失效是在用户表上记录一个password_changed_at时间戳并且会话令牌里也存当时的登录时间两相对比登录时间早于密码修改时间的会话就作废。这个设计思路很值得借鉴。5. 实操过程从零实现用户系统核心模块5.1 初始化数据库连接与基础封装如果你想照着这个思路自己也实现一套最稳妥的方案是分三步走初始化、建表、写服务代码。第一步是数据库连接初始化。SQLite在Python中的标准用法是import sqlite3 def get_connection(db_path: str shop.db) - sqlite3.Connection: conn sqlite3.connect(db_path) conn.row_factory sqlite3.Row conn.execute(PRAGMA foreign_keys ON) conn.execute(PRAGMA journal_mode WAL) return conn这里补充两个参数的说明row_factory sqlite3.Row让查询结果可以用类似字典的方式通过列名访问代码可读性更好。journal_mode WAL把SQLite的日志模式设置为预写日志。单机项目虽然只有一个用户但程序可能有多个窗口或线程同时访问数据库WAL模式能显著减少读写锁冲突。5.2 建表脚本与初始化迁移项目里没有一个单独的建表脚本文件而是在程序启动时统一执行CREATE TABLE IF NOT EXISTS。这种做法在应用初期很实用不需要部署额外的迁移工具。你可能担心以后改表结构怎么办——那当然还是可以用迁移脚本但项目起步阶段“启动即建表”是最顺手的方案。实际执行建表时建议把所有建表语句放在一个列表里按依赖顺序执行SCHEMA_SQL [ CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, nickname TEXT DEFAULT , role INTEGER NOT NULL DEFAULT 1, status INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); , CREATE TABLE IF NOT EXISTS user_address ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, receiver TEXT NOT NULL, phone TEXT NOT NULL, province TEXT NOT NULL, city TEXT NOT NULL, detail TEXT NOT NULL, is_default INTEGER DEFAULT 0, created_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); ] def init_db(db_path: str): conn get_connection(db_path) for sql in SCHEMA_SQL: conn.execute(sql) conn.commit() conn.close()注意多张表有外键关联时建表顺序有讲究必须先建父表users再建子表user_address不然REFERENCES会找不到对象。5.3 用户服务层的完整实现思路一个可用的用户服务层最好提供统一的方法让上层业务不用关心SQL细节。核心方法应该包括register(username, password, nickname)login(username, password, remember)logout(token)get_user_by_id(user_id)get_user_by_username(username)update_password(user_id, old_password, new_password)disable_user(user_id)管理员用每个方法都对应一段相对完整的逻辑。以注册方法为例完整流程是def register(username: str, password: str, confirm: str, nickname: str ): # 第1步基础校验 if not username or len(username) 3 or len(username) 20: raise BizException(用户名长度需在3到20个字符之间) if not password or len(password) 6: raise BizException(密码长度至少6位) if password ! confirm: raise BizException(两次输入的密码不一致) # 第2步加密密码 password_hash hash_password(password) # 第3步写入数据库利用唯一索引捕获重复 now datetime.now().isoformat() conn get_connection() try: cursor conn.execute( INSERT INTO users (username, password_hash, nickname, role, status, created_at, updated_at) VALUES (?, ?, ?, 1, 1, ?, ?), (username, password_hash, nickname, now, now), ) conn.commit() user_id cursor.lastrowid return user_id except sqlite3.IntegrityError: conn.rollback() raise BizException(用户名已存在) finally: conn.close()每个步骤之间用注释隔开就没有那么难读了。实际项目代码核心逻辑基本如此只不过文件名和函数封装方式稍有不同。6. 常见问题与排查技巧实录6.1 数据库文件被锁定的问题单机项目最常见的报错就是database is locked。SQLite同一时间只允许一个写事务如果你的程序开了多个连接读写逻辑交错很容易出现锁冲突。排查思路主要有三条每个操作都要严格关闭连接用with或finally确保释放资源。开启WAL模式读写可以并行减少锁冲突。把短事务和长事务分开不要在一个事务里做耗时操作。我之前排查过一个确实很隐蔽的问题程序里有一条初始化数据的循环每跑一次都新开连接且不提交大量连接累积起来把数据库锁死了。后来统一了连接管理所有写入都走同一个入口函数问题才算根治。6.2 密码正确但登录失败这种问题的常见原因不是密码哈希逻辑的问题而是会话或状态缓存问题。如果你改了密码但某个地方的旧会话没有被清理就会出现“密码对了却显示未登录”的怪现象。还有一种很常见的坑注册时密码加密参数和登录时校验参数不一致。比如注册时用的iterations100000登录时某处写成了1000那么校验必然失败。出现这种情况时先用同一个测试密码分别跑一遍hash_password和verify_password看看能否通过不能通过再去查出库里的存储格式是否被截断了。6.3 用户数据删不掉或外键不生效如果发现删除用户后地址记录和购物车记录还在十有八九是PRAGMA foreign_keys ON没有执行成功。记住SQLite每个连接都要执行一次这个PRAGMA光在SQLAlchemy或某个配置里设置了不代表实际连接上生效了你可以在删除前用PRAGMA foreign_keys查询当前值验证。6.4 用户名大小写重复的问题我跑这个项目时还发现一个边界问题SQLite的UNIQUE约束对字符串的大小写敏感。也就是说Admin和admin是可以同时存在的。如果业务上不允许这种情况你需要额外在代码里做一次大小写不敏感的比较SELECT id FROM users WHERE username ? COLLATE NOCASE LIMIT 1或者在表定义时给用户名加COLLATE NOCASE约束username TEXT NOT NULL UNIQUE COLLATE NOCASE这个坑在注册量不大时很难暴露但确实涉及用户体验建议在一开始就想清楚。7. 实战心得与扩展建议我整体看下来这个单机商城项目的用户系统在结构和代码规范上已经超过了不少“能跑就行”的项目。它没有为了简单而牺牲安全和工程约束注册登录、加盐哈希、会话管理、角色权限、关联表清理每一环都有完整实现读起来就像一位有经验的开发者在给你演示“一个严谨的单机应用应该怎么写”。我个人在阅读源码时感受最深的就是它几乎把所有边界情况都考虑到了尤其是外键CASCADE和重复注册的并发竞态处理这些细节恰恰是很多进阶学习者最容易遗漏的部分。如果你打算参考这套实现我建议你可以按自己实际需求做几处扩展。一是增加用户操作日志表记录登录、退出、修改密码、下单这些关键行为。单机项目虽然不联网但日志排查问题特别有用等于给自己留了一双眼睛。二是补充用户搜索功能。商城后台经常需要按用户名、手机号、订单号搜用户给user_address表的phone字段建索引搜索速度会好很多CREATE INDEX idx_user_address_phone ON user_address(phone);三是考虑把SQLite文件放到用户数据目录而不是程序安装目录不然有些系统环境下程序目录是受保护的数据库根本写不进去。最后分享一个小技巧你在单机项目里调试完用户的注册登录逻辑后可以直接把users表里某一行的role改成2再重启程序就能瞬间拥有管理员权限进后台。这是开发阶段快速试验权限功能的好办法比重新走一遍初始化流程省事得多。当然上线前记得把这条捷径从代码里清掉。这个项目最值得借鉴的就是一句话小项目也要有大项目的安全意识。数据和权限无小事跟它是单机还是联网没有关系。你把地基打扎实了后面往上盖商城任何楼层都不会心虚。