在正式开始前说句实在话:这一版文档是我把整个"升鲜宝社区团购商城"从需求梳理到DDL落地的完整设计过程做了回溯整理。重点不只是功能清单,而是每一步背后的业务判断和技术口径。
先说点背景。我参与过好几个社区团购项目的设计,踩过不少坑。有些系统上线第一天订单量就冲上去,结果团长端分拣单打不出来,配送司机在仓库干等;有些系统功能堆得很全,但数据字典里金额字段用decimal(10,2)存,最后对账对不上。这些问题的根源不在代码,而在设计阶段——功能边界划得不够清楚,数据口径定得不够严谨。
所以这篇文档的定位很明确:面向正在做社区团购或者生鲜配送系统的产品经理、架构师、后端开发,以及准备接手类似项目的团队。我不打算讲那些虚的,就是把这个系统的功能设计、业务流程、数据字典、DDL口径和后台权限设计全部摊开,连设计理由和取舍过程一起讲。
整个系统从下单到履约的链路非常长——用户端、团长端、运营后台、供应链端、分拣配送端,每一端都有自己独立的业务逻辑,但又必须在数据层面强关联。这就是我为什么坚持"功能设计跟着业务流程图走,数据字典跟着功能设计走,DDL口径跟着数据字典走"这个顺序。
1. 生鲜业务模型与通用电商的差异:先想清楚"为什么"再动手
任何软件设计文档的第一章都应该回答一个问题:这个系统的业务本质是什么?如果这个问题没想清楚,后面所有设计都是空中楼阁。
1.1 生鲜商品为什么不能按普通SKU管理
社区团购里卖的菜、水果、肉禽蛋,在商品管理上和3C数码、服饰鞋帽有一个本质区别——非标性。你在京东上买一台手机,SKU可以精确到颜色、内存版本、是否含发票,库存也是精确到个位数的。但你在社区团购买一把青菜,你没法在创建商品时规定"这把青菜必须是480克到520克之间,长度28厘米"。
这是一开始设计"升鲜宝"商品体系时遇到的最大挑战。上游批发市场供应的青菜,一筐可能是40斤,也可能是42斤;这批次的土豆有大有小,挑拣后损耗比例可能达到8%。如果我们机械地套用传统电商的SKU体系,库存永远对不上。
我最终采用的设计是"商品SPU + 批次(Batch)"双层结构:
- SPU层:定义商品的基础信息,比如"本地土豆500g/份",有主图、描述、销售规格。这一层不承载库存。
- 批次层:每次采购到货生成一个批次,记录供应商、采购单价、实际入库量、质检状态、保质期截止时间。这一层承载真实库存。
这样做的好处是:商品被用户看到的样子是统一的,但后台库存是跟着批次走的。一旦发生食品安全问题,可以直接定位到具体批次做下架或者召回,这在生鲜场景是刚需。
1.2 损耗和缺重:普通订单系统不会处理的问题
如果你做过非生鲜电商,你一定会觉得"订单详情里加一个损耗字段"很奇怪。但在生鲜配送里,损耗是每天真实发生的成本。
一个从小程序端流进来的订单写着"土豆2份",用户付款时价格是按照500克/份算的。但在履约环节,分拣员从筐里拿土豆装袋,可能称出来是510克,也可能只有460克,还可能有几个破皮的必须挑出来。这时候就牵涉到两个业务动作:
- 缺重补偿:如果分拣后发现实际重量低于标称重量超过3%,系统要支持在订单明细上做"减重退款",把差额推给用户。
- 损耗登记:分拣过程中废弃的、破皮的商品,需要登记损耗数量,最终会汇总成当天的"损耗率报表",运营拿这个报表去和采购、分拣环节对责。
"升鲜宝"专门设计了两个模块来处理这两个动作:订单明细的"实际重量/实际金额"字段,以及独立的损耗记录表。这也直接影响了数据字典的设计——每个订单明细行都要能单独退款,而不是非要走整单退款。
1.3 先于代码成型的业务规则清单
在设计功能之前,我还强制自己团队先列了一份业务规则清单。这份清单是后面所有流程、数据表、权限设计的基础。我挑几条关键的放在这里,供参考:
| 规则项 | 规则内容 | 触发的系统动作 |
|---|---|---|
| 截单时间 | 每日16:00截止当日订单 | 16:00后自动关闭下单入口,生成采购汇总 |
| 自提核销 | 用户凭提货码到团长处取货 | 团长端扫码核销,订单状态变为"已完成" |
| 售后时效 | 用户可在收到货后24小时内申请售后 | 超过24小时,申请入口关闭,仅支持人工介入 |
| 佣金结算 | 团长佣金按"已核销订单实付金额"计算 | 每日凌晨跑批,计入团长待结算余额 |
| 退款规则 | 生鲜商品不支持无理由退货,只支持坏损退款 | 退款申请需要团长审核通过后才进入退款流程 |
| 库存预占 | 用户下单后预占批次库存,超卖拦截 | 推荐在Redis中做库存预占 |
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 功能模块的边界:用户端、团长端、运营后台与供应链端各管什么
在画功能清单之前,我习惯先画一张角色功能总表,明确每个角色看到什么、能做什么、不能做什么。这样后期才能各自独立演进,不会出现"运营后台改了个字段,团长端炸了"的情况。
2.1 用户端小程序:轻、快、不打扰
用户端是C端入口,按微信小程序做。功能模块可以划分成六个:
- 首页与活动:展示当日的"今日开团"商品列表、限时秒杀、新人专享、团长推荐位。核心逻辑是"按团购日期维度取商品"——今天卖什么菜,是运营在后台提前配好的。
- 商品详情:展示商品规格、价格、团长自提点、预计到货时间。下面挂"已拼X件"来营造从众感。
- 购物车与下单:这里要支持"当日单"和"预售单"两种模式。当日单是当天截单前下的,第二天自提;预售单是活动商品,可以提前几天预定。订单提交时要校验自提点是否在配送范围内。
- 支付:微信支付为主,支持余额支付。支付后生成"自提码"(一单一码),同时给用户推送一条"下单成功+预计提货时间"的模板消息。
- 订单中心:包含待付款、待提货、已完成、售后/退款四个页签。订单详情展示商品、自提点、提货码、售后入口。
- 我的:个人信息、收货地址(自提点聚合)、团长申请入口、优惠券、联系客服。
有一点要特别说一下:用户端不要做"在线客服实时聊天",社区团购的售后天然在线下,用户在线上能找到团长电话就行。做实时聊天功能会大幅增加研发成本,但实际使用率极低。
2.2 团长端:每天高频操作的工具,不是管理后台
团长是社区团购运转的核心节点,但同时他们大多数是兼职——宝妈、便利店老板、全职主妇。所以团长端的设计原则是"一个页面解决一个动作",不要设计需要三级菜单才能找到的功能。
团长端核心功能:
- 开团管理:查看自己负责的自提点今天有哪些团购活动,一键复制开团链接发到微信群。
- 订单查看:按日期查看自己团的所有订单,支持按提货状态筛选。
- 我的分拣单:这是团长每天用得最多的页面。分拣单按"自提点+日期"汇总,列出所有商品的合计份数和备注。团长用这个单子到货后分拣装袋。
- 核销:用户到店后出示提货码,团长扫码完成核销。支持"整单核销"和"部分核销"。
- 售后处理:用户发起售后,团长进行初审——确认是否真的坏了、缺了,然后通过或驳回。
- 佣金中心:查看自己的佣金收入、结算进度。这里我强烈建议把佣金数字做得"大而明显",这是团长继续干下去的动力。
团长端的UI设计有一个特殊要求:核销页的面积要够大,因为团长经常一边跟用户说话一边操作,核销按钮要一眼能找到。这个细节在开发需求评审时经常被忽略,但对用户体验影响非常大。
2.3 运营后台:商品、活动、用户、内容,四个主要工作区
运营后台是B端角色里功能最重的,按"运营人员的工作流"来组织。我把它的功能拆成四个工作区:
商品工作区:
- SPU管理:商品的创建、上下架、改价、设置销售规格。
- 批次管理:每个SPU下的采购批次,记录供应商、采购成本、到货日期、库存余量。
- 分类管理:一级/二级分类,支持拖拽排序。
- 商品审核:当供应商(后面会讲)自己创建商品时,需要运营审核后才能上架。
活动工作区:
- 团购场次管理:创建"今日团购""周末特惠"等场次,每个场次关联一组商品。
- 秒杀管理:设置秒杀时间段、秒杀价、限购数量。
- 优惠券管理:创建满减券、折扣券,支持发放人群定向。
用户工作区:
- 用户列表:查询、禁用、打标签。
- 团长管理:审核用户提交的团长申请,设置团长佣金比例,查看团长业绩。
内容工作区:
- 海报配置:商品海报图、活动页模板。
- 消息推送:通过模板消息给用户推送"到货提醒""自提提醒"。
关于运营后台,我一直跟团队强调一个原则:任何列表页都要支持批量操作。运营每天处理几十个商品、几百个用户,如果下架商品要一个一个点,研发会被骂死。
2.4 供应链端:采购、分拣、配送三个子系统的协同
供应链端通常被做成独立后台,因为使用人群和运营后台不太一样。这个端最复杂的地方在于流程协同——采购单要转成分拣任务,分拣任务要生成配送装车单,配送装车单最终要跟团长核销数据闭环。
采购管理:
- 采购需求汇总:每天16:00截单后,系统根据订单自动计算各商品的需求总量,减去当前库存余量,生成"采购需求单"。
- 供应商管理:维护供应商资质、供应品类、报价历史。
- 采购单管理:将采购需求单转成采购单,分配给供应商,记录期望到货时间、实际到货时间。
分拣管理:
- 分拣任务生成:根据采购到货批次,生成当天分拣任务。
- 分拣操作:分拣员按订单清单分批分拣,每个商品称重、装袋、贴标。
- 分拣异常登记:缺货、损货、缺重等异常情况的登记,触发售后或者订单变更流程。
配送管理:
- 配送线路规划:按自提点地址聚合生成配送线路。
- 装车单:按线路生成装车单,司机查看装车商品明细和自提点顺序。
- 配送状态回传:司机确认"出发""已到""完成"三个状态,用户在端上能看到配送进度。
这三个子系统的数据流转是"驱动式"的:运营在后台发布商品 → 用户下单 → 系统生成采购需求 → 采购生成采购单 → 到货生成批次 → 分拣任务 → 配送装车单 → 团长核销。每一步都是上一步的延续,所以数据字典里的状态字段设计必须能够串起这条链。
3. 核心业务流程图的语言化推演:订单、采购、履约、售后四张主图
我习惯在写文档时把业务流程用一种"语言化伪代码"的方式描述出来,而不是直接甩visio图。因为很多开发看不懂流程图,但能看懂"如果...那么...否则..."。
3.1 用户下单到核销的主链路
这条链路是系统最核心的路径,描述如下:
code复制用户打开小程序 → 浏览当日团购商品列表 → 将商品加入购物车(选择自提点)→
提交订单(系统校验:下单时间是否在截单时间前、自提点是否有效、库存是否充足)→
支付成功 → 生成订单和订单明细(含自提码)→
订单进入"待分拣"状态,汇总到当天的采购需求池 →
截单后系统生成采购单 → 供应商配送 → 仓库到货生成批次入库 →
分拣员按团长维度生成分拣单 → 分拣完成 → 配送司机装车 → 送达团长自提点 →
团长确认收货 → 用户收到"到货提醒" → 用户到店提货,出示自提码 →
团长核销 → 订单状态变为"已完成" → 佣金进入待结算池
这个流程里有几个节点必须做状态校验:
- 支付节点:支付成功之后,订单状态必须从"待付款"原子性地变为"待分拣"。如果支付回调丢失,要靠定时任务轮询微信支付结果做补偿。
- 截单节点:16:00截单是一个"软状态"。这里有个经验:系统不能硬性在16:00:00.000把下单入口关闭,因为用户付款需要几秒钟,会出现"用户15:59:58提交订单但16:00:01才支付成功"的场景。我们的方案是16:00关闭"新订单创建"入口,但留5分钟宽限期给还没有完成的支付。
- 核销节点:核销是幂等操作,同一个自提码只能核销一次,第二次点击必须提示"已核销"。这是要放进DDL唯一约束的。
3.2 采购到入库的补货链路
采购链路的核心是"需求计算"。在"升鲜宝"里,这个计算逻辑是:
采购需求数 = 当日销售需求量 - 当前可用库存量 + 安全库存缓冲量
其中"当日销售需求量"通过订单明细里的商品总数汇总得到。注意不能用SPU维度汇总,必须用"SPU+批次"维度。因为同一个SPU可能有两个批次,一个快过期了,一个刚入库,必须优先消耗早批次。
采购单生成之后,供应商发货、到货、质检、入库,这个过程会有三种异常:
- 缺量:供应商只送了80%的货,系统需要支持"部分收货",将差额转成"待补发"状态。
- 多送:多送的货可以拒收,也可以做"赠批入库",赠批入库的成本价为0,售价为零,直接进入库存。
- 品质不达标:质检不合格的货品做退货处理,同时系统需要提醒运营是否有用户订单受影响,提前准备替代方案。
3.3 分拣与配送的逆向链路
分拣过程中如果发现"货不够"(比如采购量不足),就会出现"缺货订单"。这时候系统要支持两种处理:
- 自动退款:缺货的订单明细自动生成退款单,退款原路退回,并给用户推送通知。
- 等价换货:用户同意后,用另一款等价商品替换缺货商品,订单明细中新增一条换货记录。
这两种动作都会对订单明细的"退款金额""换货状态"字段产生变化。所以我在订单明细表里设计了"原始金额、实际金额、退款金额"三个字段,而不是只保留一个金额字段。
3.4 售后的完整生命周期
售后流程在生鲜里比普通电商更复杂,因为"证据链"很关键:
code复制用户提交售后申请(选择订单明细+上传坏损照片)→
团长端收到待审核记录 → 团长核实(可以发起用户退款,也可以驳回)→
平台运营抽审(按比例抽查售后工单)→ 审核通过 → 退款原路返回 →
售后工单状态变更为"已完成" → 售后数据进入损耗报表
这里有一个设计上的关键决策:售后单必须是"订单明细"级别的,不能是"订单"级别的。因为生鲜订单往往包含多种商品,可能土豆没问题、青菜坏了。如果订单级售后,整个订单的金额都会受影响,后端处理非常麻烦。
所以数据字典里的order_item(订单明细表)和after_sale(售后表)必须建立order_item_id外键关系。
4. 数据字典与DDL口径:每一张表、每一个字段为什么这么设计
数据字典不是一个简单的"字段清单",它是系统设计的底层契约。我见过太多项目,前期不认真定数据字典,开发做到一半发现字段不够用,临时加字段,最终导致接口文档、前端页面、后端逻辑全部混乱。
4.1 设计规范:表名、字段名、类型的统一口径
在做DDL之前,我们团队有一套统一的口径:
- 表名:小写下划线,业务模块前缀,比如
user_info是用户表、order_info是订单主表、order_item是订单明细表、product_spu是商品表、product_batch是批次表。 - 字段名:小写驼峰,不做任何缩写(除非业务通用缩写),比如
created_at、updated_at、order_status。 - 主键:统一使用
BIGINT UNSIGNED AUTO_INCREMENT,业务上不使用UUID作为主键。 - 公共字段:每张表必须包含
id、created_at、updated_at、deleted(逻辑删除标记)四个公共字段。逻辑删除比物理删除安全得多,尤其是在做对账和业务核查时。 - 金额字段:统一使用
DECIMAL(10,2),单位是"元"。这里有个很多人会踩的坑:如果用DOUBLE存金额,一旦出现浮点误差,财务对账绝对对不上。必须用DECIMAL。 - 数量字段:统一使用
INT,单位是"克"或"份",在字段注释里写清楚。 - 时间字段:统一使用
DATETIME,不使用TIMESTAMP。因为TIMESTAMP的范围到2038年(32位溢出问题),而且受时区影响,DATETIME更可控。 - 状态位:统一用
TINYINT,范围为0-9,不用CHAR类型存'pending'、'paid'这样的英文状态。 - 索引规范:每个外键字段必须建索引;高频查询字段必须建联合索引;索引命名规范为
idx_字段名。
4.2 核心表字段设计(附DDL示例)
我不能把每张表都贴出来,那样篇幅太长,但我会挑几张核心的做详细说明,这个思路可以复用到其他表。
4.2.1 用户表 user_info
用户表是系统的基本盘。需要记录的字段包括:微信openid、unionid、昵称、头像、手机号、性别、所在城市、注册来源、状态标记。
sql复制CREATE TABLE `user_info` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`open_id` VARCHAR(64) NOT NULL COMMENT '微信openid',
`union_id` VARCHAR(64) DEFAULT NULL COMMENT '微信unionid',
`nick_name` VARCHAR(64) DEFAULT NULL COMMENT '昵称',
`avatar_url` VARCHAR(255) DEFAULT NULL COMMENT '头像URL',
`phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
`gender` TINYINT DEFAULT 0 COMMENT '性别:0未知,1男,2女',
`city` VARCHAR(64) DEFAULT NULL COMMENT '城市',
`register_source` TINYINT DEFAULT 1 COMMENT '注册来源:1小程序,2美团,3抖音',
`user_status` TINYINT DEFAULT 1 COMMENT '状态:1正常,2禁用',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` TINYINT DEFAULT 0 COMMENT '逻辑删除:0未删,1已删',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_open_id` (`open_id`),
KEY `idx_phone` (`phone`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户信息表';
设计要点:open_id必须唯一索引,因为同一个用户不能有多个openid;phone加普通索引是因为运营经常要按手机号查用户;deleted字段虽然在业务层用不到,但做数据修复时能救急。
4.2.2 订单主表 order_info
订单表是整个系统最核心的一张表,字段设计必须考虑业务全链路。
sql复制CREATE TABLE `order_info` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`order_no` VARCHAR(32) NOT NULL COMMENT '订单号:格式yyyyMMdd+随机5位+校验位',
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
`leader_id` BIGINT UNSIGNED NOT NULL COMMENT '团长ID',
`pickup_point_id` BIGINT UNSIGNED NOT NULL COMMENT '自提点ID',
`order_status` TINYINT DEFAULT 0 COMMENT '订单状态:0待付款,1待分拣,2分拣中,3配送中,4待自提,5已完成,6已取消,7售后处理中',
`pay_amount` DECIMAL(10,2) DEFAULT 0.00 COMMENT '支付金额',
`total_amount` DECIMAL(10,2) DEFAULT 0.00 COMMENT '订单原价总额',
`discount_amount` DECIMAL(10,2) DEFAULT 0.00 COMMENT '优惠金额',
`freight_amount` DECIMAL(10,2) DEFAULT 0.00 COMMENT '运费',
`pay_type` TINYINT DEFAULT 1 COMMENT '支付方式:1微信支付,2余额支付',
`pay_time` DATETIME DEFAULT NULL COMMENT '支付时间',
`pickup_code` VARCHAR(12) NOT NULL COMMENT '自提码',
`pickup_time` DATETIME DEFAULT NULL COMMENT '实际提货时间',
`remark` VARCHAR(255) DEFAULT NULL COMMENT '用户备注',
`cancel_reason` VARCHAR(255) DEFAULT NULL COMMENT '取消原因',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` TINYINT DEFAULT 0 COMMENT '逻辑删除:0未删,1已删',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user_id` (`user_id`),
KEY `idx_leader_id` (`leader_id`),
KEY `idx_order_status` (`order_status`),
KEY `idx_pickup_point_id` (`pickup_point_id`),
KEY `idx_pay_time` (`pay_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单主表';
设计要点:
-
订单号设计:
yyyyMMdd+随机5位+校验位,32位以内。这个设计能在订单量不算超大的情况下保证唯一性,也便于按日期范围做日志归档。校验位用的是Luhn算法,可以快速发现人工录入的错误。 -
订单状态机设计:我专门把状态流转画在文档里,避免开发"自由发挥":
0待付款→ 支付成功 →1待分拣1待分拣→ 截单/生成分拣任务 →2分拣中2分拣中→ 分拣完成 →3配送中3配送中→ 团长确认收货 →4待自提4待自提→ 用户核销 →5已完成0待付款超时未支付 →6已取消- 任意非终态 → 售后流转 →
7售后处理中
-
权益相关:
pickup_code必须唯一,核销时会用它做查询。整个流程中最容易出性能瓶颈的就是"按user_id查订单列表",所以这个索引必不可少。
4.2.3 商品与批次表 product_spu + product_batch
商品表(SPU)存基础信息,批次表存库存和成本信息。
sql复制CREATE TABLE `product_spu` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`spu_no` VARCHAR(32) NOT NULL COMMENT '商品编码',
`spu_name` VARCHAR(128) NOT NULL COMMENT '商品名称',
`category_id` BIGINT UNSIGNED NOT NULL COMMENT '分类ID',
`main_image` VARCHAR(255) DEFAULT NULL COMMENT '主图',
`sales_unit` TINYINT DEFAULT 1 COMMENT '销售单位:1份,2斤,3两,4个,5袋',
`sales_spec` VARCHAR(64) DEFAULT NULL COMMENT '规格描述:如500g/份',
`is_multi_batch` TINYINT DEFAULT 0 COMMENT '是否多批次销售:0否,1是',
`introduction` TEXT COMMENT '商品介绍',
`sales_status` TINYINT DEFAULT 1 COMMENT '销售状态:1上架,2下架,3删除',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted` TINYINT DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_spu_no` (`spu_no`),
KEY `idx_category_id` (`category_id`),
KEY `idx_sales_status` (`sales_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='商品SPU表';
CREATE TABLE `product_batch` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`spu_id` BIGINT UNSIGNED NOT NULL COMMENT '商品SPU ID',
`supplier_id` BIGINT UNSIGNED NOT NULL COMMENT '供应商ID',
`batch_no` VARCHAR(32) NOT NULL COMMENT '批次号:采购单号+日期',
`purchase_price` DECIMAL(10,2) NOT NULL COMMENT '采购单价',
`sell_price` DECIMAL(10,2) NOT NULL COMMENT '销售单价',
`total_stock` INT NOT NULL COMMENT '采购入库总量(单位:克)',
`remaining_stock` INT NOT NULL COMMENT '当前可用余量(单位:克)',
`production_date` DATE DEFAULT NULL COMMENT '生产日期',
`expire_date` DATE DEFAULT NULL COMMENT '保质期到期日',
`qc_status` TINYINT DEFAULT 1 COMMENT '质检状态:1待检,2合格,3不合格',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted` TINYINT DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_spu_id` (`spu_id`),
KEY `idx_supplier_id` (`supplier_id`),
KEY `idx_expire_date` (`expire_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='商品批次表';
设计要点:库存余量remaining_stock必须用INT(单位克),不能用DECIMAL。因为按克存储之后,所有累计、扣减操作都是整数运算,不会出现精度问题,也方便聚合。
4.3 数据字典的"半自动"生成方式
很多团队在写数据字典的时候还是word文档手打,我强烈建议用工具半自动化生成。我实际用的方案是:
- 先写DDL建表脚本。
- 用
mysqldump导出表结构后,通过一个小脚本解析DDL,生成markdown格式的数据字典。 - 人工补充字段的业务说明和枚举值含义。
这样做的好处是:数据字典和实际数据库结构始终保证一致。如果改了表结构,重新跑一遍脚本,文档就同步更新了。完全靠手工维护,时间长了文档一定失真。
5. 后台权限设计:RBAC模型的落地与数据权限隔离
后台权限设计,是所有企业级系统里最容易出问题但最不受重视的模块。社区团购的后台尤其棘手,因为涉及的角色特别多:平台超管、运营专员、采购专员、分拣员、配送司机、供应商、团长,每类人要访问同一套后台系统,但是能看的数据范围完全不一样。
5.1 为什么不能简单地"建几个角色,给几个菜单"
第一版设计文档里,我曾经天真地给每个角色配一套菜单权限就结束了。后来在测试阶段发现一个大问题:运营专员A负责A片区,他打开订单列表,应该只能看到A片区的订单,但菜单权限只能控制"能不能打开订单列表页",控制不了"在订单列表页里看到哪些数据"。
这就牵扯出权限设计的第二层——数据权限。
5.2 三层权限模型:菜单权限、按钮权限、数据权限
最终确定的权限模型是标准的RBAC扩展,分三层:
第一层:菜单权限(能进哪个系统/模块)
角色与菜单的关联表,控制左侧导航栏和页面路由。比如:
- 平台超管:能看到全部菜单。
- 运营专员:能看到"商品管理、活动管理、订单管理、用户管理、售后管理、数据报表"。
- 分拣员:只能看到"分拣任务""分拣异常登记"。
第二层:按钮权限(能在这个页面上做什么操作)
比如订单列表页,运营专员可以看、可以改备注,但不能批量关单;采购专员可以看采购单,但不能修改已审核的采购单。按钮权限的粒度一般会细化到"新增、编辑、删除、导出、审核、关闭、核销"这些动作。
第三层:数据权限(能看到哪些数据)
这是最关键的。数据权限不是靠"额外表"来实现的,而是在每个列表查询SQL里注入一条数据范围条件。我们最终用"数据规则配置表"来维护:
code复制角色:运营专员
数据规则:WHERE city_id = ${user.city_id}
字段值从当前登录用户身上取,实现"按城市隔离"。对于团长角色,数据规则是:
code复制角色:团长
数据规则:WHERE leader_id = ${user.user_id}
这样团长登录后台(实际上团长用的是团长端小程序,但底层后台管理页面也有登录入口)只能看到自己的订单和自己的佣金数据。
5.3 权限相关的五张表设计
权限模型的物理落地,需要五张表。这里是一个标准的MySQL实现:
sql复制-- 用户-角色关联表
CREATE TABLE `user_role` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
`role_id` BIGINT UNSIGNED NOT NULL COMMENT '角色ID',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_role` (`user_id`, `role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表';
-- 角色表
CREATE TABLE `sys_role` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`role_code` VARCHAR(32) NOT NULL COMMENT '角色编码:super_admin, operator, purchaser, picker, driver, supplier, leader',
`role_name` VARCHAR(32) NOT NULL COMMENT '角色名称',
`data_scope` TINYINT DEFAULT 1 COMMENT '数据权限范围:1个人,2本区域,3本城市,4全部',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_role_code` (`role_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表';
-- 菜单表
CREATE TABLE `sys_menu` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`parent_id` BIGINT UNSIGNED DEFAULT 0 COMMENT '父级菜单ID',
`menu_name` VARCHAR(32) NOT NULL COMMENT '菜单名称',
`menu_type` TINYINT DEFAULT 1 COMMENT '菜单类型:1目录,2菜单,3按钮',
`path` VARCHAR(128) DEFAULT NULL COMMENT '路由地址',
`permission_code` VARCHAR(64) DEFAULT NULL COMMENT '权限标识:order:list, order:export',
`sort_order` INT DEFAULT 0 COMMENT '排序',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜单权限表';
-- 角色-菜单关联表
CREATE TABLE `role_menu` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`role_id` BIGINT UNSIGNED NOT NULL,
`menu_id` BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_role_menu` (`role_id`, `menu_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色菜单关联表';
5.4 权限设计中最容易忽略的三件事
第一件事:部门/区域其实是变动的。 运营专员可能从A城市调到B城市,如果"数据规则"硬编码在代码里,每次变动都要发版。所以在数据规则表里,我们用${user.city_id}这种表达式,让权限过滤条件动态绑定,而不是写死。
第二件事:导出权限要单独控制。 很多系统到了"导出Excel"这一步就绕过权限校验了,导致数据泄露。在"升鲜宝"里,所有导出按钮都是单独的按钮权限控制,而且导出记录要写入操作日志。
第三件事:权限修改要实时生效。 这里我推荐用Redis缓存用户的权限集合,权限变更时主动清除对应缓存的key。不然运营改了一个角色权限,用户要等缓存过期才能生效,容易造成"我改了权限但没效果"的假投诉。
6. 从设计文档到源码落地:我在实际开发中的几个决定性取舍
最后这部分,讲几个我在落地过程中最值得分享的经验,包括踩过的坑。它们是设计文档之外的东西,但恰恰决定了这个系统能不能真正跑起来。
6.1 数据一致性:订单状态不能只靠数据库字段
社区团购的订单状态流转非常频繁,从待付款到已完成,中间要经过5-6次状态变更。每次状态变更都涉及到订单表更新、库存扣减、日志插入。如果只用MySQL的UPDATE,在高并发下会出现两个问题:
- 乐观锁缺陷:A线程把状态改成"待分拣",B线程同时读到旧状态"待付款",也去执行支付回调,最后状态被覆盖。
- 长事务风险:状态变更如果和库存扣减放在一个事务里,一旦其中一个步骤失败,整个事务回滚,用户体验很差。
我的最终方案是状态机和消息队列:
- 订单状态放在数据库里,但状态变更必须走统一的状态机接口,这个接口内部校验"当前状态是否可以流转到目标状态"。
- 订单状态变更成功之后,发一条消息到RocketMQ/ RabbitMQ,由消费者去更新库存、推送通知、写操作日志。
这样在高峰期,即使订单状态变更和库存扣减不同步,也能通过消息队列的重试机制保证最终一致性。这也是生鲜团购系统里我最满意的一个设计决策。
6.2 库存扣减:用Redis预占,用MySQL兜底
生鲜商品库存扣减的时机比较特殊:用户在截单前下单时预占库存,截单后系统汇总采购需求。如果用户在支付前取消了订单,预占库存要释放。
实际开发中我用Redis的HINCRBY做扣减,每个商品一个hash key,field是批次号,value是剩余库存。扣减逻辑:
- 用户提交订单时,Redis对对应批次的
remaining_stock执行DECRBY,如果结果大于等于0,说明扣减成功。 - 如果扣减失败(库存不足),返回"库存不足"。
- 订单支付成功后,把扣减结果异步落库到MySQL的
product_batch.remaining_stock。
为什么要Redis和MySQL两端都存库存?因为Redis保证的是"并发扣减不错账",MySQL保证的是"最终对账有依据"。每天凌晨会跑一个对账任务,核对两端数据是否一致,不一致的自动生成差异单,人工排查。
6.3 自提码的生成与幂等性
自提码是一串12位的数字加字母混合码,我建议格式:取货日期(4位)+ 随机8位大写字母数字。生成时保证唯一性需要依赖数据库的唯一索引,同时生成后不能修改。
核销接口必须支持幂等:同一个自提码同一时间只能有一个核销请求成功。实现方案是核销时先把order_info.pickup_code更新为"已核销",如果更新影响行数等于0,说明已经被核销过,直接返回"该订单已核销"。
另外,我的一个生产环境教训:自提码要支持模糊搜索,方便团长在核销高峰期快速查询。我加了一个索引,还做了“只取后四位+手机号”的组合搜索模式,用户体验提升非常明显。
6.4 源码交付时,必须附带的三类文档
最后说说"源码"这个词。很多项目交付时只有一个代码仓库,这是不够的。一个能真正被接手团队使用起来的源码交付,至少需要以下三类配套文档:
- 部署文档:环境要求、数据库初始化脚本、Redis配置、消息队列配置、nginx反向代理配置、开机自启动脚本。没有部署文档的源码等于废品。
- 接口文档:包含所有HTTP接口的请求/响应示例、错误码定义。我这边推荐的方案是使用Swagger/OpenAPI自动生成,开发改完代码,文档同步更新。
- 数据字典和DDL脚本:在"升鲜宝"里,我把所有建表语句集中放到
sql/init/目录下,然后附带一份DATA_DICTIONARY.md,方便DBA和开发快速查阅。
我的经验是:源码交付的"可维护性"比"功能完整性"更重要。 接手团队拿到代码之后,如果能在一个小时内把环境跑起来、在数据库里查到自己想要的表结构,这个交付就是成功的。
写在最后的实操建议
如果在系统正式上线前,让我只给团队提一条建议,我会说:先在测试环境完整跑通一次"用户下单 → 供应商采购 → 分拣入库 → 配送 → 团长核销 → 售后"的端到端全链路,而且要用真实的商品、真实的微信支付沙箱和真实的用户账号,不要用模拟数据。因为这个链路里任何一环出错,都会直接导致用户收不到货、团长拿不到佣金、运营看不明白报表。
我在反复跑这个链路的过程中发现,80%的Bug都出现在"边界条件"上:截单时间前后一分钟的订单、库存只剩最后一份的商品、售后金额超过订单金额的极端情况、团长核销时网络断了。这些场景在单元测试里很难覆盖,只有端到端演练才能暴露出来。
最后补一个小细节:所有订单金额字段,不管是前端、后端还是数据库,都统一用"分"做单位存储(也就是整数类型),展示时再除以100。这样能彻底避开浮点精度问题,包括微信支付的金额计算、佣金比例计算、对账分账,全部都可以用整数运算。这个习惯我从一开始就坚持到现在,从来没有因为金额问题出过线上事故。
