如果你搜Python练手项目,排名靠前的大概率是图书管理系统、爬虫、记账本这类。但真正能帮你把电商知识串起来的项目,至少要包含商品、订单、支付、库存,而线上购书平台如果再加上抽奖和积分兑换,基本就是把运营玩法也补齐了。我前阵子做过一个这样的小项目——集图书商城、抽奖活动、积分兑换于一体的线上购书平台,这篇文章把整条实现路径和关键代码拆开来聊。
内容不局限于怎么建表和调接口,我会重点讲为什么这么设计:金额精度用什么类型、库存什么时候扣、抽奖怎么防超发、积分扣减怎么不超扣。适合准备毕设、想给简历添一个完整Web项目、或者对中小型电商系统怎么运作好奇的读者。老规矩,下文统一用“购书平台X”代称这个项目。
1. 先拆业务场景,再定框架:购书平台X的功能边界与技术选型
1.1 功能边界:别把每个模块都做成独立系统
开始写代码之前,我先做了一件事:把需求拆成三条线。
第一条是用户线,包括注册、登录、个人信息维护;第二条是交易线,包括图书分类浏览、商品详情、购物车、下单、支付回调处理、订单状态流转;第三条是运营线,包括抽奖活动、积分发放与消耗、积分兑换下单。这三条线看似独立,实际都围绕同一个用户ID运转,很多数据最终要落在一起。
交易线的技术重点在数据一致性,比如订单支付状态不能乱、库存不能超卖;运营线的技术重点在概率控制和防刷,比如抽奖中奖率要可配置、接口不能被脚本刷爆;积分兑换的重点则是账务准确,比如用户积分不能被扣成负数。三条线拆清楚之后,再考虑用什么框架,思路就顺了。
这里有个新手容易犯的错误:一上来就想着把积分、抽奖做成独立微服务。小型系统里微服务带来的网络开销和部署复杂度远大于收益,老老实实用一个Flask应用加模块化蓝图(Blueprint)就够了。等真有并发量了再拆不迟。
1.2 技术栈选择:Flask + SQLAlchemy + Redis + MySQL
技术栈的选择我权衡过一阵子。Django自带Admin后台、迁移脚本、用户认证,做内容管理确实省事,但购书平台的运营玩法较多,抽奖规则、积分规则都要灵活改,我更习惯用Flask这种轻框架把控制权握在自己手里。
最终定的方案:
- 后端:Python 3.10 + Flask 2.x + Flask-SQLAlchemy
- 数据库:MySQL 8.0,存用户、商品、订单、积分这类核心业务数据
- 缓存:Redis 6.x,存购物车、抽奖库存、接口限流计数
- 前端:Jinja2模板 + Bootstrap,没有上前后端分离
为什么上Redis?因为抽奖库存的并发扣减、签到次数的计数、订单超时提醒这几块都需要一个“支持原子操作且读取快”的存储。MySQL虽然也能做,但频繁更新同一行会引发锁竞争,Redis适合做高频率的小更新。MySQL和Redis分工明确,前者管最终账本,后者管流量高峰。
1.3 数据库表结构总览:先想清楚表之间的关系
我建表时遵循一个原则:先明确每张表要回答什么问题,再设计字段。比如订单表需要回答“这笔订单谁买的、买了多少、支付没有”,抽奖记录表需要回答“谁在什么时候抽到了什么”。带着问题去建表,字段不会乱。
核心表大致如下:
| 表名 | 作用 | 核心字段 |
|---|---|---|
| users | 用户账号与基础信息 | id, username, password_hash, points |
| books | 图书商品 | id, title, author, isbn, price, stock, cover_url |
| book_categories | 图书分类 | id, name, parent_id |
| cart_items | 购物车条目 | id, user_id, book_id, quantity |
| orders | 订单主表 | id, order_no, user_id, total_amount, pay_amount, status, receiver_name, receiver_phone, receiver_address |
| order_items | 订单明细 | id, order_id, book_id, book_title, book_price, quantity |
| prizes | 抽奖奖品配置 | id, prize_name, prize_type, weight, total_count, remain_count |
| prize_logs | 抽奖记录 | id, user_id, prize_id, is_win, created_at |
| points_accounts | 积分账户 | id, user_id, balance |
| points_records | 积分流水 | id, user_id, change_amount, source_type, ref_id |
| exchange_products | 积分兑换商品 | id, name, need_points, stock |
| exchange_orders | 兑换订单 | id, user_id, product_id, points_cost, status |
订单表里设计了order_no订单号,不用自增ID直接对外展示,原因是为了防止别人通过订单号猜测你的销量,同时方便分布式环境下生成唯一单号。订单明细里冗余了book_title和book_price,这是电商里经典的“快照”做法——下单时的标题和价格必须原样保存,哪怕之后商品改名、改价,历史订单依然有据可查。
2. 图书商城主链路:商品、购物车、订单、库存的四个关键决策
2.1 图书数据模型:ISBN与金额精度的细节
图书在电商里属于最简单的SKU场景,没有颜色、尺码这类多规格属性,一本书就是一个商品ID。books表里最值得强调的是price字段的类型选择。
我用的是DECIMAL(10,2),不是FLOAT。原因很简单:浮点数在计算机里是近似值。你在Python交互环境里跑一下0.1 + 0.2,得到的是0.30000000000000004。订单金额、优惠金额、退款金额一旦出现这种误差,对账就对不上,用户也会投诉。Decimal在MySQL底层按字符串存储,能精确表达小数。
from decimal import Decimal book_price = Decimal("59.90") quantity = 3 subtotal = book_price * quantity # 精确得到179.70顺便提一个坑:从数据库取Decimal之后,如果做计算前先转成float再乘,误差就回来了。规范是全程用Decimal,只有在返回给前端展示时才转成字符串。推荐Jsonify时封装一层to_dict,把Decimal转成str。
2.2 购物车:session实现与Redis实现之间的取舍
购物车有两种主流搞法:一种是放Session/Cookie里,另一种是放Redis或MySQL里。我在项目里前期用Session,后期把高频操作迁到了Redis。
Session方案的好处是零成本,用户把book_id和quantity塞进会话里就行,适合小规模演示。缺点也很明显:用户换设备购物车就没了,服务端重启数据也丢,而且Session存太多东西会增大请求体积。Redis方案的好处是购物车跟着用户ID走,任意设备登录都能读到,还能设置过期清掉不满意的遗留项。
Redis里购物车的设计,我用的是hash结构:
# key: cart:user:{user_id} # field: book_id value: quantity HSET cart:user:10001 101 2 HSET cart:user:10001 102 1查询购物车时一次hgetall取出全部条目,再批量查books表补上价格和封面。这个方案在查询数据库时需要避免循环查N次,正确做法是拿到所有book_id后,用WHERE id IN (...)一次查出所有图书,再在内存里组装。
2.3 订单状态机与库存扣减时机
订单状态我分成六个:待支付、已支付、拣货中、已发货、已完成、已取消。一次只允许单向流转,比如待支付能取消,已支付不能直接取消,只能走退款流程。这个状态机用数据库字段status加上Python枚举来管理,避免到处写魔法数字。
库存扣减时机是我踩过最多坑的地方。方案一,下单时预扣库存,锁定商品保证用户一定买得到,但缺点是占着库存不付款的人太多,真实想买的人反而看到缺货;方案二,支付成功后扣库存,库存利用率高,但高并发下可能出现“明明显示有货,支付完却发不出货”的超卖。
我采用的是支付成功后再扣库存,同时用Redis做一层预扣保护:
- 用户点击“提交订单”时,仅在Redis里扣减“可抢库存”;
- 用户支付成功,支付回调里再把MySQL里的真实库存减掉;
- 支付失败或订单超时,Redis库存回补。
这个方案的关键在于第1步必须原子性操作,不能先查Redis再减,两步之间一旦并发就超卖。具体用Lua脚本实现,下一章抽奖库存也是同样的套路,这里先卖个关子。
2.4 订单超时未支付,怎么优雅处理
订单超过30分钟未支付,应当自动取消,库存回补。实现有两种成熟路线:定时任务扫表,或者Redis过期事件通知。扫表方式最简单,每5分钟查一次待支付且创建时间超时的订单,批量取消。小项目里完全够用,但数据库订单量大的时候会有扫描压力。
Redis过期事件通知的优势是实时,设置订单ID到Redis并指定过期时间,过期后收到通知再取消订单。缺点是依赖Redis的key过期事件,存在事件丢失的可能,生产环境一般把它当辅助手段。我最后用的是定时任务为主,Redis实时回补为辅。实际效果稳定,代码也简单。
3. 抽奖活动模块:把概率控制与防刷做成工程化
3.1 奖品配置与权重抽奖算法
抽奖这个功能,外行觉得就是一个random.choice,内行知道里面有好几层坑。第一层是概率必须可配置,运营说“把一等奖概率调到1%”,总不能改代码发版,得把概率写进数据库。我的方案是在prizes表设计一个weight权重字段,按权重占比决定中奖概率。
权重算法的逻辑如下:把每个奖品的权重累加成一个总区间,然后生成一个随机数,看它落在哪个奖品区间里。比如一等奖权重1,二等奖权重9,谢谢参与权重90,那总权重100,随机数落在[0,1)是一等奖,落在[1,10)是二等奖,落在[10,100)是没中奖。
import random def draw_prize(activity_id): prizes = Prize.query.filter_by(activity_id=activity_id).all() total_weight = sum(p.weight for p in prizes) rand_val = random.uniform(0, total_weight) cursor = 0 for prize in prizes: cursor += prize.weight if rand_val <= cursor: return prize return None这里注意一个细节:要用random.uniform而不是random.randint,因为权重之和往往不是整数边界,随机浮点数落区间的逻辑保持一致。另外,如果某个奖品库存为0,查询时应当过滤掉,不能让它继续占权重,否则用户永远只抽到“已售罄”的奖品,体验很差。
3.2 并发抽奖下奖品库存的原子扣减
假设奖品“签名版图书”总共只有10本,20个人同时抽中,系统必须保证只有10个人成功。最保险的数据库写法是UPDATE prizes SET remain_count = remain_count - 1 WHERE id=? AND remain_count > 0,用受影响行数判断是否扣减成功。但把所有抽奖请求都打给MySQL,压力太大,而且这行记录会成为热点行,并发高了容易锁等待。
我的方案是先把库存放在Redis里,抽奖时用Lua脚本做原子扣减。Lua脚本可以保证多条Redis命令按顺序执行,中间不会被其他客户端插入,相当于给并发请求加了一把锁。
-- KEYS[1] 是奖品库存key,比如 prize:stock:{prize_id} -- ARGV[1] 是需要扣减的数量,抽奖一般就是1 local remain = tonumber(redis.call('get', KEYS[1]) or '0') local cost = tonumber(ARGV[1]) if remain >= cost then redis.call('decrby', KEYS[1], cost) return 1 end return 0拿到Lua脚本返回1,再往prize_logs写中奖记录;返回0,说明库存没了,记一条未中奖。写完中奖记录后,再异步把扣减同步到MySQL里的remain_count,保持数据最终一致。
3.3 防刷策略:登录、次数限制、频率限制
抽奖接口一旦上线,最先来的不是用户,是脚本。防刷我从四个维度做,每一层都能挡掉一批:
- 必须登录才能抽奖,未登录直接返回登录提示;
- 每个用户每天最多抽5次,用Redis计数器记录,key为
lottery:times:{user_id}:{日期},INCR后判断是否超过5; - 同一个IP每天最多抽20次,防止换小号薅羊毛;
- 每次抽奖前后校验用户积分余额,因为设定每次抽奖消耗20积分,积分不足直接拦截。
其中第2条和第3条用同一套Redis计数逻辑。次数到了就在接口入口处拦截,连概率逻辑都不必进,省资源。值得提醒的是:防刷记录必须和业务日志分开,写入独立的lottery_logs或access_log,出现问题的时候方便追查。
3.4 中奖记录与发货流程打通
抽中实体奖品后,不能只在页面上弹个“恭喜”,必须走发货流程。我的做法是:抽奖中奖记录表里存一个status字段,0表示待领取、1表示已填写地址待发货,并把中奖记录关联到一张虚似订单上。用户填写收货地址后,后台管理员能看到待发货列表。
这里我给一个务实建议:抽奖奖品里的“积分红包”“优惠券”是即时到账的,抽中就直接改积分余额、写入积分流水;只有“实体书”这类实物奖品才需要走地址填写流程。两种类型的发货处理完全不同,建表时一定要把prize_type字段单独拎出来。
4. 积分体系与兑换逻辑:把消费和激励串成闭环
4.1 积分从哪来:多入口发积分的统一设计
积分体系最忌讳的是今天想起来一个入口加一段代码,明天又加一段,到最后“积分从哪来的”都说不清楚。我的做法是设计一张points_records积分流水表,每条流水记录积分的来源类型和关联单号,所有入口都写同一条逻辑。
积分来源我设了四个入口:
- 购买图书,支付成功按订单金额1:1返积分;
- 每日签到,连续签到额外奖励;
- 抽奖消耗积分(这是负向来源,扣减也走流水);
- 后台手动调整,比如活动补偿、投诉补偿。
流水表字段包括user_id、change_amount(正数收入,负数支出)、source_type(order/signin/lottery/adjust)、ref_id(关联订单号或抽奖记录ID)。有了这张表,用户问“我的积分怎么少了”,一条SQL就能定位,不会扯皮。
4.2 积分兑礼和积分抵现:两种兑换方式的取舍
积分花出去,我做了两种通道:一种是在“兑换商城”里用纯积分换取礼品;另一种是在结算图书订单时用积分抵扣现金。
纯积分兑换的流程:用户选礼品,判断积分账户余额是否足够,足够则扣积分、生成兑换订单、减少礼品库存;不足则提示积分不够。这个流程和普通下单很像,只是支付方式从现金变成了积分。
积分抵现的设计要更小心:不能把所有订单都做成“积分抵遍全场”,要限制规则。我的规则是满100元可用积分抵现,100积分抵1元,每单最多抵10%。规则表单独建一张,后期运营改规则不用动代码。结算页面展示“订单总金额、积分抵扣、应付金额”三个数字,让用户一眼看懂。
4.3 积分扣减的并发与幂等:不能把余额扣成负数
用户只有100积分,但是他同时用两个设备各发起一次兑换请求,如果代码是先查余额再扣减,两次请求都会查到100,然后都执行扣减,积分余额就变成-100或者负数。这是典型的并发超扣问题。
解决办法是用数据库一行的原子更新,让扣减和判断同时完成。
UPDATE points_accounts SET balance = balance - 100 WHERE user_id = 123 AND balance >= 100;受影响行数为1说明扣减成功,为0说明余额不足。这个SQL依赖MySQL的行锁,同一时刻只有一条更新能成功,天然防并发。
再叠加一道幂等防线:兑换订单表给user_id加上“同一人同一兑换商品当天只能下一单”的唯一约束,或者在下单前先查一下是否存在未取消的兑换单。这样即使请求重复提交,也不会产生两笔扣款。
4.4 用户激励闭环:签到-抽奖-兑换-再消费
把商城、抽奖、积分串起来之后,我在系统里跑通了一个完整的用户激励闭环:
第一步,用户每天签到获得10积分,连续签到7天额外获得50积分;第二步,签到得到的积分可以用来抽奖,每次抽奖消耗20积分,抽中积分红包又能回血;第三步,用户买书支付成功,订单金额1:1返还积分;第四步,攒够积分的用户去兑换商城换书或文具,兑换成功后又刺激了下一次消费。
这个闭环最直观的价值是:用户不再只为了买书而打开平台,为了签到、抽奖、兑换,他们每天都会回来一次。留存率上去了,图书销量自然跟着涨。
5. 上线前最容易翻车的三个细节:金额精度、库存一致性、幂等回调
5.1 Decimal精度翻车现场与规范
我测试的时候做过一次很无聊但很有必要的实验:用float类型存99.9元,满减优惠计算后出现了99.89999999999999这种数,页面展示直接穿帮。从那以后我在项目里立了一个规范:金额计算一律Decimal,JSON返回一律转字符串,前端展示再做格式化。
好在这个项目从建表开始就用Decimal,没有埋下历史雷。如果你是从老项目改造,建表时字段已经是float,建议设一个临时字段用新的Decimal列,跑一条SQL把历史金额按照CAST(price AS DECIMAL(10,2))迁移过去,再把旧字段下线。
5.2 Redis库存与MySQL库存的最终一致性
上一章说Redis先扣库存、MySQL异步同步。这里有一个必须面对的隐患:Redis扣减成功但MySQL同步失败,比如网络抖动或者事务提交失败。解决思路是“对账补偿”。
我在项目里加了一个定时任务,每5分钟扫描一次奖品表的remain_count和同步日志,如果发现MySQL的剩余库存大于Redis的剩余库存,说明有扣减没同步成功,就用差量进行补偿更新。同时,超时未支付订单回补Redis库存时也要记日志,方便对账时判断哪笔订单回补了、哪笔还没。
这个方案不可能做到10毫秒级强一致,但对购书平台X这个量级足够。如果未来并发量上到每秒几千单,可以考虑引入消息队列把扣库存动作做成异步任务,辅以消费幂等。
5.3 支付回调的幂等处理:同一个回调可能来三次
支付平台回调有一个特点:不保证只回调一次。同一个支付成功的通知可能隔几秒又来一次,如果你的接口收到回调就把订单状态改成“已支付”、就把库存扣一遍,第二次回调就会把库存扣崩。
幂等处理说穿了就是“先查询再判断”:
order = Order.query.filter_by(order_no=order_no).first() if order.status == OrderStatus.PAID: # 已经处理过,直接返回成功,不做任何扣减 return jsonify({"code": 0, "msg": "success"})更保险的做法是在订单表加一个支付回调唯一流水号字段,并在数据库里建唯一索引。即使两个请求同时进来,一个执行成功,另一个会因为唯一索引冲突失败,也不会产生脏数据。我在购书平台X里两种手段都上了,实际上线后这个接口一次重复处理都没有出过。
5.4 上线前的冒烟测试:概率和并发都要验
功能写完不算完,上线前我建议按我的步骤做一次冒烟测试:
- 准备一套测试数据,包括20本图书、10种抽奖奖品、3个兑换礼品;
- 写一个脚本跑500次抽奖,统计各奖品中奖次数,和理论权重比一下,误差在合理范围才算过;
- 并发模拟20个用户同时兑换同一个仅剩5个库存的礼品,验证积分和库存都没有超扣;
- 模拟支付回调重复发送,验证订单状态和库存都只变动一次。
这四步跑完,核心系统的可靠性才有基本保障。很多项目挂在上线后第一波活动流量,就是因为在本地环境只用了Postman点了两次接口,根本没压过并发。
最后再分享一点我的个人体会。这个购书平台X做完,我最满意的不是功能多,而是把“运营活动”和“交易主链路”捏在了一起。单看抽奖,就是一个Lua脚本加一张表;单看积分,就是流水加减;但把它们放进同一个电商系统里,你不得不认真考虑并发扣减、幂等、一致性和对账方案。这些踩坑经验,比单纯多写一万行业务代码值钱得多。如果后续有空,我打算在这个基础上加优惠券系统和组合支付,让整个营销体系再完整一些,到时候再写一篇新的踩坑记录。